Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
55735eb
Add send immediate to notification lib
thiessenp-cds Dec 3, 2025
ae642d3
Refactor to use a dynamic lookup for sqs url
thiessenp-cds Dec 3, 2025
f81d5cd
Add notification deferred util method
thiessenp-cds Dec 3, 2025
b3d1014
Update notifications call to call notification lib to send email
thiessenp-cds Dec 4, 2025
e24947f
Update notification immediate to have an optional id and how utils ex…
thiessenp-cds Dec 4, 2025
8f6f012
Update comments and error messages
thiessenp-cds Dec 4, 2025
428deb9
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 8, 2025
0f91e2d
Update function name
thiessenp-cds Dec 9, 2025
b6b2b03
Merge branch 'feat/notifications-v3' of https://github.com/cds-snc/pl…
thiessenp-cds Dec 9, 2025
379439a
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 9, 2025
dbe76fb
Initial commit
thiessenp-cds Dec 9, 2025
7787ecc
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 9, 2025
035267a
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 9, 2025
8e08762
Move notification to core
thiessenp-cds Dec 9, 2025
c0d2a78
Add missing dependencies
thiessenp-cds Dec 10, 2025
f91b340
Update comment
thiessenp-cds Dec 10, 2025
60f8689
Remove sqs queue url from connectors
thiessenp-cds Dec 10, 2025
854d837
Add more comments
thiessenp-cds Dec 10, 2025
9134232
Undo previous change
thiessenp-cds Dec 10, 2025
1332d05
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Dec 10, 2025
82553d8
Update notification helpers
thiessenp-cds Dec 10, 2025
6ba680f
Undo previous change
thiessenp-cds Dec 10, 2025
cd78e0e
Update logging
thiessenp-cds Dec 10, 2025
d1ef95c
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 11, 2025
9e37ea9
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 11, 2025
c51419a
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 11, 2025
6ec2d73
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 12, 2025
ee7af9a
Merge branch 'feat/notifications-v3' of https://github.com/cds-snc/pl…
thiessenp-cds Dec 12, 2025
6cb9379
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 15, 2025
e8bfe99
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 15, 2025
427c2c0
Merge branch 'feat/notifications-v3' of https://github.com/cds-snc/pl…
thiessenp-cds Dec 15, 2025
157ded2
Move notification and utils to connectors
thiessenp-cds Dec 15, 2025
c7e8586
Undo previous package change
thiessenp-cds Dec 15, 2025
93e461e
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Dec 15, 2025
d980a0d
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 15, 2025
7a26d4f
Update to use latest notification lib
thiessenp-cds Dec 15, 2025
24f149b
Updatee error logging
thiessenp-cds Dec 15, 2025
9ab3837
Update logging typo
thiessenp-cds Dec 15, 2025
eca7c61
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Dec 15, 2025
ab7a4ae
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 16, 2025
0ea5e83
Update notification utils to add cause to re-thrown errors
thiessenp-cds Dec 16, 2025
4543937
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 17, 2025
c00ddb3
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 17, 2025
249f725
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 17, 2025
08fe30e
Fix typo
thiessenp-cds Dec 17, 2025
b435a3f
Updates from PR review
thiessenp-cds Dec 17, 2025
36f1e54
Merge branch 'feat/notifications-v3' of https://github.com/cds-snc/pl…
thiessenp-cds Dec 17, 2025
77917f2
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Dec 17, 2025
f0376f0
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Dec 17, 2025
842c2fb
Merge branch 'main' into feat/notifications-v3
thiessenp-cds Dec 18, 2025
4fe17a8
Merge branch 'main' into feat/notifications-v3
timarney Dec 18, 2025
92b2ec1
Merge branch 'main' into feat/notification-package-update
thiessenp-cds Jan 5, 2026
c1bfdc9
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Jan 5, 2026
594c420
Remove some no longer needed exports
thiessenp-cds Jan 5, 2026
6abc914
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Jan 5, 2026
aa368c0
Remove export from barrel file
thiessenp-cds Jan 6, 2026
f1965c7
Merge branch 'feat/notification-package-update' into feat/notificatio…
thiessenp-cds Jan 6, 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
72 changes: 29 additions & 43 deletions lib/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
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 "@lib/integration/prismaConnector";
import { notification } from "@gcforms/connectors";

