diff --git a/.env.example b/.env.example index 80771210..a45a6f66 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ NOTION_TOKEN= DATABASE_ID= PORT=3000 +ADMIN_EMAIL= + CALENDAR_URL= GOOGLE_CLOUD_PROJECT= diff --git a/__tests__/controllers/rsvpController.spec.ts b/__tests__/controllers/rsvpController.spec.ts index 32b10872..1802422d 100644 --- a/__tests__/controllers/rsvpController.spec.ts +++ b/__tests__/controllers/rsvpController.spec.ts @@ -1,5 +1,5 @@ import RsvpController from '../../src/controllers/rsvpController' -import { beforeEach, describe, expect, it } from '@jest/globals' +import { beforeEach, describe, expect, it, jest } from '@jest/globals' import { getMockReq, getMockRes } from '@jest-mock/express' import { Timestamp, addDoc } from 'firebase/firestore' import { FirebaseMock } from '../support/firebaseMock' @@ -8,6 +8,8 @@ import FirestoreAdapter from '../../src/data/firestore/firestoreAdapter' const { res, mockClear } = getMockRes() beforeEach(() => { + process.env.ADMIN_EMAIL = 'admin@example.com' + jest.clearAllMocks() mockClear() }) @@ -55,6 +57,30 @@ describe('store', () => { ) }) + it('sends an email to admins', async () => { + const req = getMockReq({ + params: { weekId: '2023-01-01' }, + body: mockBody(), + }) + + await new RsvpController(firestoreAdapter).store(req, res) + + expect(res.status).toHaveBeenCalledWith(201) + expect(addDoc).toHaveBeenCalledWith( + FirebaseMock.mockCollection('mail'), + { + to: 'admin@example.com', + message: { + subject: 'TNMC RSVP: test name', + // eslint-disable-next-line max-len + text: 'test name has RSVPed for 2023-01-01\n\nEmail: test@example.com\nPlus one: true', + // eslint-disable-next-line max-len + html: '

test name has RSVPed for 2023-01-01

', + }, + } + ) + }) + describe('when email is missing', () => { it('should return a 422', async () => { const req = getMockReq({ diff --git a/__tests__/data/firestore/firestoreAdapter.spec.ts b/__tests__/data/firestore/firestoreAdapter.spec.ts index 8194c68c..e7986715 100644 --- a/__tests__/data/firestore/firestoreAdapter.spec.ts +++ b/__tests__/data/firestore/firestoreAdapter.spec.ts @@ -216,3 +216,25 @@ describe('createRsvp', () => { ) }) }) + +describe ('sendEmail', () => { + it('sends an email', async () => { + await firestore.sendEmail('jsmith@example.com', { + subject: 'test subject', + text: 'test text', + html: 'test

html

', + }) + + expect(addDoc).toHaveBeenCalledWith( + FirebaseMock.mockCollection('mail'), + { + to: 'jsmith@example.com', + message: { + subject: 'test subject', + text: 'test text', + html: 'test

html

', + }, + } + ) + }) +}) diff --git a/src/config/mail.ts b/src/config/mail.ts new file mode 100644 index 00000000..6825655c --- /dev/null +++ b/src/config/mail.ts @@ -0,0 +1,9 @@ +export function adminEmail (): string { + const email = process.env.ADMIN_EMAIL + + if (!email) { + throw new Error('ADMIN_EMAIL is not set') + } + + return email +} diff --git a/src/controllers/rsvpController.ts b/src/controllers/rsvpController.ts index 40a4defa..893a71ff 100644 --- a/src/controllers/rsvpController.ts +++ b/src/controllers/rsvpController.ts @@ -1,6 +1,7 @@ import { type Request, type Response } from 'express' import FirestoreAdapter from '../data/firestore/firestoreAdapter' import { z } from 'zod' +import { adminEmail } from '../config/mail' class RsvpController { static PATHS = { @@ -28,6 +29,15 @@ class RsvpController { await this.firestore.createRsvp(weekId, name, email, plusOne) res.status(201).json({ message: 'Successfully RSVP\'d' }) + + await this.firestore.sendEmail(adminEmail(), { + subject: `TNMC RSVP: ${name}`, + // eslint-disable-next-line max-len + text: `${name} has RSVPed for ${weekId}\n\nEmail: ${email}\nPlus one: ${plusOne}`, + // eslint-disable-next-line max-len + html: `

${name} has RSVPed for ${weekId}

`, + }) + } private validate (req: Request, res: Response): boolean { diff --git a/src/data/firestore/firestoreAdapter.ts b/src/data/firestore/firestoreAdapter.ts index 508d29bd..7fe37e7c 100644 --- a/src/data/firestore/firestoreAdapter.ts +++ b/src/data/firestore/firestoreAdapter.ts @@ -19,8 +19,9 @@ import Week from '../../models/week.js' import setupFirestore from '../../config/firestore.js' export default class FirestoreAdapter { - static readonly WEEKS_COLLECTION_NAME = 'weeks' + static readonly MAIL_COLLECTION_NAME = 'mail' static readonly RSVPS_COLLECTION_NAME = 'rsvps' + static readonly WEEKS_COLLECTION_NAME = 'weeks' #firestore: FirestoreType @@ -88,6 +89,13 @@ export default class FirestoreAdapter { }) } + async sendEmail (to: string, message: EmailMessage): Promise { + await addDoc(this.mailCollection, { + to, + message, + }) + } + today (): Timestamp { const today = new Date() today.setHours(0, 0, 0, 0) @@ -95,15 +103,23 @@ export default class FirestoreAdapter { return Timestamp.fromDate(today) } - private get weekCollection (): - CollectionReference - { - return collection(this.#firestore, FirestoreAdapter.WEEKS_COLLECTION_NAME) + private get mailCollection (): Collection { + return collection(this.#firestore, FirestoreAdapter.MAIL_COLLECTION_NAME) } - private get rsvpCollection (): - CollectionReference - { + private get rsvpCollection (): Collection { return collection(this.#firestore, FirestoreAdapter.RSVPS_COLLECTION_NAME) } + + private get weekCollection (): Collection { + return collection(this.#firestore, FirestoreAdapter.WEEKS_COLLECTION_NAME) + } } + +export type EmailMessage = { + subject: string, + text: string, + html: string, +} + +type Collection = CollectionReference