diff --git a/src/api/forms/service/definition.js b/src/api/forms/service/definition.js index a3bb366f..31902a08 100644 --- a/src/api/forms/service/definition.js +++ b/src/api/forms/service/definition.js @@ -71,8 +71,8 @@ export async function listForms(options) { } /** - * Retrieves the form definition content for a given form ID. - * Injects the latest version metadata into definition.metadata.$$__formVersion. + * Retrieves the form definition for a given form ID, with $$__formVersion + * injected: form.live.version for Live, latest for Draft. * @param {string} formId - the ID of the form * @param {FormStatus} state - the form state * @param {ClientSession | undefined} [session] @@ -82,16 +82,34 @@ export async function getFormDefinition( state = FormStatus.Draft, session ) { - const [definition, latestVersion] = await Promise.all([ + if (state === FormStatus.Live) { + const [definition, form] = await Promise.all([ + formDefinition.get(formId, state, session), + formMetadata.get(formId, session) + ]) + + const live = + /** @type {(FormMetadataState & { version?: FormVersionMetadata }) | undefined} */ ( + form.live + ) + + if (!live?.version) { + return definition + } + + return injectFormVersion(definition, live.version) + } + + const [draftDefinition, latestVersion] = await Promise.all([ formDefinition.get(formId, state, session), formVersions.getLatestVersion(formId, session) ]) if (!latestVersion) { - return definition + return draftDefinition } - return injectFormVersion(definition, latestVersion) + return injectFormVersion(draftDefinition, latestVersion) } /** @@ -318,6 +336,35 @@ export async function makePaymentKeyLive(formHasPayment, formId, session) { } } +/** + * Builds the $set payload for updating form metadata when publishing to live. + * Pins the new version metadata onto form.live so subsequent live reads can + * inject the correct $$__formVersion. + * @param {FormMetadataState | undefined} existingLive + * @param {Date} now + * @param {FormMetadataAuthor} author + * @param {FormVersionMetadata} pinnedVersion + */ +function buildPublishLiveSet(existingLive, now, author, pinnedVersion) { + if (!existingLive) { + return { + live: { + updatedAt: now, + updatedBy: author, + createdAt: now, + createdBy: author, + version: pinnedVersion + }, + updatedAt: now, + updatedBy: author + } + } + return { + ...partialAuditFields(now, author, FormStatus.Live), + 'live.version': pinnedVersion + } +} + /** * Creates the live form from the current draft state * @param {string} formId - ID of the form @@ -356,22 +403,7 @@ export async function createLiveFromDraft(formId, author) { paymentKeyExists ) - // Build the live state const now = new Date() - const set = !form.live - ? { - // Initialise the live state - live: { - updatedAt: now, - updatedBy: author, - createdAt: now, - createdBy: author - }, - updatedAt: now, - updatedBy: author - } - : partialAuditFields(now, author, FormStatus.Live) // Partially update the live state fields - const session = client.startSession() try { @@ -379,6 +411,18 @@ export async function createLiveFromDraft(formId, author) { // Copy the draft form definition await formDefinition.createLiveFromDraft(formId, session) + const newVersion = await createFormVersion(formId, session) + const pinnedVersion = { + versionNumber: newVersion.versionNumber, + createdAt: newVersion.createdAt + } + + logger.info( + `Pinning version ${newVersion.versionNumber} onto form.live for form ID ${formId}` + ) + + const set = buildPublishLiveSet(form.live, now, author, pinnedVersion) + logger.info(`Removing form metadata (draft) for form ID ${formId}`) // Update the form with the live state and clear the draft @@ -391,8 +435,6 @@ export async function createLiveFromDraft(formId, author) { session ) - await createFormVersion(formId, session) - // Make payment key live if a pending one is stored await makePaymentKeyLive(formHasPayment, formId, session) @@ -675,6 +717,6 @@ export async function reorderDraftFormDefinitionComponents( } /** - * @import { FormDefinition, FormMetadataAuthor, FormMetadata, FilterOptions, QueryOptions } from '@defra/forms-model' + * @import { FormDefinition, FormMetadataAuthor, FormMetadataState, FormVersionMetadata, FormMetadata, FilterOptions, QueryOptions } from '@defra/forms-model' * @import { ClientSession } from 'mongodb' */ diff --git a/src/api/forms/service/definition.test.js b/src/api/forms/service/definition.test.js index abcd0087..e1274e76 100644 --- a/src/api/forms/service/definition.test.js +++ b/src/api/forms/service/definition.test.js @@ -41,7 +41,10 @@ import { formMetadataWithLiveDocument, mockFilters } from '~/src/api/forms/service/__stubs__/service.js' -import { mockFormVersionDocument } from '~/src/api/forms/service/__stubs__/versioning.js' +import { + buildFormVersionDocument, + mockFormVersionDocument +} from '~/src/api/forms/service/__stubs__/versioning.js' import { FORM_VERSION_METADATA_KEY, createDraftFromLive, @@ -224,7 +227,11 @@ describe('Forms service', () => { createdAt: dateUsedInFakeTime, createdBy: author, updatedAt: dateUsedInFakeTime, - updatedBy: author + updatedBy: author, + version: { + versionNumber: mockFormVersionDocument.versionNumber, + createdAt: mockFormVersionDocument.createdAt + } }) expect(dbMetadataOperationArgs[1].$set?.updatedAt).toEqual( dateUsedInFakeTime @@ -232,6 +239,63 @@ describe('Forms service', () => { expect(dbMetadataOperationArgs[1].$set?.updatedBy).toEqual(author) }) + it('should pin the new version on form.live when publishing for the first time', async () => { + const newVersion = buildFormVersionDocument({ + versionNumber: 42, + createdAt: new Date('2026-04-22T11:05:19.998Z') + }) + jest + .mocked(versioningService.createFormVersion) + .mockResolvedValueOnce(newVersion) + jest.mocked(formDefinition.get).mockResolvedValueOnce({ + ...definition, + outputEmail: 'test@defra.gov.uk' + }) + + const dbSpy = jest.spyOn(formMetadata, 'update') + + await createLiveFromDraft('123', author) + + expect(dbSpy.mock.calls[0][1].$set?.live).toEqual( + expect.objectContaining({ + version: { + versionNumber: 42, + createdAt: new Date('2026-04-22T11:05:19.998Z') + } + }) + ) + }) + + it('should pin the new version via dotted notation when re-publishing an existing live form', async () => { + jest + .mocked(formMetadata.get) + .mockResolvedValue(formMetadataWithLiveDocument) + const newVersion = buildFormVersionDocument({ + versionNumber: 99, + createdAt: new Date('2026-04-22T12:00:00Z') + }) + jest + .mocked(versioningService.createFormVersion) + .mockResolvedValueOnce(newVersion) + jest.mocked(formDefinition.get).mockResolvedValueOnce({ + ...definition, + outputEmail: 'test@defra.gov.uk' + }) + + const dbSpy = jest.spyOn(formMetadata, 'update') + + await createLiveFromDraft('123', author) + + expect(dbSpy.mock.calls[0][1].$set).toEqual( + expect.objectContaining({ + 'live.version': { + versionNumber: 99, + createdAt: new Date('2026-04-22T12:00:00Z') + } + }) + ) + }) + it('should fail to create a live state from existing draft form when there is no start page', async () => { const draftDefinitionNoStartPage = /** @type {FormDefinition} */ ( definition @@ -1604,6 +1668,68 @@ describe('Forms service', () => { }) }) + describe('getFormDefinition', () => { + it('should inject the pinned version from form.live when fetching the live definition', async () => { + const pinnedVersionNumber = 42 + const pinnedCreatedAt = new Date('2024-03-25T10:00:00Z') + + jest.mocked(formMetadata.get).mockResolvedValueOnce( + /** @type {WithId} */ ({ + ...formMetadataWithLiveDocument, + live: { + .../** @type {FormMetadataState} */ ( + formMetadataWithLiveDocument.live + ), + version: { + versionNumber: pinnedVersionNumber, + createdAt: pinnedCreatedAt + } + } + }) + ) + jest.mocked(formDefinition.get).mockResolvedValueOnce(definition) + + const result = await getFormDefinition('123', FormStatus.Live) + + expect(result).toEqual({ + ...definition, + metadata: { + [FORM_VERSION_METADATA_KEY]: { + versionNumber: pinnedVersionNumber, + createdAt: pinnedCreatedAt + } + } + }) + }) + + it('should not inject $$__formVersion when fetching a legacy live definition without a pinned version', async () => { + jest + .mocked(formMetadata.get) + .mockResolvedValueOnce(formMetadataWithLiveDocument) + jest.mocked(formDefinition.get).mockResolvedValueOnce(definition) + + const result = await getFormDefinition('123', FormStatus.Live) + + expect(result).toEqual(definition) + }) + + it('should inject the latest version when fetching the draft definition', async () => { + jest.mocked(formDefinition.get).mockResolvedValueOnce(definition) + + const result = await getFormDefinition('123', FormStatus.Draft) + + expect(result).toEqual({ + ...definition, + metadata: { + [FORM_VERSION_METADATA_KEY]: { + versionNumber: mockFormVersionDocument.versionNumber, + createdAt: mockFormVersionDocument.createdAt + } + } + }) + }) + }) + describe('makePaymentKeyLive', () => { const formId = 'ea8154b9-e724-4bb4-a9cb-46f4159a53fa' const mockSession = /** @type {ClientSession} */ ({}) @@ -1645,6 +1771,6 @@ describe('Forms service', () => { }) /** - * @import { FormDefinition, FormMetadata, FormMetadataDocument, QueryOptions } from '@defra/forms-model' + * @import { FormDefinition, FormMetadata, FormMetadataDocument, FormMetadataState, QueryOptions } from '@defra/forms-model' * @import { ClientSession, WithId } from 'mongodb' */