// Hard coded since only one interval is supported currently
const NOTIFICATIONS_INTERVAL = NotificationsInterval.DAY;
Expand Down Expand Up @@ -41,7 +41,7 @@ export const sendNotifications = async (formId: string, titleEn: string, titleFr
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),
sendEmailAfterSubmissionProcessed(users, formId, titleEn, titleFr, true),
setMarker(formId, Status.MULTIPLE_EMAIL_SENT),
]);
break;
Expand All @@ -51,7 +51,7 @@ export const sendNotifications = async (formId: string, titleEn: string, titleFr
default:
// No email has been sent, send single submission email
Promise.all([
sendEmailNotificationsToAllUsers(users, formId, titleEn, titleFr, false),
sendEmailAfterSubmissionProcessed(users, formId, titleEn, titleFr, false),
setMarker(formId),
]);
}
Expand Down Expand Up @@ -80,8 +80,9 @@ export const getNotificationsUsers = async (formId: string) => {
})
.catch((e) => prismaErrors(e, null));

// Can happen with legacy forms that do not have users
if (!usersAndNotificationsUsers) {
logMessage.warn(`_getNotificationsUsers no users found for formId ${formId}`);
logMessage.debug(`_getNotificationsUsers no users found for formId ${formId}`);
return null;
}

Expand Down Expand Up @@ -111,8 +112,9 @@ const _getDeliveryOption = async (formId: string) => {
})
.catch((e) => prismaErrors(e, null));

// Can happen with legacy forms that do not have a deliveryOption
if (!template) {
logMessage.warn(`_getDeliveryOption template not found with id ${formId}`);
logMessage.debug(`_getDeliveryOption template not found with id ${formId}`);
return null;
}

Expand All @@ -130,18 +132,18 @@ const setMarker = async (formId: string, status: Status = Status.SINGLE_EMAIL_SE
)
)
.catch((err) =>
logMessage.error(`setMarker: notification:formId:${formId} failed to set ${err}`)
logMessage.warn(`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}`));
.catch((err) => logMessage.warn(`getMarker: ${err}`));
};

