Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 65 additions & 23 deletions src/api/forms/service/definition.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {
Engine,
FormDefinitionRequestType,
Expand Down Expand Up @@ -71,8 +71,8 @@
}

/**
* 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]
Expand All @@ -82,16 +82,34 @@
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)
}

/**
Expand Down Expand Up @@ -318,6 +336,35 @@
}
}

/**
* 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
Expand Down Expand Up @@ -356,29 +403,26 @@
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 {
await session.withTransaction(async () => {
// 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
Expand All @@ -391,8 +435,6 @@
session
)

await createFormVersion(formId, session)

// Make payment key live if a pending one is stored
await makePaymentKeyLive(formHasPayment, formId, session)

Expand Down Expand Up @@ -675,6 +717,6 @@
}

/**
* @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'
*/
132 changes: 129 additions & 3 deletions src/api/forms/service/definition.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -224,14 +227,75 @@ 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
)
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
Expand Down Expand Up @@ -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<FormMetadataDocument>} */ ({
...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} */ ({})
Expand Down Expand Up @@ -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'
*/
Loading