Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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:
Expand All @@ -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) {
Expand Down
16 changes: 8 additions & 8 deletions app/(gcforms)/[locale]/(form administration)/forms/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand Down
154 changes: 148 additions & 6 deletions app/(gcforms)/[locale]/(form filler)/id/[...props]/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", () => ({
Expand All @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 })
);
});

Expand Down Expand Up @@ -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 })
);
});
});
Loading
Loading