const sendEmailNotificationsToAllUsers = async (
const sendEmailAfterSubmissionProcessed = async (
users: {
email: string;
enabled: boolean;
Expand All @@ -151,47 +153,31 @@ const sendEmailNotificationsToAllUsers = async (
formTitleFr: string,
multipleSubmissions: boolean = false
) => {
if (!Array.isArray(users) || users.length === 0) {
logMessage.error("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,
{
try {
const { t } = await serverTranslation("form-builder");
const HOST = await getOrigin();

if (!Array.isArray(users) || users.length === 0) {
logMessage.debug("sendEmailNotificationsToAllUsers missing users");
return;
}
const emails = users.filter(({ enabled }) => enabled).map(({ email }) => email);

await notification.sendDeferred({
notificationId: formId,
emails,
subject: multipleSubmissions
? t("settings.notifications.email.multipleSubmissions.subject")
: t("settings.notifications.email.singleSubmission.subject"),
formResponse: multipleSubmissions
body: 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}`)
});
} catch (error) {
logMessage.warn(
`sendEmailNotification failed for formId ${formId} with error: ${(error as Error).message}`
);
}
};

const singleSubmissionEmailTemplate = async (
Expand Down
5 changes: 5 additions & 0 deletions packages/connectors/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ 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).

## [2.2.14] - 2025-12-15

- Add notification helpers
- Add utils for AWS

## [2.2.13] - 2025-10-27

- Update AWS SDK package from version `3.917.0`
Expand Down
2 changes: 1 addition & 1 deletion packages/connectors/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gcforms/connectors",
"version": "2.2.13",
"version": "2.2.14",
"author": "Canadian Digital Service",
"license": "MIT",
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion packages/connectors/src/gc-notify-connector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Agent } from "https";
import { getAwsSecret } from "./getAwsSecret";
import { getAwsSecret } from "./utils";
import axios, { AxiosError } from "axios";

const API_URL: string = "https://api.notification.canada.ca";
Expand Down
7 changes: 0 additions & 7 deletions packages/connectors/src/getAwsSecret.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/connectors/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { GCNotifyConnector, type Personalisation } from "./gc-notify-connector";
export { PostgresConnector } from "./postgres-connector";
export { notification } from "./notification";
135 changes: 135 additions & 0 deletions packages/connectors/src/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";
import { ErrorWithCause } from "./types/errors";
import { getAwsSQSQueueURL } from "./utils";

const DYNAMODB_NOTIFICATION_TABLE_NAME = "Notification";

const globalConfig = {
region: process.env.AWS_REGION ?? "ca-central-1",
};

const dynamoDBDocumentClient = DynamoDBDocumentClient.from(
new DynamoDBClient({
...globalConfig,
// SDK retries use exponential backoff with jitter by default
maxAttempts: 15,
})
);

const sqsClient = new SQSClient({
...globalConfig,
});

/**
* Creates a notification record in DynamoDB and enqueues it for immediate sending.
*
* @param emails - Array of email addresses to send the notification to
* @param subject - Email subject line
* @param body - Email body content
*/
const sendImmediate = async ({
emails,
subject,
body,
}: {
notificationId?: string;
emails: string[];
subject: string;
body: string;
}): Promise<void> => {
const notificationId = randomUUID();
try {
await _createRecord({ notificationId, emails, subject, body });
await enqueueDeferred(notificationId);
} catch (error) {
throw new ErrorWithCause(`Error creating immediate notification id ${notificationId}`, {
cause: error,
});
}
};

/**
* Creates a notification record in DynamoDB for deferred sending. Once the related
* process is completed it can enqueue the notification for sending by calling
* enqueueDeferredNotification with the related notificationId.
*
* @param notificationId - Unique identifier for the notification to enqueue and
* used by the notification lambda to look up the record in DynamoDB.
*/
const sendDeferred = async ({
notificationId,
emails,
subject,
body,
}: {
notificationId: string;
emails: string[];
subject: string;
body: string;
}): Promise<void> => {
try {
await _createRecord({ notificationId, emails, subject, body });
} catch (error) {
throw new ErrorWithCause(`Error creating deferred notification id ${notificationId}`, {
cause: error,
});
}
};

const _createRecord = async ({
notificationId,
emails,
subject,
body,
}: {
notificationId: string;
emails: string[];
subject: string;
body: string;
}): Promise<void> => {
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 });
}
};

const enqueueDeferred = async (notificationId: string): Promise<void> => {
try {
const queueUrl = await getAwsSQSQueueURL("NOTIFICATION_QUEUE_URL", "notification_queue");
if (!queueUrl) {
throw new Error("Notification Queue not connected");
}

const command = new SendMessageCommand({
MessageBody: JSON.stringify({ notificationId }),
QueueUrl: queueUrl,
});
const sendMessageCommandOutput = await sqsClient.send(command);
if (!sendMessageCommandOutput.MessageId) {
throw new Error("Received null SQS message identifier");
}
} catch (error) {
throw new ErrorWithCause(`Could not enqueue`, { cause: error });
}
};

export const notification = {
sendImmediate,
sendDeferred,
enqueueDeferred,
};
2 changes: 1 addition & 1 deletion packages/connectors/src/postgres-connector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAwsSecret } from "./getAwsSecret";
import { getAwsSecret } from "./utils";
import postgres, { Sql } from "postgres";

export class PostgresConnector {
Expand Down
17 changes: 17 additions & 0 deletions packages/connectors/src/types/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Custom Error class that supports adding a "cause" for re-throwing errors.
*/
export class ErrorWithCause extends Error {
cause?: unknown;

constructor(message: string, options?: { cause?: unknown }) {
super(message);
this.name = "ErrorWithCause";
this.cause = options?.cause;

// Maintains proper stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ErrorWithCause);
}
}
}
32 changes: 32 additions & 0 deletions packages/connectors/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { SQSClient, GetQueueUrlCommand } from "@aws-sdk/client-sqs";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

const globalConfig = {
region: process.env.AWS_REGION ?? "ca-central-1",
};

const sqsClient = new SQSClient({
...globalConfig,
});

export function getAwsSecret(secretIdentifier: string): Promise<string | undefined> {
return new SecretsManagerClient()
.send(new GetSecretValueCommand({ SecretId: secretIdentifier }))
.then((commandOutput) => commandOutput.SecretString);
}

export const getAwsSQSQueueURL = async (
urlEnvName: string,
urlQueueName: string
): Promise<string | null> => {
if (process.env[urlEnvName]) {
return process.env[urlEnvName];
}

const data = await sqsClient.send(
new GetQueueUrlCommand({
QueueName: urlQueueName,
})
);
return data.QueueUrl ?? null;
};
Loading