diff --git a/example.env b/example.env index 7c70f4e..8fc68b3 100644 --- a/example.env +++ b/example.env @@ -39,3 +39,6 @@ IS_CROSS_SITE=false # e.g. /api SIMPLE_COMMENT_API_URL=/.netlify/functions + +# NOTIFICATION_SERVICE_API_KEY is optional, but if there is a notification service, this is the associated API key +# NOTIFICATION_SERVICE_API_KEY=some-optional-api-key diff --git a/package.json b/package.json index c88cc19..e1b3532 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "http-server": "^14.1.0", "jest": "29.6.1", "jest-environment-jsdom": "^29.6.1", - "jest-environment-node": "^29.6.1", + "jest-environment-node": "^29.7.0", "jsonwebtoken": "^9.0.1", "merge": "^2.1.1", "mini-css-extract-plugin": "^2.7.6", diff --git a/src/lib/AbstractNotificationService.ts b/src/lib/AbstractNotificationService.ts new file mode 100644 index 0000000..314aaf6 --- /dev/null +++ b/src/lib/AbstractNotificationService.ts @@ -0,0 +1,10 @@ +import { Success, Error } from "./simple-comment-types" + +export abstract class AbstractNotificationService { + /** + * Send notification to moderators + */ + abstract notifyModerators: ( + message: string + ) => Promise | void +} diff --git a/src/lib/MongodbService.ts b/src/lib/MongodbService.ts index c1eab91..d70eb78 100644 --- a/src/lib/MongodbService.ts +++ b/src/lib/MongodbService.ts @@ -76,6 +76,7 @@ import { import { comparePassword, getAuthToken, hashPassword } from "./crypt" import * as jwt from "jsonwebtoken" import { isGuestId, isValidResult } from "./shared-utilities" +import { AbstractNotificationService } from "./AbstractNotificationService" if (process.env.SIMPLE_COMMENT_MODERATOR_PASSWORD === undefined) throw "SIMPLE_COMMENT_MODERATOR_PASSWORD is not set in environmental variables" @@ -97,6 +98,7 @@ export class MongodbService extends AbstractDbService { private _db: Db readonly _connectionString: string readonly _dbName: string + readonly _notificationService?: AbstractNotificationService getClient = async () => { if (this._client) { @@ -114,10 +116,15 @@ export class MongodbService extends AbstractDbService { return this._db } - constructor(connectionString: string, dbName: string) { + constructor( + connectionString: string, + dbName: string, + notificationService?: AbstractNotificationService + ) { super() this._connectionString = connectionString this._dbName = dbName + this._notificationService = notificationService } /** @@ -599,6 +606,13 @@ export class MongodbService extends AbstractDbService { body: "Database insertion error", } } + + if (this._notificationService) { + this._notificationService.notifyModerators( + `New comment posted by ${authUser.name} (${authUser.email})` + ) + } + return { statusCode: 201, body: { ...insertComment, user: adminSafeUser }, diff --git a/src/lib/NoOpNotificationService.ts b/src/lib/NoOpNotificationService.ts new file mode 100644 index 0000000..23f3a9a --- /dev/null +++ b/src/lib/NoOpNotificationService.ts @@ -0,0 +1,7 @@ +import { AbstractNotificationService } from "./AbstractNotificationService" + +export class NoOpNotificationService extends AbstractNotificationService { + notifyModerators = (message: string): void => { + console.info(`NoOpNotificationService: ${message}`) + } +} diff --git a/src/lib/SendGridNotificationService.ts b/src/lib/SendGridNotificationService.ts new file mode 100644 index 0000000..a3501ca --- /dev/null +++ b/src/lib/SendGridNotificationService.ts @@ -0,0 +1,62 @@ +import { AbstractNotificationService } from "./AbstractNotificationService" +import { Error, Success } from "./simple-comment-types" +import { MailService } from "@sendgrid/mail" +import { config as dotEnvConfig } from "dotenv" +dotEnvConfig() + +const _sendGridApiKey = process.env.NOTIFICATION_SERVICE_API_KEY + +const _moderatorContactEmails = process.env + .SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL + ? process.env.SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL.split(",") + : undefined + +export class SendGridNotificationService extends AbstractNotificationService { + private readonly _mailService: MailService + private readonly _moderatorContactEmails: string[] + private readonly _sendGridApiKey: string + + constructor( + mailService: MailService, + sendGridApiKey?: string, + moderatorContactEmails?: string[] + ) { + super() + + const apiKey = sendGridApiKey ?? _sendGridApiKey + if (apiKey === undefined) + throw "NOTIFICATION_SERVICE_API_KEY is not set in environmental variables" + this._sendGridApiKey = apiKey + + const emails = moderatorContactEmails ?? _moderatorContactEmails + + if (emails === undefined || emails.length === 0) + throw `SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL is not set in environmental variables` + + this._moderatorContactEmails = emails + mailService.setApiKey(this._sendGridApiKey) + this._mailService = mailService + } + + notifyModerators = async (body: string): Promise => { + const messages = this._moderatorContactEmails.map(email => ({ + to: email, + from: "sender@email.com", // Sender's email address + subject: "Simple Comment Notification", + text: `${body}`, + })) + + const sendPromises = messages.map(message => + this._mailService.send(message) + ) + const sendResults = await Promise.race(sendPromises) + + if (sendResults[0]?.statusCode !== 202) { + const { statusCode, body } = sendResults[0] + const resultsBody = JSON.stringify(body) + return { statusCode, body: resultsBody } + } else { + return { statusCode: 202, body: "Emails sent successfully" } + } + } +} diff --git a/src/tests/backend/SendGridNotificationService.test.ts b/src/tests/backend/SendGridNotificationService.test.ts new file mode 100644 index 0000000..c57a28c --- /dev/null +++ b/src/tests/backend/SendGridNotificationService.test.ts @@ -0,0 +1,129 @@ +import { SendGridNotificationService } from "../../lib/SendGridNotificationService" +import { ClientResponse, MailService } from "@sendgrid/mail" + +jest.mock("dotenv", () => ({ + config: jest.fn(() => { + process.env.SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL = + "email1@example.com,email2@example.com,email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com,email8@example.com,email9@example.com,email10@example.com" + + process.env.NOTIFICATION_SERVICE_API_KEY = "SG.from.test.env" + }), +})) + +describe("SendGridNotificationService", () => { + let sendGridNotificationService: SendGridNotificationService + let mailServiceMock: jest.Mocked + + const moderatorContactEmails = ["a1@example,com", "test2@example.com"] + const sendGridTestApiKey = "SG.test" + + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + mailServiceMock = { + setApiKey: jest.fn(), + send: jest.fn(), + } as unknown as jest.Mocked + + sendGridNotificationService = new SendGridNotificationService( + mailServiceMock, + sendGridTestApiKey, + moderatorContactEmails + ) + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("should set API key", async () => { + expect(mailServiceMock.setApiKey).toHaveBeenCalledWith(sendGridTestApiKey) + }) + + it("should use environmental variable for API key", async () => { + new SendGridNotificationService(mailServiceMock) + expect(mailServiceMock.setApiKey).toHaveBeenCalledWith("SG.from.test.env") + }) + + it("should handle email sending failure", async () => { + const body = "Test message" + const mockErrorResponse: ClientResponse = { + statusCode: 500, + body: {}, + headers: undefined, + } + + mailServiceMock.send.mockResolvedValueOnce([mockErrorResponse, {}]) + + const result = await sendGridNotificationService.notifyModerators(body) + + expect(mailServiceMock.setApiKey).toHaveBeenCalledWith(sendGridTestApiKey) + expect(mailServiceMock.send).toHaveBeenCalledTimes( + moderatorContactEmails.length + ) + expect(result.statusCode).toEqual(mockErrorResponse.statusCode) + }) + + it("should throw given empty moderator contact emails", async () => { + expect( + () => + new SendGridNotificationService(mailServiceMock, sendGridTestApiKey, []) + ).toThrowError( + "SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL is not set in environmental variables" + ) + }) + + it("should throw given undefined moderator contact emails", async () => { + expect( + () => + new SendGridNotificationService(mailServiceMock, sendGridTestApiKey, []) + ).toThrowError( + "SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL is not set in environmental variables" + ) + }) + + it("should send notification to moderators", async () => { + const body = "Test message" + const clientResponse = { + statusCode: 202, + body: {}, + headers: undefined, + } + const mockResponse: [ClientResponse, object] = [clientResponse, {}] + + mailServiceMock.send.mockResolvedValueOnce(mockResponse) + + const result = await sendGridNotificationService.notifyModerators(body) + + expect(mailServiceMock.setApiKey).toHaveBeenCalledWith(sendGridTestApiKey) + expect(mailServiceMock.send).toHaveBeenCalledTimes( + moderatorContactEmails.length + ) + expect(result.statusCode).toEqual(202) + }) + + it("should send mulitple emails given comma-separated SIMPLE_COMMENT_MODERATOR_CONTACT_EMAIL", async () => { + const body = "Test message" + const clientResponse = { + statusCode: 202, + body: {}, + headers: undefined, + } + const mockResponse: [ClientResponse, object] = [clientResponse, {}] + + mailServiceMock.send.mockResolvedValue(mockResponse) + + const service = new SendGridNotificationService( + mailServiceMock, + sendGridTestApiKey + ) + const result = await service.notifyModerators(body) + + expect(mailServiceMock.setApiKey).toHaveBeenCalledWith(sendGridTestApiKey) + expect(mailServiceMock.send).toHaveBeenCalledTimes(10) + expect(result.statusCode).toEqual(202) + }) + + // Add more test cases as needed +}) diff --git a/yarn.lock b/yarn.lock index b36fb40..972f1ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1774,6 +1774,16 @@ "@types/node" "*" jest-mock "^29.6.2" +"@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== + dependencies: + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + "@jest/expect-utils@^29.6.2": version "29.6.2" resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.6.2.tgz#1b97f290d0185d264dd9fdec7567a14a38a90534" @@ -1801,6 +1811,18 @@ jest-mock "^29.6.2" jest-util "^29.6.2" +"@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== + dependencies: + "@jest/types" "^29.6.3" + "@sinonjs/fake-timers" "^10.0.2" + "@types/node" "*" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" + "@jest/globals@^29.6.2": version "29.6.2" resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.6.2.tgz#74af81b9249122cc46f1eb25793617eec69bf21a" @@ -1848,6 +1870,13 @@ dependencies: "@sinclair/typebox" "^0.27.8" +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + "@jest/source-map@^29.6.0": version "29.6.0" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.0.tgz#bd34a05b5737cb1a99d43e1957020ac8e5b9ddb1" @@ -1921,6 +1950,18 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": version "0.3.3" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" @@ -8622,7 +8663,7 @@ jest-environment-jsdom@^29.6.1: jest-util "^29.6.2" jsdom "^20.0.0" -jest-environment-node@^29.6.1, jest-environment-node@^29.6.2: +jest-environment-node@^29.6.2: version "29.6.2" resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.6.2.tgz#a9ea2cabff39b08eca14ccb32c8ceb924c8bb1ad" integrity sha512-YGdFeZ3T9a+/612c5mTQIllvWkddPbYcN2v95ZH24oWMbGA4GGS2XdIF92QMhUhvrjjuQWYgUGW2zawOyH63MQ== @@ -8634,6 +8675,18 @@ jest-environment-node@^29.6.1, jest-environment-node@^29.6.2: jest-mock "^29.6.2" jest-util "^29.6.2" +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + jest-util "^29.7.0" + jest-get-type@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" @@ -8696,6 +8749,21 @@ jest-message-util@^29.6.2: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^29.6.2: version "29.6.2" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.6.2.tgz#ef9c9b4d38c34a2ad61010a021866dad41ce5e00" @@ -8705,6 +8773,15 @@ jest-mock@^29.6.2: "@types/node" "*" jest-util "^29.6.2" +jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-util "^29.7.0" + jest-pnp-resolver@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" @@ -8831,6 +8908,18 @@ jest-util@^29.0.0, jest-util@^29.6.2: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^27.3.1, jest-validate@^27.4.2: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -11029,6 +11118,15 @@ pretty-format@^29.0.0, pretty-format@^29.6.2: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + pretty-ms@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-8.0.0.tgz#a35563b2a02df01e595538f86d7de54ca23194a3"