diff --git a/api/.jest/setEnvVars.ts b/api/.jest/setEnvVars.ts index 1843d435..6c95613d 100644 --- a/api/.jest/setEnvVars.ts +++ b/api/.jest/setEnvVars.ts @@ -7,6 +7,7 @@ process.env.PAY_ACCOUNT_TRANSACTIONS_URL = "https://selfservice.payments.service process.env.SUBMISSION_EMAIL_ADDRESS = "pye@cautionyourblast.com"; process.env.POST_EMAILS = '{"the British Embassy Tirana": "pye+albania@cautionyourblast.com", "the British Embassy Rome": "pye+rome@cautionyourblast.com"}'; process.env.NOTIFY_TEMPLATE_AFFIRMATION_USER_CONFIRMATION = "ABC"; +process.env.NOTIFY_TEMPLATE_AFFIRMATION_USER_CONFIRMATION_SIMPLIFIED = "ABC"; process.env.NOTIFY_TEMPLATE_CNI_USER_CONFIRMATION = "ABC"; process.env.NOTIFY_TEMPLATE_CNI_USER_POSTAL_CONFIRMATION = "ABC"; process.env.NOTIFY_TEMPLATE_EXCHANGE_USER_CONFIRMATION = "ABC"; diff --git a/api/config/custom-environment-variables.json b/api/config/custom-environment-variables.json index 140cd1a3..091b55dc 100644 --- a/api/config/custom-environment-variables.json +++ b/api/config/custom-environment-variables.json @@ -5,6 +5,7 @@ "Notify": { "Template": { "affirmationUserConfirmation": "NOTIFY_TEMPLATE_AFFIRMATION_USER_CONFIRMATION", + "affirmationUserConfirmationSimplified": "NOTIFY_TEMPLATE_AFFIRMATION_USER_CONFIRMATION_SIMPLIFIED", "cniUserConfirmation": "NOTIFY_TEMPLATE_CNI_USER_CONFIRMATION", "cniUserPostalConfirmation": "NOTIFY_TEMPLATE_CNI_USER_POSTAL_CONFIRMATION", "exchangeUserConfirmation": "NOTIFY_TEMPLATE_EXCHANGE_USER_CONFIRMATION", diff --git a/api/src/middlewares/services/QueueService/QueueService.ts b/api/src/middlewares/services/QueueService/QueueService.ts index 33ab17c8..56550771 100644 --- a/api/src/middlewares/services/QueueService/QueueService.ts +++ b/api/src/middlewares/services/QueueService/QueueService.ts @@ -4,6 +4,9 @@ import config from "config"; import { ApplicationError } from "../../../ApplicationError"; import { QueueConfig, QueueName } from "./QueueConfig"; +const DEADLOCK_ERROR_CODE = "40P01"; +const CREATE_QUEUE_ATTEMPTS = 5; + type JobWithMetadata = T & { reference?: string; metadata?: { reference: string }; @@ -27,17 +30,39 @@ export class QueueService { NOTIFY_SEND: new QueueConfig("NOTIFY_SEND"), NOTIFY_PROCESS: new QueueConfig("NOTIFY_PROCESS"), }; - boss.start().then(() => { + boss.start().then(async () => { this.logger.info("Creating queues"); - this.createQueues(); + await this.createQueues(); + }).catch((err) => { + this.logger.error({ err }, "Queue startup failed"); + throw err; }); } + async createQueueWithRetry(queueName: string) { + for (let attempt = 1; attempt <= CREATE_QUEUE_ATTEMPTS; attempt++) { + try { + await this.boss.createQueue(queueName); + return; + } catch (err: any) { + const isDeadlock = err?.code === DEADLOCK_ERROR_CODE; + const isLastAttempt = attempt === CREATE_QUEUE_ATTEMPTS; + + if (!isDeadlock || isLastAttempt) { + throw err; + } + + this.logger.warn({ queueName, attempt, err }, "Deadlock detected while creating queue. Retrying."); + await new Promise((resolve) => setTimeout(resolve, attempt * 500)); + } + } + } + async createQueues() { const configs = Object.keys(this.configs); for (const key of configs) { this.logger.info(`Creating queue ${key}`); - await this.boss.createQueue(key); + await this.createQueueWithRetry(key); } } diff --git a/api/src/middlewares/services/SubmitService/SubmitService.ts b/api/src/middlewares/services/SubmitService/SubmitService.ts index 35470b66..74cedb48 100644 --- a/api/src/middlewares/services/SubmitService/SubmitService.ts +++ b/api/src/middlewares/services/SubmitService/SubmitService.ts @@ -68,7 +68,7 @@ export class SubmitService { this.logger.info({reference, caseProcessJob}, `SES_PROCESS job queued successfully for ${reference}`); } - const userProcessJob = await this.userService.sendToProcessQueue(answers, { reference, payment: metadata.pay, type, postal: metadata.postal }); + const userProcessJob = await this.userService.sendToProcessQueue(answers, { reference, payment: metadata.pay, type, postal: metadata.postal, source: metadata.source }); this.logger.info({ reference, userProcessJob }, `NOTIFY_PROCESS job queued successfully for ${reference}`); } catch (e) { diff --git a/api/src/middlewares/services/UserService/NotifyTemplates/MarriageUserTemplates.ts b/api/src/middlewares/services/UserService/NotifyTemplates/MarriageUserTemplates.ts index a0979528..f6cfd726 100644 --- a/api/src/middlewares/services/UserService/NotifyTemplates/MarriageUserTemplates.ts +++ b/api/src/middlewares/services/UserService/NotifyTemplates/MarriageUserTemplates.ts @@ -8,15 +8,24 @@ import { UserTemplateGroup } from "./UserTemplateGroup"; export class MarriageUserTemplates implements UserTemplateGroup { templates: { - affirmation: Record; + affirmation: { + simplified: Record; + legacy: Record; + }; exchange: Record; cni: Record>; }; constructor() { this.templates = { affirmation: { - inPerson: config.get("Notify.Template.affirmationUserConfirmation"), - postal: config.get("Notify.Template.affirmationUserConfirmation"), + simplified: { + inPerson: config.get("Notify.Template.affirmationUserConfirmationSimplified"), + postal: config.get("Notify.Template.affirmationUserConfirmationSimplified"), + }, + legacy: { + inPerson: config.get("Notify.Template.affirmationUserConfirmation"), + postal: config.get("Notify.Template.affirmationUserConfirmation"), + }, }, cni: { cni: { @@ -39,7 +48,7 @@ export class MarriageUserTemplates implements UserTemplateGroup { }; } - getTemplate(data: { answers: AnswersHashMap; metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean } }) { + getTemplate(data: { answers: AnswersHashMap; metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean; source?: string } }) { const { answers, metadata } = data; const { type } = data.metadata; @@ -51,13 +60,34 @@ export class MarriageUserTemplates implements UserTemplateGroup { if (type === "cni") { const serviceSubtype = (answers.service ?? "cni") as MarriageFormType; template = this.templates.cni[serviceSubtype][postalVariant]; + } else if (type === "affirmation") { + const isSimplified = metadata.source === "simplified-marriage-v1"; + template = this.templates.affirmation[isSimplified ? "simplified" : "legacy"][postalVariant]; + } else { + template = this.templates.exchange[postalVariant]; } - template ??= this.templates[type][postalVariant]; - const personalisationBuilder = getPersonalisationBuilder(type); - builder ??= personalisationBuilder[postalVariant]; + builder = personalisationBuilder[postalVariant]; + + // Only the simplified affirmation template uses a `duration` placeholder. + // Wrap the shared builder to append it without affecting other templates. + if (type === "affirmation" && metadata.source === "simplified-marriage-v1") { + const baseBuilder = builder; + builder = (answers: AnswersHashMap, meta: { reference: string; payment?: PayMetadata; type?: FormType }) => { + const personalisation = baseBuilder(answers, meta); + const country = answers.country as string; + const countryContext = additionalContexts.marriage.countries[country]; + const hasPreviousNameByDeedPoll = this.hasSimplifiedPreviousNameStatus(answers.nameChangedByDeedPoll); + const hasPreviousNameByMarriage = this.hasSimplifiedPreviousNameStatus(answers.nameChangedByMarriage); + return { + ...personalisation, + duration: countryContext?.duration || "3 months", + previousNames: hasPreviousNameByDeedPoll || hasPreviousNameByMarriage, + }; + }; + } return { template, @@ -76,4 +106,17 @@ export class MarriageUserTemplates implements UserTemplateGroup { return postalSupport ? "postal" : "inPerson"; } + + private hasSimplifiedPreviousNameStatus(value: string | boolean | undefined) { + if (typeof value !== "string") { + return false; + } + + const normalised = value.trim().toLowerCase(); + return ( + normalised === "once" || + normalised === "name changed more than once" || + normalised === "yesmorethanonce" + ); + } } diff --git a/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplateGroup.ts b/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplateGroup.ts index 16a8fbfa..b1a5be87 100644 --- a/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplateGroup.ts +++ b/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplateGroup.ts @@ -3,7 +3,7 @@ import { FormType, PayMetadata } from "../../../../types/FormDataBody"; type getTemplateParams = { answers: AnswersHashMap; - metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean }; + metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean; source?: string }; }; type Builder = ( diff --git a/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplates.ts b/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplates.ts index f2d9bf43..03b7da5b 100644 --- a/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplates.ts +++ b/api/src/middlewares/services/UserService/NotifyTemplates/UserTemplates.ts @@ -25,7 +25,7 @@ export class UserTemplates { } } - getTemplate(data: { answers: AnswersHashMap; metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean } }) { + getTemplate(data: { answers: AnswersHashMap; metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean; source?: string } }) { const { type } = data.metadata; if (this.isMarriageFormType(type)) { diff --git a/api/src/middlewares/services/UserService/UserService.ts b/api/src/middlewares/services/UserService/UserService.ts index 9cd0f0f0..976a7d8c 100644 --- a/api/src/middlewares/services/UserService/UserService.ts +++ b/api/src/middlewares/services/UserService/UserService.ts @@ -19,11 +19,11 @@ export class UserService { /** * Stores the user's answers in the queue for processing. */ - async sendToProcessQueue(answers: AnswersHashMap, metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean }) { + async sendToProcessQueue(answers: AnswersHashMap, metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean; source?: string }) { return await this.queueService.sendToQueue("NOTIFY_PROCESS", { answers, metadata }); } - async sendEmailToUser(data: { answers: AnswersHashMap; metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean } }) { + async sendEmailToUser(data: { answers: AnswersHashMap; metadata: { reference: string; payment?: PayMetadata; type: FormType; postal?: boolean; source?: string } }) { const { answers, metadata } = data; const { reference } = metadata; diff --git a/api/src/middlewares/services/UserService/__tests__/userService.test.ts b/api/src/middlewares/services/UserService/__tests__/userService.test.ts index b943a491..35ba33b1 100644 --- a/api/src/middlewares/services/UserService/__tests__/userService.test.ts +++ b/api/src/middlewares/services/UserService/__tests__/userService.test.ts @@ -5,6 +5,7 @@ jest.mock("config", () => ({ get(setting) { const templates = { "Notify.Template.affirmationUserConfirmation": "affirmation-template", + "Notify.Template.affirmationUserConfirmationSimplified": "affirmation-simplified-template", "Notify.Template.cniUserConfirmation": "cni-template", "Notify.Template.cniUserPostalConfirmation": "cni-postal-template", "Notify.Template.mscUserConfirmation": "msc-template", @@ -47,8 +48,9 @@ beforeEach(() => { describe("sendEmailToUser - Marriage templates", () => { test.each` - label | answers | metadata | template - ${"affirmation"} | ${{}} | ${{ type: "affirmation" }} | ${"affirmation-template"} + label | answers | metadata | template + ${"affirmation - legacy"} | ${{}} | ${{ type: "affirmation" }} | ${"affirmation-template"} + ${"affirmation - simplified"} | ${{}} | ${{ type: "affirmation", source: "simplified-marriage-v1" }} | ${"affirmation-simplified-template"} ${"exchange - inPerson"} | ${{}} | ${{ type: "exchange" }} | ${"exchange-template"} ${"exchange - postal"} | ${{}} | ${{ type: "exchange", postal: true }} | ${"exchange-postal-template"} ${"exchange - croatia"} | ${{ country: "Croatia" }} | ${{ type: "exchange" }} | ${"exchange-template"} @@ -66,6 +68,91 @@ describe("sendEmailToUser - Marriage templates", () => { }) ); }); + + test("affirmation - simplified adds previousNames when either name-change path exists", async () => { + await userService.sendEmailToUser({ + answers: { + firstName: "test", + emailAddress: "test@example.com", + country: "Italy", + nameChangedByMarriage: "name changed more than once", + nameChangedByDeedPoll: "false", + previousNameByMarriage: "No", + previousNameByDeedPoll: "No", + }, + metadata: { + type: "affirmation", + source: "simplified-marriage-v1", + reference: "ref", + }, + }); + + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + personalisation: expect.objectContaining({ + previousNames: true, + }), + }), + }) + ); + }); + + test("affirmation - simplified does not add previousNames for explicit negative values", async () => { + await userService.sendEmailToUser({ + answers: { + firstName: "test", + emailAddress: "test@example.com", + country: "Italy", + nameChangedByMarriage: "No", + nameChangedByDeedPoll: "false", + previousNameByMarriage: "No", + previousNameByDeedPoll: "Old Name", + }, + metadata: { + type: "affirmation", + source: "simplified-marriage-v1", + reference: "ref", + }, + }); + + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + personalisation: expect.objectContaining({ + previousNames: false, + }), + }), + }) + ); + }); + + test("affirmation - simplified adds previousNames for deed poll YesMoreThanOnce", async () => { + await userService.sendEmailToUser({ + answers: { + firstName: "test", + emailAddress: "test@example.com", + country: "Italy", + nameChangedByMarriage: "no", + nameChangedByDeedPoll: "YesMoreThanOnce", + }, + metadata: { + type: "affirmation", + source: "simplified-marriage-v1", + reference: "ref", + }, + }); + + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + personalisation: expect.objectContaining({ + previousNames: true, + }), + }), + }) + ); + }); }); describe("sendEmailToUser - certifyCopy", () => { diff --git a/api/src/middlewares/services/UserService/personalisation/PersonalisationBuilder/marriage/inPerson/getAdditionalPersonalisations.ts b/api/src/middlewares/services/UserService/personalisation/PersonalisationBuilder/marriage/inPerson/getAdditionalPersonalisations.ts index c0ccb28d..d71c0d93 100644 --- a/api/src/middlewares/services/UserService/personalisation/PersonalisationBuilder/marriage/inPerson/getAdditionalPersonalisations.ts +++ b/api/src/middlewares/services/UserService/personalisation/PersonalisationBuilder/marriage/inPerson/getAdditionalPersonalisations.ts @@ -9,9 +9,13 @@ export const personalisationTypeMap: Record { + const answers = { + maritalStatus: "Single", + oathType: "Non-religious", + }; + + expect(getAffirmationPersonalisations(answers)).toStrictEqual({ + previouslyMarried: false, + religious: false, + }); +}); + test("getCNIPersonalisations returns the correct personalisations given all positive answers", () => { const answers = { livesInCountry: true, @@ -210,6 +222,17 @@ test("getCNIPersonalisations returns the correct personalisations given all nega }); }); +test("buildPostalPersonalisation treats simplified 'Single' as no previous marriage", () => { + const answers = { + ...marriageAnswers, + maritalStatus: "Single", + }; + + const personalisation = buildPostalPersonalisation(answers, { reference: "1234", type: "cni" }); + + expect(personalisation.userHadPreviousMarriage).toBe(false); +}); + test("buildJobData should return the correct personalisation for a certify a copy in-person email", () => { const personalisation = CertifyCopyPersonalisationBuilder.inPerson(certifyCopyAnswers, { type: "certifyCopy", reference: "1234" }); diff --git a/docker-compose.yml b/docker-compose.yml index dad1bd67..2cd78ff7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,40 @@ -version: "3.9" services: api: - depends_on: [postgres] + depends_on: + postgres: + condition: service_healthy env_file: - api/.env + ports: + - "9000:9000" environment: - - QUEUE_DATABASE_URL=postgres://user:root@postgres:5432/notarial + - QUEUE_URL=postgres://user:root@postgres:5432/notarial build: context: . dockerfile: api/Dockerfile + worker: - depends_on: [postgres] + depends_on: + postgres: + condition: service_healthy + api: + condition: service_started environment: - NOTIFY_API_KEY=${NOTIFY_API_KEY:-} - - QUEUE_URL=postgres://user:root@postgres:5432/notarial + - ARCHIVE_FAILED_AFTER_DAYS=90 + - DELETE_ARCHIVED_AFTER_DAYS=7 + - SUBMISSION_ADDRESS=${SUBMISSION_ADDRESS:-noreply@prove-eligibility-foreign-government.forms.fcodev.org.uk} + - SENDER_EMAIL_ADDRESS=noreply@prove-eligibility-foreign-government.forms.fcodev.org.uk + - QUEUE_DRAIN_SCHEMA=pgboss_v10 - QUEUE_SCHEMA=pgboss_v10 - - QUEUE_DRAIN_SCHEMA=pgboss + - NODE_ENV=development + - QUEUE_URL=postgres://user:root@postgres:5432/notarial + - SES_SENDER_NAME=Getting Married Abroad Service + - NOTARIAL_API_CREATE_SES_EMAIL_URL=http://host.docker.internal:9000/forms/emails/ses + - NOTARIAL_API_CREATE_NOTIFY_EMAIL_URL=http://host.docker.internal:9000/forms/emails/notify + - FILES_ALLOWED_ORIGINS=["http://host.docker.internal:9004", "http://host.docker.internal:3030"] + - MONITOR_STATE_INTERVAL_IN_SECONDS=60 + - NOTIFY_FAILURE_CHECK_SCHEDULE=9 * * * * build: context: . dockerfile: worker/Dockerfile @@ -30,4 +49,10 @@ services: environment: POSTGRES_DB: notarial POSTGRES_PASSWORD: root - POSTGRES_USER: user \ No newline at end of file + POSTGRES_USER: user + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d notarial"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s diff --git a/worker/src/Consumer/startListener.ts b/worker/src/Consumer/startListener.ts index 0e3d2b34..c0aa7dc1 100644 --- a/worker/src/Consumer/startListener.ts +++ b/worker/src/Consumer/startListener.ts @@ -5,11 +5,33 @@ import pino from "pino"; import config from "config"; const logger = pino(); +const DEADLOCK_ERROR_CODE = "40P01"; +const CREATE_QUEUE_ATTEMPTS = 5; + +async function createQueueWithRetry(consumer: PgBoss, queueName: string) { + for (let attempt = 1; attempt <= CREATE_QUEUE_ATTEMPTS; attempt++) { + try { + await consumer.createQueue(queueName); + return; + } catch (err: any) { + const isDeadlock = err?.code === DEADLOCK_ERROR_CODE; + const isLastAttempt = attempt === CREATE_QUEUE_ATTEMPTS; + + if (!isDeadlock || isLastAttempt) { + throw err; + } + + logger.warn({ queue: queueName, attempt, err }, "Deadlock detected while creating queue. Retrying."); + await new Promise((resolve) => setTimeout(resolve, attempt * 500)); + } + } +} + export async function startListener(queueName: string, handler: WorkHandler, options: WorkOptions = {}) { const consumer: PgBoss = await getConsumer(); logger.info({ queue: queueName, options }, `Creating queue ${queueName}`); - await consumer.createQueue(queueName); + await createQueueWithRetry(consumer, queueName); if (config.has("Queue.drainSchema")) { const queueDrainSchema = config.get<"string">("Queue.drainSchema"); diff --git a/worker/src/queues/index.ts b/worker/src/queues/index.ts index 9bed6a95..20297d02 100644 --- a/worker/src/queues/index.ts +++ b/worker/src/queues/index.ts @@ -2,7 +2,7 @@ import { getConsumer } from "../Consumer"; import { setupNotifyWorker } from "./notify"; import { setupSesQueueWorker } from "./ses"; -getConsumer().then(() => { - setupNotifyWorker(); - setupSesQueueWorker(); +getConsumer().then(async () => { + await setupNotifyWorker(); + await setupSesQueueWorker(); }); diff --git a/worker/src/queues/notify/workers/notifyProcessHandler.ts b/worker/src/queues/notify/workers/notifyProcessHandler.ts index bbe260a2..521f9c67 100644 --- a/worker/src/queues/notify/workers/notifyProcessHandler.ts +++ b/worker/src/queues/notify/workers/notifyProcessHandler.ts @@ -16,7 +16,7 @@ const logger = pino().child({ const CREATE_NOTIFY_EMAIL_URL = config.get("NotarialApi.createNotifyEmailUrl"); type NotifyParseJob = { answers: any; - metadata: { reference: string; payment?: PayMetadata; type: string }; + metadata: { reference: string; payment?: PayMetadata; type: string; source?: string }; }; /** * When a "NOTIFY_PARSE" event is detected, this worker simply POSTs the data back to the notarial-api/forms/emails/user