Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add notification #124

Merged
merged 4 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@babel/preset-env": "^7.22.9",
"@babel/preset-typescript": "^7.12.7",
"@netlify/functions": "^1.0.0",
"@sendgrid/mail": "^7.7.0",
"@tsconfig/svelte": "^5.0.0",
"@types/aws-lambda": "^8.10.95",
"@types/node": "^17.0.25",
Expand All @@ -60,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 All @@ -81,7 +82,7 @@
"ts-jest": "^29.1.1",
"ts-loader": "^9.3.0",
"ts-node": "^10.1.0",
"typescript": "^5.1.6",
"typescript": "^5.3.2",
"webpack": "^5.76.0",
"webpack-bundle-analyzer": "^4.9.0",
"webpack-cli": "^4.9.2",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/Service.ts → src/lib/AbstractDbService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
TokenClaim,
} from "./simple-comment-types"

export abstract class Service {
export abstract class AbstractDbService {
/**
* Accept a user name and password, return authentication token
*
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
}
20 changes: 17 additions & 3 deletions src/lib/MongodbService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Action } from "./simple-comment-types"
import { isUserAllowedTo } from "./policyEnforcement"
import type { Collection, Db, WithId } from "mongodb"
import { MongoClient } from "mongodb"
import { Service } from "./Service"
import { AbstractDbService } from "./AbstractDbService"
import {
adminOnlyModifiableUserProperties,
generateCommentId,
Expand Down 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 @@ -91,12 +92,13 @@ if (process.env.JWT_SECRET === undefined)
throw "JWT_SECRET is not set in environmental variables"
const jwtSecret = process.env.JWT_SECRET

export class MongodbService extends Service {
export class MongodbService extends AbstractDbService {
private isCrossSite = process.env.IS_CROSS_SITE === "true"
private _client: MongoClient
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 Service {
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 Service {
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}`)
}
}
66 changes: 66 additions & 0 deletions src/lib/SendGridNotificationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 sendGridVerifiedSender = process.env.SENDGRID_VERIFIED_SENDER

if (sendGridVerifiedSender === undefined)
throw "SENDGRID_VERIFIED_SENDER is not set in environmental variables"

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: sendGridVerifiedSender, // 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" }
}
}
}
127 changes: 127 additions & 0 deletions src/tests/backend/SendGridNotificationService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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.SENDGRID_VERIFIED_SENDER = "[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"

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

sendGridNotificationService = new SendGridNotificationService(
mailServiceMock,
sendGridTestApiKey,
moderatorContactEmails
)
})

afterEach(() => {
jest.resetModules()
})

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
})
14 changes: 7 additions & 7 deletions src/tests/backend/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
User,
UserId,
} from "../../../src/lib/simple-comment-types"
import { Service } from "../../../src/lib/Service"
import { AbstractDbService } from "../../../src/lib/AbstractDbService"

type Method = "get" | "post" | "delete" | "put"

Expand Down Expand Up @@ -65,7 +65,7 @@ describe("Ensures API specs match controller service", () => {
[]
)

class TestService extends Service {
class TestService extends AbstractDbService {
authDELETE = async (): Promise<Success | Error> => {
throw "Error: not implemented"
}
Expand Down Expand Up @@ -199,19 +199,19 @@ describe("Ensures API specs match controller service", () => {
}
}

// TestService indirectly tests the abstract class Service
// This test relies on compile time flagging an error that TestService does not implment Service
// TestService indirectly tests the abstract class AbstractDbService
// This test relies on compile time flagging an error that TestService does not implment AbstractDbService
const testService = new TestService()

// Make sure that each entry in serviceMethods has a corresponding
// value in the Service instance, `testService`
// value in the AbstractDbService instance, `testService`
serviceMethods.forEach(method => {
test(`${method} should be defined in Service`, () => {
test(`${method} should be defined in AbstractDbService`, () => {
expect(testService[method]).toBeDefined()
})
})

test(`non existent method on Service should fail`, () => {
test(`non existent method on AbstractDbService should fail`, () => {
expect(testService["nonexistent"]).toBeUndefined()
})
})
Loading