Skip to content

Commit dc13a2f

Browse files
committed
add onboarding email prep
1 parent aef727c commit dc13a2f

File tree

7 files changed

+172
-4
lines changed

7 files changed

+172
-4
lines changed

ecs/env.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@
213213
{
214214
"name": "ADMIN_JWT_SECRET_KEY",
215215
"valueFrom": "plumber-<ENVIRONMENT>-admin-jwt-secret-key"
216+
},
217+
{
218+
"name": "ONBOARDING_EMAIL_WEBHOOK_URL",
219+
"valueFrom": "plumber-onboarding-email-webhook-url"
216220
}
217221
]
218222
}

packages/backend/src/config/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type AppConfig = {
4646
}
4747
launchDarklySdkKey: string
4848
maxJobAttempts: number
49+
onboardingEmailWebhookUrl: string
4950
}
5051

5152
const port = process.env.PORT || '3000'
@@ -105,6 +106,7 @@ const appConfig: AppConfig = {
105106
},
106107
launchDarklySdkKey: process.env.LAUNCH_DARKLY_SDK_KEY,
107108
maxJobAttempts: Number(process.env.MAX_JOB_ATTEMPTS ?? '10'),
109+
onboardingEmailWebhookUrl: process.env.ONBOARDING_EMAIL_WEBHOOK_URL || '',
108110
}
109111

110112
if (!appConfig.encryptionKey) {

packages/backend/src/graphql/mutations/login-with-selected-sgid.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { type Request } from 'express'
22
import { verify as verifyJwt } from 'jsonwebtoken'
33

44
import appConfig from '@/config/app'
5-
import { getOrCreateUser, setAuthCookie, updateLastLogin } from '@/helpers/auth'
5+
import {
6+
getOrCreateUser,
7+
sendOnboardingEmail,
8+
setAuthCookie,
9+
updateLastLogin,
10+
} from '@/helpers/auth'
611
import logger from '@/helpers/logger'
712
import {
813
type PublicOfficerEmployment,
@@ -44,6 +49,7 @@ const loginWithSelectedSgid: MutationResolvers['loginWithSelectedSgid'] =
4449
}
4550

4651
const user = await getOrCreateUser(workEmail)
52+
await sendOnboardingEmail(user.id)
4753
await updateLastLogin(user.id)
4854
setAuthCookie(context.res, { userId: user.id })
4955

packages/backend/src/graphql/mutations/login-with-sgid.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { type Response } from 'express'
33
import { sign as signJwt } from 'jsonwebtoken'
44

55
import appConfig from '@/config/app'
6-
import { getOrCreateUser, setAuthCookie, updateLastLogin } from '@/helpers/auth'
6+
import {
7+
getOrCreateUser,
8+
sendOnboardingEmail,
9+
setAuthCookie,
10+
updateLastLogin,
11+
} from '@/helpers/auth'
712
import { validateAndParseEmail } from '@/helpers/email-validator'
813
import logger from '@/helpers/logger'
914
import {
@@ -129,6 +134,7 @@ const loginWithSgid: MutationResolvers['loginWithSgid'] = async (
129134
// Log user in directly if there is only 1 employment.
130135
if (publicOfficerEmployments.length === 1) {
131136
const user = await getOrCreateUser(publicOfficerEmployments[0].workEmail)
137+
await sendOnboardingEmail(user.id)
132138
await updateLastLogin(user.id)
133139
setAuthCookie(context.res, { userId: user.id })
134140

packages/backend/src/graphql/mutations/verify-otp.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import crypto from 'crypto'
22

33
import BaseError from '@/errors/base'
4-
import { setAuthCookie } from '@/helpers/auth'
4+
import { sendOnboardingEmail, setAuthCookie } from '@/helpers/auth'
55
import { validateAndParseEmail } from '@/helpers/email-validator'
66
import User from '@/models/user'
77

@@ -51,14 +51,15 @@ const verifyOtp: MutationResolvers['verifyOtp'] = async (
5151
) {
5252
throw new BaseError('Invalid OTP')
5353
}
54+
await sendOnboardingEmail(user.id)
55+
5456
// reset otp columns
5557
await user.$query().patch({
5658
otpHash: null,
5759
otpAttempts: 0,
5860
otpSentAt: null,
5961
lastLoginAt: new Date(),
6062
})
61-
6263
// set auth jwt as cookie
6364
setAuthCookie(context.res, { userId: user.id })
6465

packages/backend/src/helpers/__tests__/auth.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import axios from 'axios'
12
import jwt from 'jsonwebtoken'
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
34

45
import appConfig from '@/config/app'
6+
import User from '@/models/user'
57

68
import {
79
getAdminTokenUser,
810
getOrCreateUser,
911
parseAdminToken,
12+
sendOnboardingEmail,
1013
updateLastLogin,
1114
} from '../auth'
1215

@@ -23,6 +26,7 @@ const mocks = vi.hoisted(() => ({
2326
patch: vi.fn(() => ({
2427
where: mockPatchWhere,
2528
})),
29+
findById: vi.fn(),
2630
}))
2731

2832
vi.mock('@/models/user', () => ({
@@ -32,10 +36,24 @@ vi.mock('@/models/user', () => ({
3236
findOne: mocks.findOne,
3337
insertAndFetch: mocks.insertAndFetch,
3438
patch: mocks.patch,
39+
findById: mocks.findById,
3540
})),
3641
},
3742
}))
3843

44+
vi.mock('axios', () => ({
45+
default: {
46+
post: vi.fn(),
47+
},
48+
}))
49+
vi.mock('@/config/app', () => ({
50+
default: {
51+
isProd: false,
52+
onboardingEmailWebhookUrl: 'https://test-webhook.com',
53+
adminJwtSecretKey: 'test-secret-key',
54+
},
55+
}))
56+
3957
describe('Auth helpers', () => {
4058
afterEach(() => {
4159
vi.restoreAllMocks()
@@ -202,4 +220,107 @@ describe('Auth helpers', () => {
202220
)
203221
})
204222
})
223+
224+
describe('sendOnboardingEmail', () => {
225+
beforeEach(() => {
226+
vi.clearAllMocks()
227+
})
228+
229+
afterEach(() => {
230+
vi.restoreAllMocks()
231+
})
232+
233+
it('throws error with no user id', async () => {
234+
await expect(sendOnboardingEmail('')).rejects.toThrowError(
235+
'User id required',
236+
)
237+
})
238+
239+
it('throws error with non-existent user id', async () => {
240+
mocks.findById.mockResolvedValueOnce(null)
241+
await expect(sendOnboardingEmail('non-existent-id')).rejects.toThrowError(
242+
'User not found',
243+
)
244+
})
245+
246+
it('does not send email if user has logged in before', async () => {
247+
const mockUser = {
248+
id: 'test-id',
249+
250+
lastLoginAt: new Date(),
251+
createdAt: new Date(),
252+
}
253+
254+
mocks.findById.mockResolvedValueOnce(mockUser)
255+
256+
await sendOnboardingEmail(mockUser.id)
257+
expect(axios.post).not.toHaveBeenCalled()
258+
})
259+
260+
it('does not send email if user was created before release date', async () => {
261+
const mockUser = {
262+
id: 'test-id',
263+
264+
createdAt: new Date('2024-01-01'), // Before release date
265+
}
266+
267+
mocks.findById.mockResolvedValueOnce(mockUser)
268+
269+
await sendOnboardingEmail(mockUser.id)
270+
expect(axios.post).not.toHaveBeenCalled()
271+
})
272+
273+
it('does not send email in non-prod environment', async () => {
274+
const mockUser = {
275+
id: 'test-id',
276+
277+
createdAt: new Date('2025-03-01'), // After release date
278+
}
279+
280+
mocks.findById.mockResolvedValueOnce(mockUser)
281+
282+
await sendOnboardingEmail(mockUser.id)
283+
expect(axios.post).not.toHaveBeenCalled()
284+
})
285+
286+
it('sends email in prod for eligible users', async () => {
287+
appConfig.isProd = true
288+
289+
const mockUser = {
290+
id: 'test-id',
291+
292+
lastLoginAt: null,
293+
createdAt: new Date('2025-03-01'), // After release date
294+
} as unknown as User
295+
296+
mocks.findById.mockResolvedValueOnce(mockUser)
297+
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} })
298+
299+
await sendOnboardingEmail(mockUser.id)
300+
301+
expect(axios.post).toHaveBeenCalledWith(
302+
appConfig.onboardingEmailWebhookUrl,
303+
{
304+
email: mockUser.email,
305+
},
306+
)
307+
})
308+
309+
it('does not send email if webhook URL is not configured', async () => {
310+
vi.mocked(appConfig).isProd = true
311+
vi.mocked(appConfig).onboardingEmailWebhookUrl = ''
312+
313+
const mockUser = {
314+
id: 'test-id',
315+
316+
lastLoginAt: null,
317+
createdAt: new Date('2025-03-01'), // After release date
318+
} as unknown as User
319+
320+
mocks.findById.mockResolvedValueOnce(mockUser)
321+
322+
await sendOnboardingEmail(mockUser.id)
323+
expect(axios.post).not.toHaveBeenCalled()
324+
})
325+
})
205326
})

