Skip to content

Commit

Permalink
Add SendGrid notification service
Browse files Browse the repository at this point in the history
  • Loading branch information
rendall committed Nov 30, 2023
1 parent 38632db commit f120286
Show file tree
Hide file tree
Showing 8 changed files with 326 additions and 3 deletions.
3 changes: 3 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/AbstractNotificationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Success, Error } from "./simple-comment-types"

export abstract class AbstractNotificationService {
/**
* Send notification to moderators
*/
abstract notifyModerators: (
message: string
) => Promise<Success | Error> | void
}
16 changes: 15 additions & 1 deletion src/lib/MongodbService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -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 },
Expand Down
7 changes: 7 additions & 0 deletions src/lib/NoOpNotificationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { AbstractNotificationService } from "./AbstractNotificationService"

export class NoOpNotificationService extends AbstractNotificationService {
notifyModerators = (message: string): void => {
console.info(`NoOpNotificationService: ${message}`)
}
}
62 changes: 62 additions & 0 deletions src/lib/SendGridNotificationService.ts
Original file line number Diff line number Diff line change
@@ -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<Error | Success> => {
const messages = this._moderatorContactEmails.map(email => ({
to: email,
from: "[email protected]", // 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" }
}
}
}
129 changes: 129 additions & 0 deletions src/tests/backend/SendGridNotificationService.test.ts
Original file line number Diff line number Diff line change
@@ -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 =
"[email protected],[email protected],[email protected],[email protected],[email protected],[email protected],[email protected],[email protected],[email protected],[email protected]"

process.env.NOTIFICATION_SERVICE_API_KEY = "SG.from.test.env"
}),
}))

describe("SendGridNotificationService", () => {
let sendGridNotificationService: SendGridNotificationService
let mailServiceMock: jest.Mocked<MailService>

const moderatorContactEmails = ["a1@example,com", "[email protected]"]
const sendGridTestApiKey = "SG.test"

const originalEnv = process.env

beforeEach(() => {
jest.resetModules()
mailServiceMock = {
setApiKey: jest.fn(),
send: jest.fn(),
} as unknown as jest.Mocked<MailService>

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
})
Loading

0 comments on commit f120286

Please sign in to comment.