diff --git a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts index 720f1237aa..cff7cf187a 100644 --- a/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts +++ b/app/(gcforms)/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms/actions.ts @@ -6,7 +6,7 @@ import { TemplateHasUnprocessedSubmissions } from "@lib/templates/internal/error import { revalidatePath } from "next/cache"; import { AuthenticatedAction } from "@lib/actions"; import { getTemplateWithAssignedUsers } from "@lib/templates/queries/getTemplateWithAssignedUsers"; -import { sendArchivedFormNotifications } from "@lib/notifications"; +import { sendArchivedFormNotifications } from "@lib/formEmailOrchestration"; // Public facing functions - they can be used by anyone who finds the associated server action identifer @@ -18,13 +18,14 @@ export const deleteForm = AuthenticatedAction(async (session, id: string) => { } await deleteTemplate(id); - await sendArchivedFormNotifications( - session, - id, - template.formRecord.form.titleEn, - template.formRecord.form.titleFr, - template.users - ); + + sendArchivedFormNotifications(session.user.email, { + title: { + en: template.formRecord.form.titleEn, + fr: template.formRecord.form.titleFr, + }, + ownersEmailAddresses: template.users.map((u) => u.email), + }); revalidatePath("app/[locale]/(app administration)/admin/(with nav)/accounts/[id]/manage-forms"); } catch (error) { diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx index fce1229f4b..7088f56d2c 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/page.tsx @@ -6,10 +6,7 @@ import { authorization } from "@lib/privileges"; import { AuthenticatedPage } from "@lib/pages/auth"; import { SetClosingDate } from "./components/close/SetClosingDate"; import { Notifications } from "./components/notifications/Notifications"; -import { - getNotificationsUsersForForm, - getUserNotificationSettingsForForm, -} from "@lib/notifications"; +import { getNotificationsUsersForForm } from "@lib/formEmailOrchestration"; export async function generateMetadata(props: { params: Promise<{ locale: string }>; @@ -47,15 +44,12 @@ export default AuthenticatedPage( closedDetails = closedData?.closedDetails; } - // Get logged in user's notification setting for this form - const loggedInUserNotificationsSetting = await getUserNotificationSettingsForForm( - id, - props.session.user.id - ); - // Get list of users and their notification settings for this form const userNotificationsForForm = await getNotificationsUsersForForm(id); + const loggedInUserNotificationsSetting = + userNotificationsForForm?.find((u) => u.id === props.session.user.id)?.enabled ?? false; + // Is the currently logged in user assigned to this form const userIsNotifiable = userNotificationsForForm ? userNotificationsForForm.some((user) => user.id === props.session.user.id) diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts index 27ec88181b..c3ba4f51c5 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts @@ -22,7 +22,7 @@ import { getFullTemplateByID } from "@lib/templates/queries/getFullTemplateByID" import { getFormJSONConfig } from "@lib/templates/queries/getFormJSONConfig"; import { isValidEmail } from "@gcforms/core"; import { slugify } from "@lib/client/clientHelpers"; -import { sendEmail } from "@lib/integration/notifyConnector"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { getOrigin } from "@lib/origin"; import { NotificationsInterval } from "@gcforms/types"; import { redirect } from "next/navigation"; @@ -506,19 +506,10 @@ export const shareForm = AuthenticatedAction( const HOST = await getOrigin(); - // Here is the documentation for the `sendEmail` function: https://docs.notifications.service.gov.uk/node.html#send-an-email - await Promise.all( - emails.map((email: string) => { - return sendEmail( - email, - { - application_file: { - file: base64data, - filename: `${cleanedFilename}.json`, - sending_method: "attach", - }, - subject: "Form shared | Formulaire partagé", - formResponse: ` + await sendDefaultEmail({ + to: emails, + subject: "Form shared | Formulaire partagé", + body: ` **${session.user.name} (${session.user.email}) has shared a form with you.** To preview this form: @@ -540,11 +531,13 @@ Pour prévisualiser ce formulaire : Aller sur [Formulaires GC](${HOST}). Aucun compte n'est nécessaire. - **Étape 3 :** Sélectionner "Ouvrir un formulaire".`, - }, - "shareForm" - ); - }) - ); + attachments: [ + { + fileName: `${cleanedFilename}.json`, + base64EncodedFile: base64data, + }, + ], + }); return { success: true }; } catch (error) { diff --git a/app/(gcforms)/[locale]/(form administration)/forms/actions.ts b/app/(gcforms)/[locale]/(form administration)/forms/actions.ts index 980ca181b9..e3fcb94b33 100644 --- a/app/(gcforms)/[locale]/(form administration)/forms/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/forms/actions.ts @@ -9,7 +9,7 @@ import { restoreTemplate } from "@lib/templates/mutations/restoreTemplate"; import { revalidatePath } from "next/cache"; import { FormRecord } from "@lib/types"; import { AuthenticatedAction } from "@lib/actions"; -import { sendArchivedFormNotifications } from "@lib/notifications"; +import { sendArchivedFormNotifications } from "@lib/formEmailOrchestration"; import { getTemplateWithAssignedUsers } from "@lib/templates/queries/getTemplateWithAssignedUsers"; import { createDraftVersionForTemplate } from "@lib/templates/versioning/mutations/createDraftForTemplate"; @@ -52,13 +52,13 @@ export const deleteForm = AuthenticatedAction( } }); - await sendArchivedFormNotifications( - session, - id, - template.formRecord.form.titleEn, - template.formRecord.form.titleFr, - template.users - ); + sendArchivedFormNotifications(session.user.email, { + title: { + en: template.formRecord.form.titleEn, + fr: template.formRecord.form.titleFr, + }, + ownersEmailAddresses: template.users.map((u) => u.email), + }); revalidatePath("(gcforms)/[locale]/(form administration)/forms", "page"); } catch (e) { diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.test.ts b/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.test.ts index 1d6e83cb72..8f1d45bf28 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.test.ts +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { NotificationsInterval } from "@gcforms/types"; import { submitForm } from "./actions"; import { PublicFormRecord, FormElementTypes } from "@lib/types"; @@ -32,8 +33,14 @@ vi.mock("@root/i18n", () => ({ serverTranslation: vi.fn(), })); -vi.mock("@lib/notifications", () => ({ - sendNotifications: vi.fn(), +vi.mock("@lib/formEmailOrchestration", () => ({ + getFormNotificationInterval: vi.fn(), + updateNotificationMarker: vi.fn(), + prepareFormSubmissionEmail: vi.fn(), +})); + +vi.mock("@lib/integration/notifyConnector", () => ({ + sendDefaultEmail: vi.fn(), })); vi.mock("./lib/server/normalizeFormResponses", () => ({ @@ -50,7 +57,12 @@ import { checkOne } from "@lib/cache/flags"; import { dateHasPast } from "@lib/utils"; import { validateVisibleElements, valuesMatchErrorContainsElementType } from "@gcforms/core"; import { serverTranslation } from "@root/i18n"; -import { sendNotifications } from "@lib/notifications"; +import { + getFormNotificationInterval, + prepareFormSubmissionEmail, + updateNotificationMarker, +} from "@lib/formEmailOrchestration"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { normalizeFormResponses } from "./lib/server/normalizeFormResponses"; import { processFormData } from "./lib/server/processFormData"; import { ResponseValidationValues } from "@gcforms/core"; @@ -98,7 +110,10 @@ describe("submitForm", () => { submissionId: "test-submission-id", fileURLMap: {}, }); - (sendNotifications as Mock).mockResolvedValue(undefined); + (getFormNotificationInterval as Mock).mockResolvedValue(false); + (updateNotificationMarker as Mock).mockResolvedValue(null); + (prepareFormSubmissionEmail as Mock).mockResolvedValue(null); + (sendDefaultEmail as Mock).mockResolvedValue(undefined); }); it("should return MissingFormDataError when file input validation fails", async () => { @@ -151,11 +166,105 @@ describe("submitForm", () => { version: 2, language: mockLanguage, fileChecksums: undefined, + notificationId: undefined, + }); + expect(getFormNotificationInterval).toHaveBeenCalledWith(mockFormId); + expect(prepareFormSubmissionEmail).not.toHaveBeenCalled(); + expect(sendDefaultEmail).not.toHaveBeenCalled(); + }); + + it("should send a first submission notification when form is eligible and no prior marker exists", async () => { + const mockEmailData = { + emails: ["user@example.com"], + subject: "You have a new submission", + formResponse: "Email body", + }; + (checkOne as Mock).mockResolvedValue(true); + (getFormNotificationInterval as Mock).mockResolvedValue(NotificationsInterval.DAY); + (updateNotificationMarker as Mock).mockResolvedValue("FIRST_EMAIL"); + (prepareFormSubmissionEmail as Mock).mockResolvedValue(mockEmailData); + + const result = await submitForm(mockValues, mockLanguage, mockFormId); + + expect(result).toEqual({ + id: mockFormId, + submissionId: "test-submission-id", + fileURLMap: {}, + }); + + expect(updateNotificationMarker).toHaveBeenCalledWith(mockFormId, NotificationsInterval.DAY); + + expect(prepareFormSubmissionEmail).toHaveBeenCalledWith( + mockFormId, + mockTemplate.form.titleEn, + mockTemplate.form.titleFr, + "FIRST_EMAIL" + ); + + const notificationId = (processFormData as Mock).mock.calls[0][0].notificationId; + expect(notificationId).toBeTypeOf("string"); + + expect(sendDefaultEmail).toHaveBeenCalledWith({ + to: mockEmailData.emails, + subject: mockEmailData.subject, + body: mockEmailData.formResponse, + options: { mode: "deferred", notificationId }, + }); + expect(processFormData).toHaveBeenCalledWith(expect.objectContaining({ notificationId })); + }); + + it("should send a second submission notification when form is eligible and first marker already set", async () => { + const mockEmailData = { + emails: ["user@example.com"], + subject: "You have multiple new submissions", + formResponse: "Email body", + }; + (checkOne as Mock).mockResolvedValue(true); + (getFormNotificationInterval as Mock).mockResolvedValue(NotificationsInterval.DAY); + (updateNotificationMarker as Mock).mockResolvedValue("SECOND_EMAIL"); + (prepareFormSubmissionEmail as Mock).mockResolvedValue(mockEmailData); + + const result = await submitForm(mockValues, mockLanguage, mockFormId); + + expect(result).toEqual({ + id: mockFormId, + submissionId: "test-submission-id", + fileURLMap: {}, }); - expect(sendNotifications).toHaveBeenCalledWith( + + expect(updateNotificationMarker).toHaveBeenCalledWith(mockFormId, NotificationsInterval.DAY); + + expect(prepareFormSubmissionEmail).toHaveBeenCalledWith( mockFormId, mockTemplate.form.titleEn, - mockTemplate.form.titleFr + mockTemplate.form.titleFr, + "SECOND_EMAIL" + ); + + const notificationId = (processFormData as Mock).mock.calls[0][0].notificationId; + expect(notificationId).toBeTypeOf("string"); + + expect(sendDefaultEmail).toHaveBeenCalledWith({ + to: mockEmailData.emails, + subject: mockEmailData.subject, + body: mockEmailData.formResponse, + options: { mode: "deferred", notificationId }, + }); + expect(processFormData).toHaveBeenCalledWith(expect.objectContaining({ notificationId })); + }); + + it("should not send a notification when form is eligible but marker limit is reached", async () => { + (checkOne as Mock).mockResolvedValue(true); + (getFormNotificationInterval as Mock).mockResolvedValue(NotificationsInterval.DAY); + (updateNotificationMarker as Mock).mockResolvedValue(null); + + await submitForm(mockValues, mockLanguage, mockFormId); + + expect(updateNotificationMarker).toHaveBeenCalledWith(mockFormId, NotificationsInterval.DAY); + expect(prepareFormSubmissionEmail).not.toHaveBeenCalled(); + expect(sendDefaultEmail).not.toHaveBeenCalled(); + expect(processFormData).toHaveBeenCalledWith( + expect.objectContaining({ notificationId: undefined }) ); }); @@ -236,4 +345,37 @@ describe("submitForm", () => { t: expect.any(Function), }); }); + + it("should fall back to GC Notify (sendEmail without deferred mode) and return undefined notificationId when the notification flag is off", async () => { + const mockEmailData = { + emails: ["user@example.com"], + subject: "You have a new submission", + formResponse: "Email body", + }; + // Flag OFF + (checkOne as Mock).mockResolvedValue(false); + (getFormNotificationInterval as Mock).mockResolvedValue(NotificationsInterval.DAY); + (updateNotificationMarker as Mock).mockResolvedValue("FIRST_EMAIL"); + (prepareFormSubmissionEmail as Mock).mockResolvedValue(mockEmailData); + + const result = await submitForm(mockValues, mockLanguage, mockFormId); + + expect(result).toEqual({ + id: mockFormId, + submissionId: "test-submission-id", + fileURLMap: {}, + }); + + // sendEmail is called without deferred mode — falls back to GC Notify + expect(sendDefaultEmail).toHaveBeenCalledWith({ + to: mockEmailData.emails, + subject: mockEmailData.subject, + body: mockEmailData.formResponse, + }); + + // No notificationId is generated or forwarded to processFormData when the flag is off + expect(processFormData).toHaveBeenCalledWith( + expect.objectContaining({ notificationId: undefined }) + ); + }); }); diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts b/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts index 00b5db65eb..ca07ed47fd 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts @@ -13,13 +13,19 @@ import { FeatureFlags } from "@lib/cache/types"; import { dateHasPast } from "@lib/utils"; import { validateVisibleElements } from "@gcforms/core"; import { serverTranslation } from "@root/i18n"; -import { sendNotifications } from "@lib/notifications"; +import { + getFormNotificationInterval, + prepareFormSubmissionEmail, + updateNotificationMarker, +} from "@lib/formEmailOrchestration"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { traceFunction } from "@lib/otel"; import { MissingFormDataError } from "./lib/client/exceptions"; import { valuesMatchErrorContainsElementType } from "@gcforms/core"; import { shouldCheckCaptcha } from "@lib/utils/shouldCheckCaptcha"; import { ResponseValidationValues } from "@gcforms/core"; +import { randomUUID } from "crypto"; // Public facing functions - they can be used by anyone who finds the associated server action identifer @@ -115,6 +121,12 @@ export async function submitForm( const version = template.versionNumber || 1; const formData = normalizeFormResponses(template, values.responses as Responses); + const notificationId = await scheduleFormSubmissionNotification( + formId, + template.form.titleEn, + template.form.titleFr + ); + const { submissionId, fileURLMap } = await processFormData({ responses: formData, securityAttribute: template.securityAttribute, @@ -122,10 +134,10 @@ export async function submitForm( version, language, fileChecksums, + // If non-null will be used in the reliability lambda to kick off the deferred notification pipeline + notificationId, }); - sendNotifications(formId, template.form.titleEn, template.form.titleFr); - return { id: formId, submissionId, fileURLMap }; } catch (e) { logMessage.error( @@ -136,3 +148,71 @@ export async function submitForm( } }); } + +const scheduleFormSubmissionNotification = async ( + formId: string, + formTitleEn: string, + formTitleFr: string +): Promise => { + try { + const interval = await getFormNotificationInterval(formId); + if (!interval) return undefined; + + const notificationEmailType = await updateNotificationMarker(formId, interval); + if (!notificationEmailType) return undefined; + + const emailData = await prepareFormSubmissionEmail( + formId, + formTitleEn, + formTitleFr, + notificationEmailType + ); + if (!emailData) return undefined; + + // The flag is checked here (not just inside sendEmail) because the notificationId must only + // be generated and returned when the pipeline is active — it is passed to processFormData so + // the reliability lambda can enqueue the deferred send after the submission is persisted. + const notificationEnabled = await checkOne(FeatureFlags.notification); + + // Notification flag ON: send deferred email via notification pipeline + if (notificationEnabled) { + const notificationId = randomUUID(); + + /** + * Not using await here to avoid adding extra latency in the submission flow. + * Because the infra pipeline does not process submissions right away, the notification data should have enough time to get in DynamoDB before the Reliability lambda request its processing + */ + sendDefaultEmail({ + to: emailData.emails, + subject: emailData.subject, + body: emailData.formResponse, + options: { mode: "deferred", notificationId }, + }).catch((error) => + logMessage.warn( + `scheduleFormSubmissionNotification: failed to send deferred email via notification pipeline. Form ID: ${formId}. Reason: ${(error as Error).message}` + ) + ); + + return notificationId; + } + + // Notification flag OFF: send immediate email + sendDefaultEmail({ + to: emailData.emails, + subject: emailData.subject, + body: emailData.formResponse, + }).catch((error) => + logMessage.warn( + `scheduleFormSubmissionNotification: failed to send immediate email. Form ID: ${formId}. Reason: ${(error as Error).message}` + ) + ); + + return undefined; + } catch (error) { + logMessage.warn( + `scheduleFormSubmissionNotification processing failed for form ${formId}. Reason: ${(error as Error).message}` + ); + + return undefined; + } +}; diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/invokeSubmissionLambda.ts b/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/invokeSubmissionLambda.ts index 99e3946a3e..4cb2136082 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/invokeSubmissionLambda.ts +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/invokeSubmissionLambda.ts @@ -11,7 +11,8 @@ export const invokeSubmissionLambda = async ( language: string, securityAttribute: string, version?: number, - fileChecksums?: Record + fileChecksums?: Record, + notificationId?: string ): Promise<{ submissionId: string; fileURLMap?: SignedURLMap; @@ -24,6 +25,7 @@ export const invokeSubmissionLambda = async ( securityAttribute, ...(version && { version }), ...(fileChecksums && Object.keys(fileChecksums).length > 0 && { fileChecksums }), + ...(notificationId && { notificationId }), }; const lambdaInvokeResponse = await lambdaClient.send( diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/processFormData.ts b/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/processFormData.ts index e842518f69..ed10cc8e05 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/processFormData.ts +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/server/processFormData.ts @@ -12,6 +12,7 @@ type ProcessFormDataParams = { version?: number; language?: string; fileChecksums?: Record; + notificationId?: string; }; export const processFormData = async ({ @@ -21,6 +22,7 @@ export const processFormData = async ({ version, language, fileChecksums, + notificationId, }: ProcessFormDataParams): Promise<{ submissionId: string; fileURLMap?: SignedURLMap; @@ -62,7 +64,8 @@ export const processFormData = async ({ language ? language : "en", securityAttribute ? securityAttribute : "Protected A", version, - fileChecksums + fileChecksums, + notificationId ); logMessage.info(`Response submitted for Form ID: ${form.id}`); diff --git a/i18n/translations/en/admin-flags.json b/i18n/translations/en/admin-flags.json index 6b1e1cf184..4802fa87dd 100644 --- a/i18n/translations/en/admin-flags.json +++ b/i18n/translations/en/admin-flags.json @@ -45,6 +45,10 @@ "templateVersioning": { "title": "Template versioning", "description": "Enable draft versions and re-publishing flow for form templates" + }, + "notification": { + "title": "Notification", + "description": "Enable sending emails immediately or deferred using the Notification flow. Disabling this will send emails directly to GC Notify." } }, "addFlag": "Add user feature flag", diff --git a/i18n/translations/en/common.json b/i18n/translations/en/common.json index 6a753c77e0..1caee5e197 100644 --- a/i18n/translations/en/common.json +++ b/i18n/translations/en/common.json @@ -130,7 +130,8 @@ "typography": "Typography", "upload": "Upload", "viewTemplates": "View Templates", - "allForms": "Forms" + "allForms": "Forms", + "events": "Audit logs" }, "formElements": { "characterCount": { diff --git a/i18n/translations/fr/admin-flags.json b/i18n/translations/fr/admin-flags.json index 8cd4e18599..f5c9b9b65d 100644 --- a/i18n/translations/fr/admin-flags.json +++ b/i18n/translations/fr/admin-flags.json @@ -45,6 +45,10 @@ "templateVersioning": { "title": "Gestion des versions de formulaires", "description": "Activer le flux de version d'ébauches et de republication pour les modèles de formulaire" + }, + "notification": { + "title": "Système de notification", + "description": "Activer l'envoi de courriels immédiats ou différés via le système de notification. Si cette option est désactivée, les courriels seront envoyés directement à GC Notify." } }, "addFlag": "Ajouter un indicateur de fonctionnalité", diff --git a/i18n/translations/fr/common.json b/i18n/translations/fr/common.json index 287f3824b3..75f7a8ff8a 100644 --- a/i18n/translations/fr/common.json +++ b/i18n/translations/fr/common.json @@ -130,7 +130,8 @@ "typography": "Typographie", "upload": "Téléverser", "viewTemplates": "Afficher les modèles", - "allForms": "Formulaires" + "allForms": "Formulaires", + "events": "Journaux d’audit" }, "formElements": { "characterCount": { diff --git a/lib/auth/2fa.ts b/lib/auth/2fa.ts index cc1333ef6b..6fe385c7fc 100644 --- a/lib/auth/2fa.ts +++ b/lib/auth/2fa.ts @@ -1,5 +1,4 @@ -import { sendEmail } from "@lib/integration/notifyConnector"; - +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { generateTokenCode } from "@lib/auth/tokenGenerator"; import { logMessage } from "@lib/logger"; @@ -7,19 +6,17 @@ export const generateVerificationCode = async () => generateTokenCode(5); export const sendVerificationCode = async (email: string, verificationCode: string) => { try { - await sendEmail( - email, - { - subject: "Your security code | Votre code de sécurité", - formResponse: ` + await sendDefaultEmail({ + to: [email], + subject: "Your security code | Votre code de sécurité", + body: ` **Your security code | Votre code de sécurité** ${verificationCode}`, - }, - "2faVerificationCode" - ); + options: { bypassNotificationPipeline: true }, // The notification pipeline uses DynamoDB to temporarily store email content but we don't want this 2FA code to be stored anywhere + }); } catch (err) { logMessage.error( `Failed to send verification code email to ${email}. Reason: ${(err as Error).message}.` diff --git a/lib/auth/passwordReset.ts b/lib/auth/passwordReset.ts index f54fe3a100..08b0c71be1 100644 --- a/lib/auth/passwordReset.ts +++ b/lib/auth/passwordReset.ts @@ -1,7 +1,7 @@ import { prisma } from "@gcforms/database"; import { generateVerificationCode } from "./2fa"; import { logMessage } from "@lib/logger"; -import { sendEmail } from "@lib/integration/notifyConnector"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { userHasSecurityQuestions } from "@lib/auth/securityQuestions"; import { getOrigin } from "@lib/origin"; @@ -93,11 +93,10 @@ export const getPasswordResetAuthenticatedUserEmailAddress = async ( const sendPasswordResetEmail = async (email: string, token: string) => { const baseUrl = await getOrigin(); - await sendEmail( - email, - { - subject: "Password reset | Réinitialisation de mot de passe", - formResponse: ` + await sendDefaultEmail({ + to: [email], + subject: "Password reset | Réinitialisation de mot de passe", + body: ` Reset your password with this link: [${baseUrl}/en/auth/reset-password/${token}](${baseUrl}/en/auth/reset-password/${token}) @@ -105,7 +104,5 @@ Reset your password with this link: Réinitialisez votre mot de passe avec ce lien : [${baseUrl}/fr/auth/reset-password/${token}](${baseUrl}/fr/auth/reset-password/${token})`, - }, - "passwordReset" - ); + }); }; diff --git a/lib/cache/types.ts b/lib/cache/types.ts index ea946a6276..919fd075fa 100644 --- a/lib/cache/types.ts +++ b/lib/cache/types.ts @@ -10,6 +10,7 @@ export const UserFeatureFlags = { export const FeatureFlags = { formTimer: "formTimer", hCaptcha: "hCaptcha", + notification: "notification", topBanner: "topBanner", zitadelLogin: "zitadelLogin", ...UserFeatureFlags, diff --git a/lib/deactivate.ts b/lib/deactivate.ts index 2a972ab5e8..2f9af270f2 100644 --- a/lib/deactivate.ts +++ b/lib/deactivate.ts @@ -1,4 +1,4 @@ -import { sendEmail } from "./integration/notifyConnector"; +import { sendDefaultEmail } from "./integration/notifyConnector"; import { getOrigin } from "./origin"; import { DeactivationReason, DeactivationReasons } from "./types"; @@ -64,15 +64,12 @@ export const sendDeactivationEmail = async ( ) => { const HOST = await getOrigin(); - await sendEmail( - email, - { - subject: "Account deactivated | Compte désactivé", - formResponse: - reason === DeactivationReasons.DEFAULT - ? defaultTemplate(email, HOST) - : sharedTemplate(email, HOST), - }, - "deactivateAccount" - ); + await sendDefaultEmail({ + to: [email], + subject: "Account deactivated | Compte désactivé", + body: + reason === DeactivationReasons.DEFAULT + ? defaultTemplate(email, HOST) + : sharedTemplate(email, HOST), + }); }; diff --git a/lib/flags/default_flag_settings.json b/lib/flags/default_flag_settings.json index e60dd791bd..2a97a78c8d 100644 --- a/lib/flags/default_flag_settings.json +++ b/lib/flags/default_flag_settings.json @@ -2,6 +2,7 @@ "addressComplete": false, "formTimer": false, "hCaptcha": false, + "notification": false, "templateVersioning": false, "topBanner": false, "responsesPilot": false, diff --git a/lib/formEmailOrchestration.ts b/lib/formEmailOrchestration.ts new file mode 100644 index 0000000000..66087475b5 --- /dev/null +++ b/lib/formEmailOrchestration.ts @@ -0,0 +1,264 @@ +import { logMessage } from "@lib/logger"; +import { getRedisInstance } from "@lib/integration/redisConnector"; +import { getOrigin } from "@lib/origin"; +import { serverTranslation } from "@i18n"; +import { prisma, prismaErrors } from "@gcforms/database"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; + +const Status = { + SINGLE_EMAIL_SENT: "SINGLE_EMAIL_SENT", + MULTIPLE_EMAIL_SENT: "MULTIPLE_EMAIL_SENT", +} as const; +type Status = (typeof Status)[keyof typeof Status]; + +export type NotificationEmailType = "FIRST_EMAIL" | "SECOND_EMAIL"; + +/** + * Returns a list of users associated with the form and their notification settings + * + * @param formId + * @returns + */ +export const getNotificationsUsersForForm = async (formId: string) => { + const template = await prisma.template + .findUnique({ + where: { + id: formId, + }, + select: { + users: { + select: { + id: true, + email: true, + notificationsTemplates: { + where: { + id: formId, + }, + select: { + id: true, + }, + }, + }, + }, + }, + }) + .catch((e) => prismaErrors(e, null)); + + if (!template) { + logMessage.debug(`_getNotificationsUsers no users found for formId ${formId}`); + return null; + } + + return template.users.map((user) => ({ + id: user.id, + email: user.email, + enabled: user.notificationsTemplates.length > 0, + })); +}; + +/** + * Determines whether to send email submission updates based on template state. + * + * Returns the form's configured notification interval in minutes if eligible, false otherwise. + * A return of false means notifications are either disabled or the form is not set up for them. + */ +export const getFormNotificationInterval = async (formId: string): Promise => { + const template = await prisma.template + .findUnique({ + where: { id: formId }, + select: { + deliveryOption: true, + notificationsInterval: true, + users: { + select: { + id: true, + notificationsTemplates: { + where: { id: formId }, + select: { id: true }, + }, + }, + }, + }, + }) + .catch((e) => prismaErrors(e, null)); + + // Avoid legacy forms that receive delivery by email + if (!template || template.deliveryOption) return false; + + // Respect the per-form interval setting — null means notifications are turned off + if (!template.notificationsInterval) return false; + + // Avoid some older forms that may not have users + if (!template.users.length) return false; + + // Avoid forms where no user has notifications enabled + if (!template.users.some((user) => user.notificationsTemplates.length > 0)) return false; + + return template.notificationsInterval; +}; + +export const updateNotificationMarker = async ( + formId: string, + interval: number +): Promise => { + const key = `notification:formId:${formId}`; + const ttl = interval * 60; // convert from minutes to seconds + const redis = await getRedisInstance(); + + // The SET NX bit is atomic and helps prevent a race condition where two simultaneous submissions + // could both trigger a first email + const wasSet = await redis.set(key, Status.SINGLE_EMAIL_SENT, "EX", ttl, "NX"); + if (wasSet === "OK") { + logMessage.debug(`updateNotificationMarker: formId ${formId} transitioned to FIRST_EMAIL`); + return "FIRST_EMAIL"; + } + + const current = await redis.get(key); + if (current === Status.SINGLE_EMAIL_SENT) { + await redis.set(key, Status.MULTIPLE_EMAIL_SENT, "EX", ttl); + logMessage.debug(`updateNotificationMarker: formId ${formId} transitioned to SECOND_EMAIL`); + return "SECOND_EMAIL"; + } + + if (current === Status.MULTIPLE_EMAIL_SENT) { + logMessage.debug(`updateNotificationMarker: formId ${formId} — interval limit reached`); + return null; + } + + // Unexpected or stale value (e.g. empty string from a bad previous write) - reset to a likely state + logMessage.warn( + `updateNotificationMarker: unexpected marker value "${current}" for formId ${formId}, resetting` + ); + await redis.set(key, Status.SINGLE_EMAIL_SENT, "EX", ttl); + return "FIRST_EMAIL"; +}; + +/** + * Prepares the email data for a form submission notification. + * Returns the recipient emails and personalisation content, or null if nothing should be sent. + */ +export const prepareFormSubmissionEmail = async ( + formId: string, + formTitleEn: string, + formTitleFr: string, + emailType: NotificationEmailType +): Promise<{ emails: string[]; subject: string; formResponse: string } | null> => { + const users = await getNotificationsUsersForForm(formId); + if (!Array.isArray(users) || users.length === 0) return null; + + const emails = users.filter(({ enabled }) => enabled).map(({ email }) => email); + if (emails.length === 0) return null; + + const multipleSubmissions = emailType === "SECOND_EMAIL"; + const { t } = await serverTranslation("form-builder"); + const HOST = await getOrigin(); + + return { + emails, + subject: multipleSubmissions + ? t("settings.notifications.email.multipleSubmissions.subject") + : t("settings.notifications.email.singleSubmission.subject"), + formResponse: multipleSubmissions + ? await multipleSubmissionsEmailTemplate(HOST, formTitleEn, formTitleFr) + : await singleSubmissionEmailTemplate(HOST, formTitleEn, formTitleFr), + }; +}; + +// Public facing function to send notifications to all related users on a form archival +export const sendArchivedFormNotifications = async ( + emailOfUserInitiatingAction: string, + formInformation: { title: { en: string; fr: string }; ownersEmailAddresses: string[] } +): Promise => { + // Some older forms may not have users, do nothing + if (formInformation.ownersEmailAddresses.length === 0) { + return; + } + + const { t } = await serverTranslation("form-builder"); + const { t: t_en } = await serverTranslation("form-builder", { lang: "en" }); + const { t: t_fr } = await serverTranslation("form-builder", { lang: "fr" }); + + const HOST = await getOrigin(); + const subject = t("settings.notifications.email.archivedForm.subject"); + const body = ` +${t_en("settings.notifications.email.archivedForm.paragraph1")} +${formInformation.title.en} +${t_en("settings.notifications.email.archivedForm.paragraph2")} +${emailOfUserInitiatingAction} + +**[${t_en("settings.notifications.email.archivedForm.paragraph3")}](${HOST}/auth/login)** + +*${t_en("settings.notifications.email.archivedForm.paragraph4")}* + +--- + +${t_fr("settings.notifications.email.archivedForm.paragraph1")} +${formInformation.title.fr} +${t_fr("settings.notifications.email.archivedForm.paragraph2")} +${emailOfUserInitiatingAction} + +**[${t_fr("settings.notifications.email.archivedForm.paragraph3")}](${HOST}/auth/login)** + +*${t_fr("settings.notifications.email.archivedForm.paragraph4")}* + `; + + await sendDefaultEmail({ + to: formInformation.ownersEmailAddresses, + subject, + body, + }); +}; + +const singleSubmissionEmailTemplate = async ( + HOST: string, + formTitleEn: string, + formTitleFr: string +) => { + const { t: t_en } = await serverTranslation("form-builder", { lang: "en" }); + const { t: t_fr } = await serverTranslation("form-builder", { lang: "fr" }); + + return ` +${t_en("settings.notifications.email.singleSubmission.paragraph1")} +${formTitleEn} + +**[${t_en("settings.notifications.email.singleSubmission.paragraph2")}](${HOST}/auth/login)** + +*${t_en("settings.notifications.email.singleSubmission.paragraph3")}* + +--- + +${t_fr("settings.notifications.email.singleSubmission.paragraph1")} +${formTitleFr} + +**[${t_fr("settings.notifications.email.singleSubmission.paragraph2")}](${HOST}/auth/login)** + +*${t_fr("settings.notifications.email.singleSubmission.paragraph3")}* + `; +}; + +const multipleSubmissionsEmailTemplate = async ( + HOST: string, + formTitleEn: string, + formTitleFr: string +) => { + const { t: t_en } = await serverTranslation("form-builder", { lang: "en" }); + const { t: t_fr } = await serverTranslation("form-builder", { lang: "fr" }); + + return ` +${t_en("settings.notifications.email.multipleSubmissions.paragraph1")} +${formTitleEn} + +**[${t_en("settings.notifications.email.multipleSubmissions.paragraph2")}](${HOST}/auth/login)** + +*${t_en("settings.notifications.email.multipleSubmissions.paragraph3")}* + +--- + +${t_fr("settings.notifications.email.multipleSubmissions.paragraph1")} +${formTitleFr} + +**[${t_fr("settings.notifications.email.multipleSubmissions.paragraph2")}](${HOST}/auth/login)** + +*${t_fr("settings.notifications.email.multipleSubmissions.paragraph3")}* + `; +}; diff --git a/lib/integration/notifyConnector.ts b/lib/integration/notifyConnector.ts index 2f4f5eb185..241862bbcb 100644 --- a/lib/integration/notifyConnector.ts +++ b/lib/integration/notifyConnector.ts @@ -1,9 +1,47 @@ -import { GCNotifyConnector, type Personalisation } from "@gcforms/connectors"; +import { + EmailAttachment, + EmailContent, + GCNotifyConnector, + sendImmediate, + sendDeferred, +} from "@gcforms/connectors"; import { logMessage } from "@lib/logger"; import { traceFunction } from "../otel"; +import { checkOne } from "@lib/cache/flags"; +import { FeatureFlags } from "@lib/cache/types"; + +type SendEmailOptions = ({ mode?: "immediate" } | { mode: "deferred"; notificationId: string }) & { + bypassNotificationPipeline?: boolean; +}; + const gcNotifyConnector = GCNotifyConnector.default(process.env.NOTIFY_API_KEY ?? ""); -export const sendEmail = async (email: string, personalisation: Personalisation, type: string) => { +export function sendDefaultEmail(input: { + to: string[]; + subject: string; + body: string; + attachments?: EmailAttachment[]; + options?: SendEmailOptions; +}): Promise { + return sendEmail( + input.to, + { + templateId: process.env.TEMPLATE_ID ?? "undefined", + placeholders: { + subject: input.subject, + formResponse: input.body, + }, + attachments: input.attachments, + }, + input.options + ); +} + +async function sendEmail( + to: string[], + content: EmailContent, + options?: SendEmailOptions +): Promise { return traceFunction("sendEmail", async () => { try { if (process.env.APP_ENV === "test") { @@ -11,19 +49,42 @@ export const sendEmail = async (email: string, personalisation: Personalisation, return; } - const templateId = process.env.TEMPLATE_ID; - if (!templateId) { - throw new Error("No Notify template ID configured."); + const notificationEnabled = await checkOne(FeatureFlags.notification); + + if (notificationEnabled && options?.bypassNotificationPipeline !== true) { + logMessage.debug( + `Sending email through notification pipeline with option: ${options?.mode === "deferred" ? "sendDeferred" : "sendImmediate"}` + ); + + if (options?.mode === "deferred") { + await sendDeferred({ + notificationId: options.notificationId, + emails: to, + content, + }); + } else { + await sendImmediate({ emails: to, content }); + } + + return; } - await gcNotifyConnector.sendEmail(email, templateId, personalisation); + logMessage.debug("Sending email directly through GC Notify"); - logMessage.info("HealthCheck: send email success"); + await Promise.all( + to.map((emailAddress) => + gcNotifyConnector.sendEmail(emailAddress, content).catch((error) => { + logMessage.warn( + `Failed to send email to ${emailAddress} through GC Notify. Reason: ${ + (error as Error).message + }` + ); + }) + ) + ); } catch (error) { - logMessage.info("HealthCheck: send email failure"); - logMessage.error( - `Failed to send ${type} email to ${email} through GC Notify. Reason: ${ + `Failed to send email to ${to.join(", ")} through GC Notify. Reason: ${ (error as Error).message }` ); @@ -31,4 +92,4 @@ export const sendEmail = async (email: string, personalisation: Personalisation, throw error; } }); -}; +} diff --git a/lib/invitations/inviteUserByEmail.ts b/lib/invitations/inviteUserByEmail.ts index 03c9ddad33..5f413b1faa 100644 --- a/lib/invitations/inviteUserByEmail.ts +++ b/lib/invitations/inviteUserByEmail.ts @@ -9,7 +9,7 @@ import { } from "./exceptions"; import { getTemplateWithAssignedUsers } from "@lib/templates/queries/getTemplateWithAssignedUsers"; import { prisma, Invitation } from "@gcforms/database"; -import { sendEmail } from "@lib/integration/notifyConnector"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { inviteToCollaborateEmailTemplate } from "@lib/invitations/emailTemplates/inviteToCollaborateEmailTemplate"; import { inviteToFormsEmailTemplate } from "@lib/invitations/emailTemplates/inviteToFormsEmailTemplate"; import { getOrigin } from "@lib/origin"; @@ -198,14 +198,11 @@ const _sendInvitationEmail = async ( formUrlFr ); - await sendEmail( - email, - { - subject: "Invitation to access form | Invitation pour accéder au formulaire", - formResponse: emailContent, - }, - "formInvitationToExistingUser" - ); + await sendDefaultEmail({ + to: [email], + subject: "Invitation to access form | Invitation pour accéder au formulaire", + body: emailContent, + }); return; } @@ -221,14 +218,11 @@ const _sendInvitationEmail = async ( registerUrlFr ); - await sendEmail( - email, - { - subject: "Invitation to access form | Invitation pour accéder au formulaire", - formResponse: emailContent, - }, - "formInvitationToFutureUser" - ); + await sendDefaultEmail({ + to: [email], + subject: "Invitation to access form | Invitation pour accéder au formulaire", + body: emailContent, + }); }; /** diff --git a/lib/invitations/tests/invitations.test.ts b/lib/invitations/tests/invitations.test.ts index 21d53260b7..518b57cf34 100644 --- a/lib/invitations/tests/invitations.test.ts +++ b/lib/invitations/tests/invitations.test.ts @@ -3,7 +3,7 @@ import { prismaMock } from "@testUtils"; import { mockAuthorizationPass, mockGetAbility } from "__utils__/authorization"; import { getUser } from "@lib/users"; import { getTemplateWithAssignedUsers } from "@lib/templates/queries/getTemplateWithAssignedUsers"; -import { sendEmail } from "@lib/integration/notifyConnector"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; import { InvalidDomainError, InvitationIsExpiredError, @@ -152,15 +152,12 @@ describe("Invitations", () => { expect.stringContaining("register") ); - expect(sendEmail).toHaveBeenCalledTimes(1); - expect(sendEmail).toHaveBeenCalledWith( - "invited@cds-snc.ca", - expect.objectContaining({ - subject: expect.any(String), - formResponse: "email contents", - }), - "formInvitationToFutureUser" - ); + expect(sendDefaultEmail).toHaveBeenCalledTimes(1); + expect(sendDefaultEmail).toHaveBeenCalledWith({ + to: ["invited@cds-snc.ca"], + subject: "Invitation to access form | Invitation pour accéder au formulaire", + body: "email contents", + }); expect(invalidateTemplateEditLockUserCountCache).toHaveBeenCalledWith("form-id"); }); @@ -205,12 +202,12 @@ describe("Invitations", () => { expect.stringContaining("forms"), expect.stringContaining("forms") ); - expect(sendEmail).toHaveBeenCalledTimes(1); - expect(sendEmail).toHaveBeenCalledWith( - "invited@cds-snc.ca", - expect.any(Object), - "formInvitationToExistingUser" - ); + expect(sendDefaultEmail).toHaveBeenCalledTimes(1); + expect(sendDefaultEmail).toHaveBeenCalledWith({ + to: ["invited@cds-snc.ca"], + subject: "Invitation to access form | Invitation pour accéder au formulaire", + body: "email contents", + }); expect(invalidateTemplateEditLockUserCountCache).toHaveBeenCalledWith("form-id"); }); @@ -281,12 +278,12 @@ describe("Invitations", () => { expect.stringContaining("register"), expect.stringContaining("register") ); - expect(sendEmail).toHaveBeenCalledTimes(1); - expect(sendEmail).toHaveBeenCalledWith( - "invited2@cds-snc.ca", - expect.any(Object), - "formInvitationToFutureUser" - ); + expect(sendDefaultEmail).toHaveBeenCalledTimes(1); + expect(sendDefaultEmail).toHaveBeenCalledWith({ + to: ["invited2@cds-snc.ca"], + subject: "Invitation to access form | Invitation pour accéder au formulaire", + body: "email contents", + }); expect(invalidateTemplateEditLockUserCountCache).toHaveBeenCalledWith("form-id"); }); }); diff --git a/lib/notifications.ts b/lib/notifications.ts deleted file mode 100644 index dd65e616f9..0000000000 --- a/lib/notifications.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { sendEmail } from "@lib/integration/notifyConnector"; -import { logMessage } from "@lib/logger"; -import { getRedisInstance } from "@lib/integration/redisConnector"; -import { getOrigin } from "@lib/origin"; -import { NotificationsInterval } from "@gcforms/types"; -import { serverTranslation } from "@i18n"; -import { prisma, prismaErrors } from "@gcforms/database"; - -// Hard coded since only one interval is supported currently -const NOTIFICATIONS_INTERVAL = NotificationsInterval.DAY; - -const Status = { - SINGLE_EMAIL_SENT: "SINGLE_EMAIL_SENT", - MULTIPLE_EMAIL_SENT: "MULTIPLE_EMAIL_SENT", -} as const; -type Status = (typeof Status)[keyof typeof Status]; - -// Public facing function to send notifications to all related users on a form submission -export const sendNotifications = async (formId: string, titleEn: string, titleFr: string) => { - // Avoid sending additional notifications to legacy forms that receive delivery by email. - const deliveryOption = await _getDeliveryOption(formId); - if (deliveryOption) { - return; - } - - const users = await getNotificationsUsersForForm(formId); - - // Some older forms may not have users, do nothing - if (!Array.isArray(users) || users.length === 0) { - return; - } - - // No users have notifications enabled, do nothing - const atLeastOneUserEnabled = users.some((user) => user.enabled); - if (!atLeastOneUserEnabled) { - return; - } - - const marker = await getMarker(formId); - switch (marker) { - case Status.SINGLE_EMAIL_SENT: - // Single submissions email sent but not multiple submissions email, send multiple email - Promise.all([ - sendEmailNotificationsToAllUsers(users, formId, titleEn, titleFr, true), - setMarker(formId, Status.MULTIPLE_EMAIL_SENT), - ]); - break; - case Status.MULTIPLE_EMAIL_SENT: - // Multiple submissions email has been sent, do nothing - break; - default: - // No email has been sent, send single submission email - Promise.all([ - sendEmailNotificationsToAllUsers(users, formId, titleEn, titleFr, false), - setMarker(formId), - ]); - } -}; - -/** - * Returns whether the given user has notifications enabled for the given form - * - * @param formId - * @param userId - * @returns - */ -export const getUserNotificationSettingsForForm = async (formId: string, userId: string) => { - const template = await prisma.template - .findFirst({ - where: { - id: formId, - }, - select: { - notificationsUsers: { - where: { - id: userId, - }, - select: { - id: true, - }, - }, - }, - }) - .catch((e) => prismaErrors(e, null)); - - return !!template?.notificationsUsers.length; -}; - -/** - * Returns a list of users associated with the form and their notification settings - * - * @param formId - * @returns - */ -export const getNotificationsUsersForForm = async (formId: string) => { - const template = await prisma.template - .findUnique({ - where: { - id: formId, - }, - select: { - users: { - select: { - id: true, - email: true, - notificationsTemplates: { - where: { - id: formId, - }, - select: { - id: true, - }, - }, - }, - }, - }, - }) - .catch((e) => prismaErrors(e, null)); - - if (!template) { - logMessage.debug(`_getNotificationsUsers no users found for formId ${formId}`); - return null; - } - - return template.users.map((user) => ({ - id: user.id, - email: user.email, - enabled: user.notificationsTemplates.length > 0, - })); -}; - -const _getDeliveryOption = async (formId: string) => { - const template = await prisma.template - .findUnique({ - where: { - id: formId, - }, - select: { - deliveryOption: true, - }, - }) - .catch((e) => prismaErrors(e, null)); - - if (!template) { - logMessage.debug(`_getDeliveryOption template not found with id ${formId}`); - return null; - } - - return template.deliveryOption; -}; - -const setMarker = async (formId: string, status: Status = Status.SINGLE_EMAIL_SENT) => { - const ttl = NOTIFICATIONS_INTERVAL * 60; // convert from minutes to seconds - const redis = await getRedisInstance(); - await redis - .set(`notification:formId:${formId}`, status, "EX", ttl) - .then(() => - logMessage.debug( - `setMarker: notification:formId:${formId} set with ttl ${ttl} and marked ${status}` - ) - ) - .catch((err) => - logMessage.error(`setMarker: notification:formId:${formId} failed to set ${err}`) - ); -}; - -const getMarker = async (formId: string) => { - const redis = await getRedisInstance(); - return redis - .get(`notification:formId:${formId}`) - .catch((err) => logMessage.error(`getMarker: ${err}`)); -}; - -const sendEmailNotificationsToAllUsers = async ( - users: { - email: string; - enabled: boolean; - }[], - formId: string, - formTitleEn: string, - formTitleFr: string, - multipleSubmissions: boolean = false -) => { - if (!Array.isArray(users) || users.length === 0) { - logMessage.debug("sendEmailNotificationsToAllUsers missing users"); - return; - } - users.forEach( - ({ email, enabled }) => - enabled && sendEmailNotification(email, formId, formTitleEn, formTitleFr, multipleSubmissions) - ); -}; - -const sendEmailNotification = async ( - email: string, - formId: string, - formTitleEn: string, - formTitleFr: string, - multipleSubmissions: boolean = false -) => { - const { t } = await serverTranslation("form-builder"); - const HOST = await getOrigin(); - await sendEmail( - email, - { - subject: multipleSubmissions - ? t("settings.notifications.email.multipleSubmissions.subject") - : t("settings.notifications.email.singleSubmission.subject"), - formResponse: multipleSubmissions - ? await multipleSubmissionsEmailTemplate(HOST, formTitleEn, formTitleFr) - : await singleSubmissionEmailTemplate(HOST, formTitleEn, formTitleFr), - }, - "notification" - ) - .then(() => - logMessage.debug( - `sendEmailNotification sent email to ${email} with formId ${formId} for type ${ - multipleSubmissions ? "multiple email" : "single email" - }` - ) - ) - .catch(() => - logMessage.error(`sendEmailNotification failed to send email ${email} with formId ${formId}`) - ); -}; - -const singleSubmissionEmailTemplate = async ( - HOST: string, - formTitleEn: string, - formTitleFr: string -) => { - const { t: t_en } = await serverTranslation("form-builder", { lang: "en" }); - const { t: t_fr } = await serverTranslation("form-builder", { lang: "fr" }); - return ` -${t_en("settings.notifications.email.singleSubmission.paragraph1")} -${formTitleEn} - -**[${t_en("settings.notifications.email.singleSubmission.paragraph2")}](${HOST}/auth/login)** - -*${t_en("settings.notifications.email.singleSubmission.paragraph3")}* - ---- - -${t_fr("settings.notifications.email.singleSubmission.paragraph1")} -${formTitleFr} - -**[${t_fr("settings.notifications.email.singleSubmission.paragraph2")}](${HOST}/auth/login)** - -*${t_fr("settings.notifications.email.singleSubmission.paragraph3")}* - `; -}; - -const multipleSubmissionsEmailTemplate = async ( - HOST: string, - formTitleEn: string, - formTitleFr: string -) => { - const { t: t_en } = await serverTranslation("form-builder", { lang: "en" }); - const { t: t_fr } = await serverTranslation("form-builder", { lang: "fr" }); - return ` - ${t_en("settings.notifications.email.multipleSubmissions.paragraph1")} - ${formTitleEn} - - **[${t_en("settings.notifications.email.multipleSubmissions.paragraph2")}](${HOST}/auth/login)** - - *${t_en("settings.notifications.email.multipleSubmissions.paragraph3")}* - - --- - - ${t_fr("settings.notifications.email.multipleSubmissions.paragraph1")} - ${formTitleFr} - - **[${t_fr("settings.notifications.email.multipleSubmissions.paragraph2")}](${HOST}/auth/login)** - - *${t_fr("settings.notifications.email.multipleSubmissions.paragraph3")}* - `; -}; - -const archivedFormEmailTemplate = async ( - HOST: string, - formTitleEn: string, - formTitleFr: string, - actionEmail: string -) => { - const { t: t_en } = await serverTranslation("form-builder", { lang: "en" }); - const { t: t_fr } = await serverTranslation("form-builder", { lang: "fr" }); - return ` - ${t_en("settings.notifications.email.archivedForm.paragraph1")} - ${formTitleEn} - ${t_en("settings.notifications.email.archivedForm.paragraph2")} - ${actionEmail} - - **[${t_en("settings.notifications.email.archivedForm.paragraph3")}](${HOST}/auth/login)** - - *${t_en("settings.notifications.email.archivedForm.paragraph4")}* - - --- - - ${t_fr("settings.notifications.email.archivedForm.paragraph1")} - ${formTitleFr} - ${t_fr("settings.notifications.email.archivedForm.paragraph2")} - ${actionEmail} - - **[${t_fr("settings.notifications.email.archivedForm.paragraph3")}](${HOST}/auth/login)** - - *${t_fr("settings.notifications.email.archivedForm.paragraph4")}* - `; -}; - -const sendArchivedFormNotification = async ( - email: string, - formId: string, - formTitleEn: string, - formTitleFr: string, - actionEmail: string -) => { - const { t } = await serverTranslation("form-builder"); - const HOST = await getOrigin(); - await sendEmail( - email, - { - subject: t("settings.notifications.email.archivedForm.subject"), - formResponse: await archivedFormEmailTemplate(HOST, formTitleEn, formTitleFr, actionEmail), - }, - "archived_form_notification" - ) - .then(() => - logMessage.debug( - `sendArchivedFormNotification sent email to ${email} with formId ${formId} for type archived form` - ) - ) - .catch(() => - logMessage.error( - `sendArchivedFormNotification failed to send email ${email} with formId ${formId}` - ) - ); -}; - -const sendArchivedFormNotificationsToAllUsers = async ( - users: { - email: string; - }[], - formId: string, - formTitleEn: string, - formTitleFr: string, - actionEmail: string -) => { - if (!Array.isArray(users) || users.length === 0) { - logMessage.debug("sendArchivedFormNotificationsToAllUsers missing users"); - return; - } - users.forEach(({ email }) => - sendArchivedFormNotification(email, formId, formTitleEn, formTitleFr, actionEmail) - ); -}; - -interface Session { - user: { - email: string; - }; -} - -// Public facing function to send notifications to all related users on a form archival -export const sendArchivedFormNotifications = async ( - session: Session, - formId: string, - titleEn: string, - titleFr: string, - templateUsers: { email: string }[] -): Promise => { - // Some older forms may not have users, do nothing - if (!Array.isArray(templateUsers) || templateUsers.length === 0) { - return; - } - - sendArchivedFormNotificationsToAllUsers( - templateUsers, - formId, - titleEn, - titleFr, - session.user.email - ); -}; diff --git a/lib/templates/internal/notifications.ts b/lib/templates/internal/notifications.ts index f353d86d4e..8d14b4aba1 100644 --- a/lib/templates/internal/notifications.ts +++ b/lib/templates/internal/notifications.ts @@ -2,7 +2,7 @@ import { FormProperties } from "@lib/types"; import { youHaveBeenRemovedEmailTemplate } from "@lib/invitations/emailTemplates/youHaveBeenRemovedEmailTemplate"; import { ownerRemovedEmailTemplate } from "@lib/invitations/emailTemplates/ownerRemovedEmailTemplate"; import { ownerAddedEmailTemplate } from "@lib/invitations/emailTemplates/ownerAddedEmailTemplate"; -import { sendEmail } from "@lib/integration/notifyConnector"; +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; /** * Notify owners of ownership changes (owner removed) @@ -22,14 +22,11 @@ export const notifyOwnerRemoved = async ( form.titleFr ); - sendEmail( - userToRemove.email, - { - subject: "Form access removed | Accès au formulaire supprimé", - formResponse: youHaveBeenRemovedEmailContent, - }, - "notifyRemovedOwner" - ); + sendDefaultEmail({ + to: [userToRemove.email], + subject: "Form access removed | Accès au formulaire supprimé", + body: youHaveBeenRemovedEmailContent, + }); // Send email to remaining owners users.forEach((owner) => { @@ -39,14 +36,11 @@ export const notifyOwnerRemoved = async ( userToRemove.name || "An owner" ); - sendEmail( - owner.email, - { - subject: "Form access removed | Accès au formulaire supprimé", - formResponse: ownerRemovedEmailContent, - }, - "notifyOtherOwnersOfRemovedOwner" - ); + sendDefaultEmail({ + to: [owner.email], + subject: "Form access removed | Accès au formulaire supprimé", + body: ownerRemovedEmailContent, + }); }); }; @@ -68,14 +62,9 @@ export const notifyOwnerAdded = async ( userToAdd.name || userToAdd.email ); - users.forEach((owner) => { - sendEmail( - owner.email, - { - subject: "Ownership change notification | Notification de changement de propriété", - formResponse: emailContent, - }, - "notifyAddedOwner" - ); + sendDefaultEmail({ + to: users.map((u) => u.email), + subject: "Ownership change notification | Notification de changement de propriété", + body: emailContent, }); }; diff --git a/lib/tests/formEmailOrchestration.test.ts b/lib/tests/formEmailOrchestration.test.ts new file mode 100644 index 0000000000..b96796f63a --- /dev/null +++ b/lib/tests/formEmailOrchestration.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotificationsInterval } from "@gcforms/types"; + +vi.mock("@gcforms/database", () => ({ + prisma: { + template: { + findUnique: vi.fn(), + }, + }, + prismaErrors: vi.fn((_err, fallback) => fallback), +})); + +vi.mock("@lib/logger", () => ({ + logMessage: { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})); + +// Unused in getFormNotificationInterval but imported at module level in notifications.ts +vi.mock("@gcforms/connectors", () => ({ + notification: { + sendDeferred: vi.fn(), + sendImmediate: vi.fn(), + enqueueDeferred: vi.fn(), + }, + GCNotifyConnector: { + default: vi.fn(() => ({ + sendEmail: vi.fn(), + })), + }, +})); + +vi.mock("@lib/integration/redisConnector", async () => { + const { default: Redis } = await import("ioredis-mock"); + const redis = new Redis(); + return { + getRedisInstance: vi.fn(async () => redis), + }; +}); + +vi.mock("@lib/origin", () => ({ + getOrigin: vi.fn(), +})); + +vi.mock("@i18n", () => ({ + serverTranslation: vi.fn(), +})); + +import { getFormNotificationInterval, updateNotificationMarker } from "@lib/formEmailOrchestration"; +import { prisma } from "@gcforms/database"; +import { getRedisInstance } from "@lib/integration/redisConnector"; + +const mockFormId = "test-form-id"; + +// Helper to set up the single merged prisma.template.findUnique call +const mockTemplate = ( + options: { + deliveryOption?: object | null; + notificationsInterval?: number | null; + users?: { id: string; notificationsTemplates: { id: string }[] }[]; + } | null +) => { + if (options === null) { + vi.mocked(prisma.template.findUnique).mockResolvedValueOnce(null); + } else { + vi.mocked(prisma.template.findUnique).mockResolvedValueOnce({ + deliveryOption: options.deliveryOption ?? null, + notificationsInterval: + options.notificationsInterval !== undefined + ? options.notificationsInterval + : NotificationsInterval.DAY, + users: options.users ?? [], + } as never); + } +}; + +describe("getFormNotificationInterval", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns false when the form has a delivery option set (legacy email delivery form)", async () => { + mockTemplate({ deliveryOption: { emailAddress: "recipient@example.com" }, users: [] }); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(false); + expect(prisma.template.findUnique).toHaveBeenCalledTimes(1); + }); + + it("returns false when the form template is not found", async () => { + mockTemplate(null); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(false); + }); + + it("returns false when notificationsInterval is null (notifications turned off)", async () => { + mockTemplate({ + notificationsInterval: null, + users: [{ id: "user-1", notificationsTemplates: [{ id: mockFormId }] }], + }); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(false); + }); + + it("returns false when the form has no associated users", async () => { + mockTemplate({ users: [] }); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(false); + }); + + it("returns false when no users have notifications enabled", async () => { + mockTemplate({ + users: [ + { id: "user-1", notificationsTemplates: [] }, + { id: "user-2", notificationsTemplates: [] }, + ], + }); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(false); + }); + + it("returns the interval when at least one user has notifications enabled", async () => { + mockTemplate({ + users: [{ id: "user-1", notificationsTemplates: [{ id: mockFormId }] }], + }); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(NotificationsInterval.DAY); + }); + + it("returns the interval when some users have notifications enabled and others do not", async () => { + mockTemplate({ + notificationsInterval: NotificationsInterval.WEEK, + users: [ + { id: "user-1", notificationsTemplates: [] }, + { id: "user-2", notificationsTemplates: [{ id: mockFormId }] }, + ], + }); + + const result = await getFormNotificationInterval(mockFormId); + + expect(result).toBe(NotificationsInterval.WEEK); + }); +}); + +describe("updateNotificationMarker", () => { + const redisKey = `notification:formId:${mockFormId}`; + let redis: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + redis = await getRedisInstance(); + await redis.flushall(); + }); + + it("returns FIRST_EMAIL and sets marker to SINGLE_EMAIL_SENT when no prior marker exists", async () => { + const result = await updateNotificationMarker(mockFormId, NotificationsInterval.DAY); + + expect(result).toBe("FIRST_EMAIL"); + expect(await redis.get(redisKey)).toBe("SINGLE_EMAIL_SENT"); + }); + + it("returns SECOND_EMAIL and advances marker to MULTIPLE_EMAIL_SENT when marker is SINGLE_EMAIL_SENT", async () => { + await redis.set(redisKey, "SINGLE_EMAIL_SENT"); + + const result = await updateNotificationMarker(mockFormId, NotificationsInterval.DAY); + + expect(result).toBe("SECOND_EMAIL"); + expect(await redis.get(redisKey)).toBe("MULTIPLE_EMAIL_SENT"); + }); + + it("returns null and does not change the marker when marker is MULTIPLE_EMAIL_SENT", async () => { + await redis.set(redisKey, "MULTIPLE_EMAIL_SENT"); + + const result = await updateNotificationMarker(mockFormId, NotificationsInterval.DAY); + + expect(result).toBeNull(); + expect(await redis.get(redisKey)).toBe("MULTIPLE_EMAIL_SENT"); + }); + + it("returns FIRST_EMAIL and resets the marker when an unexpected value is present", async () => { + await redis.set(redisKey, ""); + + const result = await updateNotificationMarker(mockFormId, NotificationsInterval.DAY); + + expect(result).toBe("FIRST_EMAIL"); + expect(await redis.get(redisKey)).toBe("SINGLE_EMAIL_SENT"); + }); +}); diff --git a/lib/tests/notifyConnector.test.ts b/lib/tests/notifyConnector.test.ts new file mode 100644 index 0000000000..f121d4ff07 --- /dev/null +++ b/lib/tests/notifyConnector.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + gcNotifySendEmail: vi.fn().mockResolvedValue({}), + notificationSendImmediate: vi.fn(), + notificationSendDeferred: vi.fn(), + checkOne: vi.fn(), + logInfo: vi.fn(), + logDebug: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +vi.mock("@gcforms/connectors", () => ({ + GCNotifyConnector: { + default: vi.fn(() => ({ sendEmail: mocks.gcNotifySendEmail })), + }, + sendImmediate: mocks.notificationSendImmediate, + sendDeferred: mocks.notificationSendDeferred, +})); + +vi.mock("@lib/logger", () => ({ + logMessage: { + info: mocks.logInfo, + debug: mocks.logDebug, + error: mocks.logError, + warn: mocks.logWarn, + }, +})); + +// traceFunction must call its callback to execute the actual sendEmail logic +vi.mock("../otel", () => ({ + traceFunction: vi.fn((_name: string, fn: () => unknown) => fn()), +})); + +vi.mock("@lib/cache/flags", () => ({ + checkOne: mocks.checkOne, +})); + +import { sendDefaultEmail } from "@lib/integration/notifyConnector"; + +describe("sendDefaultEmail", () => { + describe("test-environment guard", () => { + it("logs info and returns early without sending when APP_ENV is 'test'", async () => { + vi.stubEnv("APP_ENV", "test"); + + await sendDefaultEmail({ to: ["user@example.com"], subject: "", body: "" }); + + expect(mocks.logInfo).toHaveBeenCalledWith("Mock Notify email sent."); + expect(mocks.notificationSendImmediate).not.toHaveBeenCalled(); + expect(mocks.notificationSendDeferred).not.toHaveBeenCalled(); + expect(mocks.gcNotifySendEmail).not.toHaveBeenCalled(); + }); + }); + + describe("routing logic", () => { + beforeEach(() => { + vi.stubEnv("APP_ENV", "production"); + vi.stubEnv("TEMPLATE_ID", "dummy_template_id"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("routes to sendImmediate when the notification flag is on and there is no file attachment", async () => { + mocks.checkOne.mockResolvedValue(true); + + await sendDefaultEmail({ + to: ["user@example.com"], + subject: "Test Subject", + body: "Test Body", + }); + + expect(mocks.notificationSendImmediate).toHaveBeenCalledWith({ + emails: ["user@example.com"], + content: expect.objectContaining({ + attachments: undefined, + placeholders: { + formResponse: "Test Body", + subject: "Test Subject", + }, + }), + }); + expect(mocks.gcNotifySendEmail).not.toHaveBeenCalled(); + }); + + it("routes to sendDeferred when the notification flag is on and mode is deferred", async () => { + mocks.checkOne.mockResolvedValue(true); + + await sendDefaultEmail({ + to: ["user@example.com"], + subject: "Test Subject", + body: "Test Body", + options: { + mode: "deferred", + notificationId: "notif-abc-123", + }, + }); + + expect(mocks.notificationSendDeferred).toHaveBeenCalledWith({ + notificationId: "notif-abc-123", + emails: ["user@example.com"], + content: expect.objectContaining({ + placeholders: { + formResponse: "Test Body", + subject: "Test Subject", + }, + }), + }); + expect(mocks.gcNotifySendEmail).not.toHaveBeenCalled(); + }); + + it("falls back to GC Notify when the notification flag is off", async () => { + mocks.checkOne.mockResolvedValue(false); + + await sendDefaultEmail({ to: ["user@example.com"], subject: "", body: "" }); + + expect(mocks.gcNotifySendEmail).toHaveBeenCalledWith( + "user@example.com", + expect.objectContaining({ + templateId: "dummy_template_id", + }) + ); + expect(mocks.notificationSendImmediate).not.toHaveBeenCalled(); + }); + + it("falls back to GC Notify when bypassNotificationPipeline is true, even if the flag is on", async () => { + mocks.checkOne.mockResolvedValue(true); + + await sendDefaultEmail({ + to: ["user@example.com"], + subject: "", + body: "", + options: { + bypassNotificationPipeline: true, + }, + }); + + expect(mocks.gcNotifySendEmail).toHaveBeenCalledWith("user@example.com", expect.any(Object)); + expect(mocks.notificationSendImmediate).not.toHaveBeenCalled(); + }); + + it("sends to all addresses when an array of emails is provided", async () => { + mocks.checkOne.mockResolvedValue(false); + const emails = ["a@example.com", "b@example.com", "c@example.com"]; + + await sendDefaultEmail({ to: emails, subject: "", body: "" }); + + expect(mocks.gcNotifySendEmail).toHaveBeenCalledTimes(3); + for (const addr of emails) { + expect(mocks.gcNotifySendEmail).toHaveBeenCalledWith(addr, expect.any(Object)); + } + }); + }); + + describe("error handling", () => { + beforeEach(() => { + vi.stubEnv("APP_ENV", "production"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("throws and logs an error when GC Notify sendEmail rejects", async () => { + mocks.checkOne.mockResolvedValue(false); + mocks.gcNotifySendEmail.mockRejectedValueOnce(new Error("Notify API unavailable")); + + await sendDefaultEmail({ to: ["user@example.com"], subject: "", body: "" }); + + expect(mocks.logWarn).toHaveBeenCalledWith( + "Failed to send email to user@example.com through GC Notify. Reason: Notify API unavailable" + ); + }); + }); +}); diff --git a/packages/connectors/CHANGELOG.md b/packages/connectors/CHANGELOG.md index 8fc953e8c8..d465794db6 100644 --- a/packages/connectors/CHANGELOG.md +++ b/packages/connectors/CHANGELOG.md @@ -5,11 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-07-02 + +- Rework `GCNotifyConnector` and `Notification` modules to prepare for new notification pipeline + +## [2.2.22] - 2026-06-16 + +- Remove unused input for `sendImmediate` function + ## [2.2.21] - 2026-06-15 - Update AWS SDK packages to latest versions - ## [2.2.20] - 2026-05-11 - Update AWS SDK packages to latest versions diff --git a/packages/connectors/package.json b/packages/connectors/package.json index 5148e1f7d4..955cbe0ef0 100644 --- a/packages/connectors/package.json +++ b/packages/connectors/package.json @@ -1,6 +1,6 @@ { "name": "@gcforms/connectors", - "version": "2.2.21", + "version": "3.0.0", "author": "Canadian Digital Service", "license": "MIT", "publishConfig": { diff --git a/packages/connectors/src/gc-notify-connector.ts b/packages/connectors/src/gc-notify-connector.ts index 92aa82c53f..e94fa31ed2 100644 --- a/packages/connectors/src/gc-notify-connector.ts +++ b/packages/connectors/src/gc-notify-connector.ts @@ -2,9 +2,18 @@ import { Agent } from "https"; import { getAwsSecret } from "./utils"; import axios, { AxiosError } from "axios"; -const API_URL: string = "https://api.notification.canada.ca"; +export type EmailAttachment = { + fileName: string; + base64EncodedFile: string; +}; + +export type EmailContent = { + templateId: string; + placeholders: Record; + attachments?: EmailAttachment[]; +}; -export type Personalisation = Record>; +const API_URL: string = "https://api.notification.canada.ca"; const httpsAgent = new Agent({ keepAlive: true }); @@ -37,10 +46,9 @@ export class GCNotifyConnector { } public async sendEmail( - emailAddress: string, - templateId: string, - personalisation: Personalisation, - reference?: string + to: string, + content: EmailContent, + referenceIdentifier?: string ): Promise { try { await axios({ @@ -53,10 +61,24 @@ export class GCNotifyConnector { Authorization: `ApiKey-v1 ${this.apiKey}`, }, data: { - email_address: emailAddress, - template_id: templateId, - personalisation, - ...(reference && { reference }), + email_address: to, + template_id: content.templateId, + personalisation: { + ...content.placeholders, + ...(content.attachments + ? Object.fromEntries( + content.attachments.map((f, i) => [ + `file${i}`, + { + file: f.base64EncodedFile, + filename: f.fileName, + sending_method: "attach", + }, + ]) + ) + : {}), + }, + ...(referenceIdentifier && { reference: referenceIdentifier }), }, }); } catch (error) { diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts index a8836567e4..c9348c3adc 100644 --- a/packages/connectors/src/index.ts +++ b/packages/connectors/src/index.ts @@ -1,3 +1,3 @@ -export { GCNotifyConnector, type Personalisation } from "./gc-notify-connector"; +export { GCNotifyConnector, type EmailContent, type EmailAttachment } from "./gc-notify-connector"; export { PostgresConnector } from "./postgres-connector"; -export { notification } from "./notification"; +export { sendImmediate, sendDeferred, enqueueDeferred } from "./notification"; diff --git a/packages/connectors/src/notification.ts b/packages/connectors/src/notification.ts index c70975f154..99bbc73d5a 100644 --- a/packages/connectors/src/notification.ts +++ b/packages/connectors/src/notification.ts @@ -4,6 +4,7 @@ import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; import { randomUUID } from "crypto"; import { ErrorWithCause } from "./types/errors"; import { getAwsSQSQueueURL } from "./utils"; +import { EmailContent } from "./gc-notify-connector"; const DYNAMODB_NOTIFICATION_TABLE_NAME = "Notification"; @@ -16,13 +17,16 @@ const dynamoDBDocumentClient = DynamoDBDocumentClient.from( ...globalConfig, // SDK retries use exponential backoff with jitter by default maxAttempts: 15, - }) + }), + { marshallOptions: { removeUndefinedValues: true } } ); const sqsClient = new SQSClient({ ...globalConfig, }); +let cachedNotificationQueueUrl: string | null = null; + /** * Creates a notification record in DynamoDB and enqueues it for immediate sending. * @@ -30,19 +34,17 @@ const sqsClient = new SQSClient({ * @param subject - Email subject line * @param body - Email body content */ -const sendImmediate = async ({ +export const sendImmediate = async ({ emails, - subject, - body, + content, }: { - notificationId?: string; emails: string[]; - subject: string; - body: string; + content: EmailContent; }): Promise => { const notificationId = randomUUID(); + try { - await _createRecord({ notificationId, emails, subject, body }); + await _createRecord({ notificationId, emails, content }); await enqueueDeferred(notificationId); } catch (error) { throw new ErrorWithCause(`Error creating immediate notification id ${notificationId}`, { @@ -58,20 +60,21 @@ const sendImmediate = async ({ * * @param notificationId - Unique identifier for the notification to enqueue and * used by the notification lambda to look up the record in DynamoDB. + * @param emails - Array of email addresses to send the notification to + * @param subject - Email subject line + * @param body - Email body content */ -const sendDeferred = async ({ +export const sendDeferred = async ({ notificationId, emails, - subject, - body, + content, }: { notificationId: string; emails: string[]; - subject: string; - body: string; + content: EmailContent; }): Promise => { try { - await _createRecord({ notificationId, emails, subject, body }); + await _createRecord({ notificationId, emails, content }); } catch (error) { throw new ErrorWithCause(`Error creating deferred notification id ${notificationId}`, { cause: error, @@ -79,48 +82,27 @@ const sendDeferred = async ({ } }; -const _createRecord = async ({ - notificationId, - emails, - subject, - body, -}: { - notificationId: string; - emails: string[]; - subject: string; - body: string; -}): Promise => { +export const enqueueDeferred = async (notificationId: string): Promise => { try { - const ttl = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now - const command = new PutCommand({ - TableName: DYNAMODB_NOTIFICATION_TABLE_NAME, - Item: { - NotificationID: notificationId, - Emails: emails, - Subject: subject, - Body: body, - TTL: ttl, - }, - }); - await dynamoDBDocumentClient.send(command); - } catch (error) { - throw new ErrorWithCause(`Could not create record`, { cause: error }); - } -}; + if (cachedNotificationQueueUrl === null) { + cachedNotificationQueueUrl = await getAwsSQSQueueURL( + "NOTIFICATION_QUEUE_URL", + "notification_queue" + ); + } -const enqueueDeferred = async (notificationId: string): Promise => { - try { - const queueUrl = await getAwsSQSQueueURL("NOTIFICATION_QUEUE_URL", "notification_queue"); - if (!queueUrl) { - throw new Error("Notification Queue not connected"); + if (cachedNotificationQueueUrl === null) { + throw new Error("SQS Notification queue is null"); } - const command = new SendMessageCommand({ - MessageBody: JSON.stringify({ notificationId }), - QueueUrl: queueUrl, - }); - const sendMessageCommandOutput = await sqsClient.send(command); - if (!sendMessageCommandOutput.MessageId) { + const sendMessageCommandOutput = await sqsClient.send( + new SendMessageCommand({ + MessageBody: JSON.stringify({ notificationId }), + QueueUrl: cachedNotificationQueueUrl, + }) + ); + + if (sendMessageCommandOutput.MessageId === undefined) { throw new Error("Received null SQS message identifier"); } } catch (error) { @@ -128,8 +110,28 @@ const enqueueDeferred = async (notificationId: string): Promise => { } }; -export const notification = { - sendImmediate, - sendDeferred, - enqueueDeferred, +const _createRecord = async ({ + notificationId, + emails, + content, +}: { + notificationId: string; + emails: string[]; + content: EmailContent; +}): Promise => { + try { + await dynamoDBDocumentClient.send( + new PutCommand({ + TableName: DYNAMODB_NOTIFICATION_TABLE_NAME, + Item: { + NotificationID: notificationId, + Emails: emails, + Content: content, + TTL: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now, + }, + }) + ); + } catch (error) { + throw new ErrorWithCause(`Could not create record`, { cause: error }); + } }; diff --git a/packages/connectors/src/utils.ts b/packages/connectors/src/utils.ts index 67ed712f97..6f5ddf8471 100644 --- a/packages/connectors/src/utils.ts +++ b/packages/connectors/src/utils.ts @@ -28,5 +28,6 @@ export const getAwsSQSQueueURL = async ( QueueName: urlQueueName, }) ); + return data.QueueUrl ?? null; };