packages/backend/src/helpers/auth.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import axios from 'axios'
12
import { Request, Response } from 'express'
23
import jwt, { JsonWebTokenError } from 'jsonwebtoken'
34

@@ -7,6 +8,7 @@ import User from '@/models/user'
78
const AUTH_COOKIE_NAME = 'plumber.sid'
89
// 3 days expiry
910
const TOKEN_EXPIRES_IN_SEC = 3 * 24 * 60 * 60
11+
const ONBOARDING_EMAIL_RELEASE_DATE = new Date('2025-02-27')
1012

1113
export function setAuthCookie(
1214
res: Response,
@@ -61,6 +63,32 @@ export async function getOrCreateUser(email: string): Promise<User> {
6163
return user
6264
}
6365

66+
export async function sendOnboardingEmail(id: string) {
67+
if (!id) {
68+
throw new Error('User id required!')
69+
}
70+
71+
const user = await User.query().findById(id)
72+
if (!user) {
73+
throw new Error('User not found!')
74+
}
75+
76+
// check if user has logged in before and has been created
77+
// after the specified date for the release of onboarding email
78+
if (
79+
user.lastLoginAt !== null ||
80+
new Date(user.createdAt) < ONBOARDING_EMAIL_RELEASE_DATE
81+
) {
82+
return
83+
}
84+
// call plumber webhook to send onboarding email only in prod
85+
if (appConfig.isProd && appConfig.onboardingEmailWebhookUrl) {
86+
await axios.post(appConfig.onboardingEmailWebhookUrl, {
87+
email: user.email,
88+
})
89+
}
90+
}
91+
6492
export async function updateLastLogin(id: string) {
6593
if (!id) {
6694
throw new Error('User id required!')

0 commit comments

Comments
 (0)