Skip to content

Commit fb8caae

Browse files
authored
feat: add onboarding email (#883)
## Details - Add onboarding email flow for new users 3 days after they logged in (FOR PROD ONLY) - Add backend tests for it since it is hard to test just for prod Note: An old user is defined to be created before the release of the onboarding email date - Scenario 1: old user haven’t login since forever (no last_login_at) - Scenario 2: old user logged in recently (last_login_at present) - Scenario 3: new user failed to login —> then login at a later time - Scenario 4: new user login again after (last_login_at present) Only scenario 3 will send the onboarding email ## Before Release - [x] Make sure the pipe is set up, tested and published - [x] Add the `ONBOARDING_EMAIL_WEBHOOK_URL` on AWS before deploying - [x] Edit code back to `isProd` ## Tests - [x] Old user who hasn't login since 03/10 will not trigger the webhook - [x] Old user who logged in recently will not trigger the webhook - [x] New user after 03/10 will trigger the webhook, subsequent logins will not trigger
1 parent 7e6772f commit fb8caae

File tree

11 files changed

+171
-5
lines changed

11 files changed

+171
-5
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/.env-example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ M365_SG_GOVT_ALLOWED_SENSITIVITY_LABEL_GUIDS_CSV=11111111-1111-1111-1111-1111111
3232
LAUNCH_DARKLY_SDK_KEY=...
3333
MAX_JOB_ATTEMPTS=3
3434
POSTMAN_SMS_QPS_LIMIT_PER_CAMPAIGN=3
35+
ONBOARDING_EMAIL_WEBHOOK_URL=test-webhook-url
3536

3637
# LOCAL DEV
3738
SERVE_WEB_APP_SEPARATELY=true

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/controllers/webhooks/handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default async (request: IRequest, response: Response) => {
4747
const flow = await Flow.query().findById(flowId).withGraphJoined('user')
4848

4949
if (!flow) {
50-
logger.info(`Flow not found for webhook id ${flowId}}`)
50+
logger.info(`Flow not found for webhook id ${flowId}`)
5151
return response.sendStatus(404)
5252
}
5353

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type Context from '@/types/express/context'
88
const mocks = vi.hoisted(() => ({
99
setAuthCookie: vi.fn(),
1010
getOrCreateUser: vi.fn(),
11+
sendOnboardingEmail: vi.fn(),
1112
updateLastLogin: vi.fn(),
1213
logError: vi.fn(),
1314
verifyJwt: vi.fn(),
@@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({
1617
vi.mock('@/helpers/auth', () => ({
1718
setAuthCookie: mocks.setAuthCookie,
1819
getOrCreateUser: mocks.getOrCreateUser,
20+
sendOnboardingEmail: mocks.sendOnboardingEmail,
1921
updateLastLogin: mocks.updateLastLogin,
2022
}))
2123

@@ -81,6 +83,7 @@ describe('Login with selected SGID', () => {
8183
expect(mocks.getOrCreateUser).toHaveBeenCalledWith(
8284
8385
)
86+
expect(mocks.sendOnboardingEmail).toHaveBeenCalledWith({ id: 'abc-def' })
8487
expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def')
8588
expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), {
8689
userId: 'abc-def',
@@ -121,6 +124,7 @@ describe('Login with selected SGID', () => {
121124
).rejects.toThrowError('Invalid work email')
122125

123126
expect(mocks.getOrCreateUser).not.toBeCalled()
127+
expect(mocks.sendOnboardingEmail).not.toBeCalled()
124128
expect(mocks.updateLastLogin).not.toBeCalled()
125129
expect(mocks.setAuthCookie).not.toBeCalled()
126130
})

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
99
sgidUserInfo: vi.fn(),
1010
setAuthCookie: vi.fn(),
1111
getOrCreateUser: vi.fn(),
12+
sendOnboardingEmail: vi.fn(),
1213
updateLastLogin: vi.fn(),
1314
isWhitelistedEmail: vi.fn(),
1415
logError: vi.fn(),
@@ -45,6 +46,7 @@ vi.mock('@opengovsg/sgid-client', () => ({
4546
vi.mock('@/helpers/auth', () => ({
4647
setAuthCookie: mocks.setAuthCookie,
4748
getOrCreateUser: mocks.getOrCreateUser,
49+
sendOnboardingEmail: mocks.sendOnboardingEmail,
4850
updateLastLogin: mocks.updateLastLogin,
4951
}))
5052

@@ -91,6 +93,7 @@ describe('Login with SGID', () => {
9193
expect(mocks.getOrCreateUser).toHaveBeenCalledWith(
9294
9395
)
96+
expect(mocks.sendOnboardingEmail).toHaveBeenCalledWith({ id: 'abc-def' })
9497
expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def')
9598
expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), {
9699
userId: 'abc-def',
@@ -142,6 +145,7 @@ describe('Login with SGID', () => {
142145
const result = await loginWithSgid(null, STUB_PARAMS, STUB_CONTEXT)
143146

144147
expect(mocks.getOrCreateUser).not.toBeCalled()
148+
expect(mocks.sendOnboardingEmail).not.toBeCalled()
145149
expect(mocks.updateLastLogin).not.toBeCalled()
146150
expect(mocks.setAuthCookie).not.toBeCalled()
147151
expect(result.publicOfficerEmployments).toEqual([])
@@ -180,6 +184,7 @@ describe('Login with SGID', () => {
180184
const result = await loginWithSgid(null, STUB_PARAMS, STUB_CONTEXT)
181185

182186
expect(mocks.getOrCreateUser).toHaveBeenCalledWith('[email protected]')
187+
expect(mocks.sendOnboardingEmail).toHaveBeenCalledWith({ id: 'abc-def' })
183188
expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def')
184189
expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), {
185190
userId: 'abc-def',
@@ -259,6 +264,7 @@ describe('Login with SGID', () => {
259264
},
260265
)
261266
expect(mocks.getOrCreateUser).not.toBeCalled()
267+
expect(mocks.sendOnboardingEmail).not.toBeCalled()
262268
expect(mocks.updateLastLogin).not.toBeCalled()
263269
expect(mocks.setAuthCookie).not.toBeCalled()
264270
})
@@ -276,6 +282,7 @@ describe('Login with SGID', () => {
276282
event: 'sgid-login-failed-user-info',
277283
})
278284
expect(mocks.getOrCreateUser).not.toBeCalled()
285+
expect(mocks.sendOnboardingEmail).not.toBeCalled()
279286
expect(mocks.updateLastLogin).not.toBeCalled()
280287
expect(mocks.setAuthCookie).not.toBeCalled()
281288
})

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)
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)
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)
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: 107 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,93 @@ describe('Auth helpers', () => {
202220
)
203221
})
204222
})
223+
224+
describe('sendOnboardingEmail', () => {
225+
beforeEach(() => {
226+
vi.clearAllMocks()
227+
})
228+
229+
afterEach(() => {
230+
vi.restoreAllMocks()
231+
})
232+
it('does not send email if user has logged in before', async () => {
233+
const mockUser = {
234+
id: 'test-id',
235+
236+
lastLoginAt: new Date(),
237+
createdAt: new Date(),
238+
} as unknown as User
239+
240+
mocks.findById.mockResolvedValueOnce(mockUser)
241+
242+
await sendOnboardingEmail(mockUser)
243+
expect(axios.post).not.toHaveBeenCalled()
244+
})
245+
246+
it('does not send email if user was created before release date', async () => {
247+
const mockUser = {
248+
id: 'test-id',
249+
250+
createdAt: new Date('2024-01-01'), // Before release date
251+
} as unknown as User
252+
253+
mocks.findById.mockResolvedValueOnce(mockUser)
254+
255+
await sendOnboardingEmail(mockUser)
256+
expect(axios.post).not.toHaveBeenCalled()
257+
})
258+
259+
it('does not send email in non-prod environment', async () => {
260+
const mockUser = {
261+
id: 'test-id',
262+
263+
createdAt: new Date('2025-03-11'), // After release date
264+
} as unknown as User
265+
266+
mocks.findById.mockResolvedValueOnce(mockUser)
267+
268+
await sendOnboardingEmail(mockUser)
269+
expect(axios.post).not.toHaveBeenCalled()
270+
})
271+
272+
it('sends email in prod for eligible users', async () => {
273+
appConfig.isProd = true
274+
275+
const mockUser = {
276+
id: 'test-id',
277+
278+
lastLoginAt: null,
279+
createdAt: new Date('2025-03-11'), // After release date
280+
} as unknown as User
281+
282+
mocks.findById.mockResolvedValueOnce(mockUser)
283+
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} })
284+
285+
await sendOnboardingEmail(mockUser)
286+
287+
expect(axios.post).toHaveBeenCalledWith(
288+
appConfig.onboardingEmailWebhookUrl,
289+
{
290+
email: mockUser.email,
291+
},
292+
)
293+
})
294+
295+
it('does not send email if webhook URL is not configured', async () => {
296+
vi.mocked(appConfig).isProd = true
297+
vi.mocked(appConfig).onboardingEmailWebhookUrl = ''
298+
299+
const mockUser = {
300+
id: 'test-id',
301+
302+
lastLoginAt: null,
303+
createdAt: new Date('2025-03-11'), // After release date
304+
} as unknown as User
305+
306+
mocks.findById.mockResolvedValueOnce(mockUser)
307+
308+
await sendOnboardingEmail(mockUser)
309+
expect(axios.post).not.toHaveBeenCalled()
310+
})
311+
})
205312
})

0 commit comments

Comments
 (0)