Skip to content
Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
f1bce72
Update notifications to use new notification package
thiessenp-cds Jan 6, 2026
266ea22
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 6, 2026
b58647c
Update import
thiessenp-cds Jan 6, 2026
4678663
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 6, 2026
c33ad2f
Update log message
thiessenp-cds Jan 6, 2026
8ddf21b
Merge branch 'feat/notification-v3' of https://github.com/cds-snc/pla…
thiessenp-cds Jan 6, 2026
7b626b8
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 12, 2026
3b6f94b
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 12, 2026
caf0780
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 13, 2026
455d08f
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 13, 2026
804e1d6
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 14, 2026
cb3c5ce
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 15, 2026
07ed6be
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 19, 2026
51eccd6
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 19, 2026
27eafdc
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 20, 2026
b6365ce
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 22, 2026
3e266d2
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 23, 2026
b7f09e7
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 27, 2026
aa0174f
Merge branch 'main' into feat/notification-v3
thiessenp-cds Jan 28, 2026
2d9e557
Merge branch 'main' into feat/notification-v3
thiessenp-cds Feb 4, 2026
b7117a2
Merge branch 'main' into feat/notification-v3
thiessenp-cds Feb 4, 2026
20714a1
Merge branch 'main' into feat/notification-v3
thiessenp-cds Feb 9, 2026
24c251e
Merge branch 'main' into feat/notification-v3
thiessenp-cds Feb 16, 2026
c85d1d9
Merge branch 'main' into feat/notification-v3
thiessenp-cds Feb 24, 2026
820212b
Merge branch 'main' into feat/notification-v3
thiessenp-cds Mar 3, 2026
06f4762
Merge branch 'main' into feat/notification-v3
thiessenp-cds Mar 5, 2026
2deda46
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Mar 6, 2026
99d12ab
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Mar 9, 2026
c5968af
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Mar 11, 2026
be233e2
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Mar 17, 2026
c58b05f
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Mar 23, 2026
ab4ff98
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Mar 30, 2026
ea83cf3
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Apr 8, 2026
5fca3ee
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Apr 17, 2026
d90dff2
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Apr 27, 2026
d1a72cb
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Apr 27, 2026
7302a89
Merge branch 'main' into feat/notifications-v3
thiessenp-cds May 11, 2026
bf6dcd4
Merge branch 'main' into feat/notifications-v3
thiessenp-cds May 20, 2026
f701fae
Merge remote-tracking branch 'origin/main' into feat/notifications-v3
craigzour May 25, 2026
5ad2c45
replace old sendEmail function with new sendImmediate
craigzour May 25, 2026
1d1006b
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Jun 1, 2026
521e50c
keep notification in DynamoDB for one hour
craigzour Jun 2, 2026
9e0f95d
Merge branch 'main' into feat/notifications-v3
craigzour Jun 9, 2026
9d2e39d
Merge branch 'main' into feat/notifications-v3
craigzour Jun 11, 2026
cbabc30
Merge remote-tracking branch 'origin/main' into feat/notifications-v3
craigzour Jun 16, 2026
0c326e6
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Jun 19, 2026
a32da31
Update to only enable notifications path when notification conditions…
thiessenp-cds Jun 22, 2026
3cf5d37
add debug logs to notification package
thiessenp-cds Jun 22, 2026
346200f
add notifications feature flag
thiessenp-cds Jun 22, 2026
f18cfab
Feature flag the send to archive call as well
thiessenp-cds Jun 23, 2026
6fe3e45
update unit test
thiessenp-cds Jun 23, 2026
a75d94d
move majority of notification send logic into sendEmail
thiessenp-cds Jun 25, 2026
9f08139
refactor notifications.ts to formEmailOrchestration.ts for clarity
thiessenp-cds Jun 25, 2026
5d25eb8
update comments
thiessenp-cds Jun 25, 2026
2072884
rename feature flag from notifications to notification for clarity
thiessenp-cds Jun 25, 2026
71a8d9f
fix a few race conditions and formatting
thiessenp-cds Jun 25, 2026
75e2ea9
minor optimizations and readabilty improvements
thiessenp-cds Jun 26, 2026
e9609ce
remove some dead code
thiessenp-cds Jun 26, 2026
3eb50aa
update code to respect notificationInterval settings
thiessenp-cds Jun 26, 2026
ec000ab
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Jun 26, 2026
36b1b18
add option to send direct to GC Notify and updates 2fa to use that
thiessenp-cds Jun 26, 2026
f1715f4
add missing notify unit tests and update a few comments
thiessenp-cds Jun 26, 2026
878eb16
Merge remote-tracking branch 'origin/main' into feat/notifications-v3
craigzour Jun 29, 2026
2f3b95d
add french translation for notification feature flag
craigzour Jun 30, 2026
562529b
add comment to explain why 2FA code cannot be sent through new notifi…
craigzour Jun 30, 2026
93e4d53
add error log
craigzour Jun 30, 2026
fe25f1f
fix tests
craigzour Jun 30, 2026
10443bb
remove extra space
craigzour Jun 30, 2026
cc9710e
add missing translations
craigzour Jun 30, 2026
00536d1
simplify sendArchivedFormNotifications function
craigzour Jun 30, 2026
32aa0c3
remove await when using sendArchivedFormNotifications as the actual o…
craigzour Jun 30, 2026
5963dd2
remove unnecessary try/catch block
craigzour Jun 30, 2026
e3654b3
adjust log level
craigzour Jun 30, 2026
3ca80ff
simplify code in formEmailOrchestration
craigzour Jun 30, 2026
7637071
simplify sendArchivedFormNotifications
craigzour Jun 30, 2026
892897d
rework GCNotify connector to handle attachments
craigzour Jun 30, 2026
7ab6635
refactor email sender code to allow file attachments (in emails) to b…
craigzour Jul 2, 2026
0f04bb5
make dynamodb handle undefined values
craigzour Jul 2, 2026
a1ffd51
Merge remote-tracking branch 'origin/main' into feat/notifications-v3
craigzour Jul 2, 2026
efd6d39
update changelog
craigzour Jul 2, 2026
8493edb
fix unit tests
craigzour Jul 2, 2026
67f92bf
fix tests
craigzour Jul 2, 2026
4256b6c
Merge branch 'main' into feat/notifications-v3
craigzour Jul 2, 2026
bf5eeed
Merge branch 'main' into feat/notifications-v3
craigzour Jul 3, 2026
bbde253
Merge branch 'main' into feat/notifications-v3
craigzour Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Notifications } from "./components/notifications/Notifications";
import {
getNotificationsUsersForForm,
getUserNotificationSettingsForForm,
} from "@lib/notifications";
} from "@lib/formEmailOrchestration";

export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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";

// Public facing functions - they can be used by anyone who finds the associated server action identifer
Expand Down
122 changes: 115 additions & 7 deletions app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ vi.mock("@root/i18n", () => ({
serverTranslation: vi.fn(),
}));

vi.mock("@lib/notifications", () => ({
sendNotifications: vi.fn(),
vi.mock("@lib/formEmailOrchestration", () => ({
isFormEligibleForEmails: vi.fn(),
updateNotificationMarker: vi.fn(),
prepareFormSubmissionEmail: vi.fn(),
}));

vi.mock("@lib/integration/notifyConnector", () => ({
sendEmail: vi.fn(),
}));

vi.mock("./lib/server/normalizeFormResponses", () => ({
Expand All @@ -52,7 +58,8 @@ 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 { isFormEligibleForEmails, prepareFormSubmissionEmail, updateNotificationMarker } from "@lib/formEmailOrchestration";
import { sendEmail } from "@lib/integration/notifyConnector";
import { normalizeFormResponses } from "./lib/server/normalizeFormResponses";
import { processFormData } from "./lib/server/processFormData";

Expand Down Expand Up @@ -93,7 +100,10 @@ describe("submitForm", () => {
submissionId: "test-submission-id",
fileURLMap: {}
});
(sendNotifications as Mock).mockResolvedValue(undefined);
(isFormEligibleForEmails as Mock).mockResolvedValue(false);
(updateNotificationMarker as Mock).mockResolvedValue(null);
(prepareFormSubmissionEmail as Mock).mockResolvedValue(null);
(sendEmail as Mock).mockResolvedValue(undefined);
});

it("should return MissingFormDataError when file input validation fails", async () => {
Expand Down Expand Up @@ -144,12 +154,110 @@ describe("submitForm", () => {
securityAttribute: mockTemplate.securityAttribute,
formId: mockFormId,
language: mockLanguage,
fileChecksums: undefined
fileChecksums: undefined,
notificationId: undefined,
});
expect(isFormEligibleForEmails).toHaveBeenCalledWith(mockFormId);
expect(prepareFormSubmissionEmail).not.toHaveBeenCalled();
expect(sendEmail).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);
(isFormEligibleForEmails as Mock).mockResolvedValue(true);
(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);

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(sendEmail).toHaveBeenCalledWith(
mockEmailData.emails,
{ subject: mockEmailData.subject, formResponse: mockEmailData.formResponse },
"formSubmissionNotification",
{ 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);
(isFormEligibleForEmails as Mock).mockResolvedValue(true);
(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);

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(sendEmail).toHaveBeenCalledWith(
mockEmailData.emails,
{ subject: mockEmailData.subject, formResponse: mockEmailData.formResponse },
"formSubmissionNotification",
{ 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);
(isFormEligibleForEmails as Mock).mockResolvedValue(true);
(updateNotificationMarker as Mock).mockResolvedValue(null);

await submitForm(mockValues, mockLanguage, mockFormId);

expect(updateNotificationMarker).toHaveBeenCalledWith(mockFormId);
expect(prepareFormSubmissionEmail).not.toHaveBeenCalled();
expect(sendEmail).not.toHaveBeenCalled();
expect(processFormData).toHaveBeenCalledWith(
expect.objectContaining({ notificationId: undefined })
);
});

Expand Down
57 changes: 54 additions & 3 deletions app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ 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 {
isFormEligibleForEmails,
prepareFormSubmissionEmail,
updateNotificationMarker,
} from "@lib/formEmailOrchestration";
import { sendEmail } 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 { randomUUID } from "crypto";

// Public facing functions - they can be used by anyone who finds the associated server action identifer

Expand Down Expand Up @@ -113,16 +119,22 @@ export async function submitForm(

const formData = normalizeFormResponses(template, values);

const notificationId = await scheduleFormSubmissionNotification(
formId,
template.form.titleEn,
template.form.titleFr
);

const { submissionId, fileURLMap } = await processFormData({
responses: formData,
securityAttribute: template.securityAttribute,
formId,
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(
Expand All @@ -133,3 +145,42 @@ export async function submitForm(
}
});
}

const scheduleFormSubmissionNotification = async (
formId: string,
formTitleEn: string,
formTitleFr: string
): Promise<string | undefined> => {
try {
const shouldSendNotification = await isFormEligibleForEmails(formId);
if (!shouldSendNotification) return undefined;

const notificationEmailType = await updateNotificationMarker(formId);
if (!notificationEmailType) return undefined;

const emailData = await prepareFormSubmissionEmail(
formId,
formTitleEn,
formTitleFr,
notificationEmailType
);
if (!emailData) return undefined;

const notificationId = randomUUID();

// Await to avoid a race condition where if the DB write fails, the reliability
// lambda will be given a reference to a non-existent record
await sendEmail(
emailData.emails,
{ subject: emailData.subject, formResponse: emailData.formResponse },
"formSubmissionNotification",
{ mode: "deferred", notificationId }
);
return notificationId;
} catch (error) {
logMessage.warn(
`scheduleFormSubmissionNotification failed for form ${formId}: ${(error as Error).message}`
);
return undefined;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const invokeSubmissionLambda = async (
fields: Responses,
language: string,
securityAttribute: string,
fileChecksums?: Record<string, string>
fileChecksums?: Record<string, string>,
notificationId?: string
): Promise<{
submissionId: string;
fileURLMap?: SignedURLMap;
Expand All @@ -22,6 +23,7 @@ export const invokeSubmissionLambda = async (
responses: fields,
securityAttribute,
...(fileChecksums && Object.keys(fileChecksums).length > 0 && { fileChecksums }),
...(notificationId && { notificationId }),
};

const lambdaInvokeResponse = await lambdaClient.send(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type ProcessFormDataParams = {
formId: string;
language?: string;
fileChecksums?: Record<string, string>;
notificationId?: string;
};

export const processFormData = async ({
Expand All @@ -19,6 +20,7 @@ export const processFormData = async ({
formId,
language,
fileChecksums,
notificationId,
}: ProcessFormDataParams): Promise<{
submissionId: string;
fileURLMap?: SignedURLMap;
Expand Down Expand Up @@ -59,7 +61,8 @@ export const processFormData = async ({
responses,
language ? language : "en",
securityAttribute ? securityAttribute : "Protected A",
fileChecksums
fileChecksums,
notificationId
);

logMessage.info(`Response submitted for Form ID: ${form.id}`);
Expand Down
4 changes: 4 additions & 0 deletions i18n/translations/en/admin-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions i18n/translations/fr/admin-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[FR]Notification",
"description": "[FR]Enable sending emails immediately or deferred using the Notification flow. Disabling this will send emails directly to GC Notify."
}
},
"addFlag": "Ajouter un indicateur de fonctionnalité",
Expand Down
1 change: 1 addition & 0 deletions lib/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const UserFeatureFlags = {
export const FeatureFlags = {
formTimer: "formTimer",
hCaptcha: "hCaptcha",
notification: "notification",
topBanner: "topBanner",
zitadelLogin: "zitadelLogin",
...UserFeatureFlags,
Expand Down
1 change: 1 addition & 0 deletions lib/flags/default_flag_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"addressComplete": false,
"formTimer": false,
"hCaptcha": false,
"notification": false,
"templateVersioning": false,
"topBanner": false,
"responsesPilot": false,
Expand Down
Loading
Loading