diff --git a/api/src/app.ts b/api/src/app.ts index 29297cb98ac..c75acfa302d 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -38,6 +38,7 @@ import { challengeRoutes } from './routes/challenge'; import { deprecatedEndpoints } from './routes/deprecated-endpoints'; import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe'; import { donateRoutes } from './routes/donate'; +import { emailSubscribtionRoutes } from './routes/email-subscription'; import { settingRoutes } from './routes/settings'; import { statusRoute } from './routes/status'; import { userGetRoutes, userRoutes } from './routes/user'; @@ -205,6 +206,7 @@ export const build = async ( void fastify.register(challengeRoutes); void fastify.register(settingRoutes); void fastify.register(donateRoutes); + void fastify.register(emailSubscribtionRoutes); void fastify.register(userRoutes); void fastify.register(protectedCertificateRoutes); void fastify.register(unprotectedCertificateRoutes); diff --git a/api/src/routes/auth-dev.test.ts b/api/src/routes/auth-dev.test.ts index 79e1ce7932b..a3a3ef9dfcf 100644 --- a/api/src/routes/auth-dev.test.ts +++ b/api/src/routes/auth-dev.test.ts @@ -7,6 +7,7 @@ import { superRequest } from '../../jest.utils'; import { HOME_LOCATION } from '../utils/env'; +import { nanoidCharSet } from '../utils/create-user'; describe('dev login', () => { setupServer(); @@ -39,6 +40,7 @@ describe('dev login', () => { it('should populate the user with the correct data', async () => { const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/; + const unsubscribeIdRe = new RegExp(`^[${nanoidCharSet}]{21}$`); await superRequest('/signin', { method: 'GET' }); const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ @@ -78,7 +80,7 @@ describe('dev login', () => { keyboardShortcuts: false, location: '', name: '', - unsubscribeId: '', + unsubscribeId: expect.stringMatching(unsubscribeIdRe), picture: '', profileUI: { isLocked: false, diff --git a/api/src/routes/email-subscription.test.ts b/api/src/routes/email-subscription.test.ts new file mode 100644 index 00000000000..c8ff41ccce0 --- /dev/null +++ b/api/src/routes/email-subscription.test.ts @@ -0,0 +1,265 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import type { Prisma } from '@prisma/client'; +import { setupServer, superRequest } from '../../jest.utils'; +import { HOME_LOCATION } from '../utils/env'; +import { createUserInput } from '../utils/create-user'; + +const urlEncodedInfoMessage1 = + '?messages=info%5B0%5D%3DWe%2520could%2520not%2520find%2520an%2520account%2520to%2520unsubscribe.'; +const urlEncodedInfoMessage2 = + '?messages=info%5B0%5D%3DWe%2520were%2520unable%2520to%2520process%2520this%2520request%252C%2520please%2520check%2520and%2520try%2520again.'; +const urlEncodedInfoMessage3 = + '?messages=info%5B0%5D%3DWe%2520could%2520not%2520find%2520an%2520account%2520to%2520resubscribe.'; +const urlEncodedSuccessMessage1 = + '?messages=success%5B0%5D%3DWe%2527ve%2520successfully%2520updated%2520your%2520email%2520preferences.'; +const urlEncodedSuccessMessage2 = + '?messages=success%5B0%5D%3DWe%2527ve%2520successfully%2520updated%2520your%2520email%2520preferences.%2520Thank%2520you%2520for%2520resubscribing.'; + +const unsubscribeId1 = 'abcde'; +const unsubscribeId2 = 'abcdef'; + +const testUserData1: Prisma.userCreateInput[] = [ + { + ...createUserInput('user1@freecodecamp.org'), + unsubscribeId: unsubscribeId1, + sendQuincyEmail: true + }, + { + ...createUserInput('user2@freecodecamp.org'), + unsubscribeId: unsubscribeId2, + sendQuincyEmail: true + }, + { + ...createUserInput('user3@freecodecamp.org'), + unsubscribeId: unsubscribeId2, + sendQuincyEmail: true + } +]; + +const testUserData2: Prisma.userCreateInput[] = [ + { + ...createUserInput('user1@freecodecamp.org'), + unsubscribeId: unsubscribeId1, + sendQuincyEmail: false + }, + { + ...createUserInput('user2@freecodecamp.org'), + unsubscribeId: unsubscribeId2, + sendQuincyEmail: false + }, + { + ...createUserInput('user3@freecodecamp.org'), + unsubscribeId: unsubscribeId2, + sendQuincyEmail: false + } +]; + +describe('Email Subscription endpoints', () => { + setupServer(); + + describe('GET /ue/unsubscribe/:unsubscribeId', () => { + test('should 302 redirect with info message if no ID', async () => { + const response = await superRequest('/ue/', { method: 'GET' }); + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}${urlEncodedInfoMessage1}` + ); + expect(response.status).toBe(302); + }); + + test('should 302 redirect with info message if bad ID', async () => { + const response = await superRequest('/ue/54321edcba', { method: 'GET' }); + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}${urlEncodedInfoMessage1}` + ); + expect(response.status).toBe(302); + }); + + test("should set 'sendQuincyEmail' to 'false' for user with matching ID and 302 redirect with success message", async () => { + await fastifyTestInstance.prisma.user.createMany({ + data: testUserData1 + }); + + const response = await superRequest(`/ue/${unsubscribeId1}`, { + method: 'GET' + }); + + const users = await fastifyTestInstance.prisma.user.findMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + + expect(users).toHaveLength(3); + users.forEach(user => { + if (user.unsubscribeId === unsubscribeId1) { + expect(user.sendQuincyEmail).toBe(false); + } else { + expect(user.sendQuincyEmail).toBe(true); + } + }); + + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}/unsubscribed/${unsubscribeId1}${urlEncodedSuccessMessage1}` + ); + + expect(response.status).toBe(302); + await fastifyTestInstance.prisma.user.deleteMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + }); + + test("should set 'sendQuincyEmail' to 'false' for all users with matching ID and 302 redirect with success message", async () => { + await fastifyTestInstance.prisma.user.createMany({ + data: testUserData1 + }); + + const response = await superRequest(`/ue/${unsubscribeId2}`, { + method: 'GET' + }); + + const users = await fastifyTestInstance.prisma.user.findMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + + expect(users).toHaveLength(3); + users.forEach(user => { + if (user.unsubscribeId === unsubscribeId2) { + expect(user.sendQuincyEmail).toBe(false); + } else { + expect(user.sendQuincyEmail).toBe(true); + } + }); + + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}/unsubscribed/${unsubscribeId2}${urlEncodedSuccessMessage1}` + ); + + expect(response.status).toBe(302); + await fastifyTestInstance.prisma.user.deleteMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + }); + }); + + describe('GET /resubscribe/:unsubscribeId', () => { + test('should 302 redirect with info message if no ID', async () => { + const response = await superRequest('/resubscribe/', { method: 'GET' }); + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}${urlEncodedInfoMessage2}` + ); + expect(response.status).toBe(302); + }); + + test('should 302 redirect with info message if bad ID', async () => { + const response = await superRequest('/resubscribe/54321edcba', { + method: 'GET' + }); + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}${urlEncodedInfoMessage3}` + ); + expect(response.status).toBe(302); + }); + + test("should set 'sendQuincyEmail' to 'true' for user with matching ID and 302 redirect with success message", async () => { + await fastifyTestInstance.prisma.user.createMany({ + data: testUserData2 + }); + + const response = await superRequest(`/resubscribe/${unsubscribeId1}`, { + method: 'GET' + }); + + const users = await fastifyTestInstance.prisma.user.findMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + + expect(users).toHaveLength(3); + users.forEach(user => { + if (user.unsubscribeId === unsubscribeId1) { + expect(user.sendQuincyEmail).toBe(true); + } else { + expect(user.sendQuincyEmail).toBe(false); + } + }); + + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}${urlEncodedSuccessMessage2}` + ); + + expect(response.status).toBe(302); + await fastifyTestInstance.prisma.user.deleteMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + }); + + test("should set 'sendQuincyEmail' to 'true' for first user with matching ID and 302 redirect with success message", async () => { + await fastifyTestInstance.prisma.user.createMany({ + data: testUserData2 + }); + + const response = await superRequest(`/resubscribe/${unsubscribeId2}`, { + method: 'GET' + }); + + const users = await fastifyTestInstance.prisma.user.findMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + + expect(users).toHaveLength(3); + users.forEach(user => { + if (user.email === 'user2@freecodecamp.org') { + expect(user.sendQuincyEmail).toBe(true); + } else { + expect(user.sendQuincyEmail).toBe(false); + } + }); + + expect(response.headers.location).toStrictEqual( + `${HOME_LOCATION}${urlEncodedSuccessMessage2}` + ); + + expect(response.status).toBe(302); + await fastifyTestInstance.prisma.user.deleteMany({ + where: { + OR: [ + { unsubscribeId: unsubscribeId1 }, + { unsubscribeId: unsubscribeId2 } + ] + } + }); + }); + }); +}); diff --git a/api/src/routes/email-subscription.ts b/api/src/routes/email-subscription.ts new file mode 100644 index 00000000000..8444c1f36c8 --- /dev/null +++ b/api/src/routes/email-subscription.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; +import { schemas } from '../schemas'; +import { getRedirectParams } from '../utils/redirection'; + +/** + * Endpoints to set 'sendQuincyEmail' to true or false using 'unsubscribeId'. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done The callback to signal that the plugin is ready. + */ +export const emailSubscribtionRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.get( + '/ue/:unsubscribeId', + { + schema: schemas.unsubscribe, + errorHandler(error, request, reply) { + if (error.validation) { + const { origin } = getRedirectParams(request); + void reply.code(302); + void reply.redirectWithMessage(origin, { + type: 'info', + content: 'We could not find an account to unsubscribe.' + }); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const { origin } = getRedirectParams(req); + const { unsubscribeId } = req.params; + const users = await fastify.prisma.user.findMany({ + where: { unsubscribeId } + }); + + if (!users.length) { + void reply.code(302); + return reply.redirectWithMessage(origin, { + type: 'info', + content: 'We could not find an account to unsubscribe.' + }); + } + + const userUpdatePromises = users.map(user => + fastify.prisma.user.update({ + where: { id: user.id }, + data: { + sendQuincyEmail: false + } + }) + ); + + await Promise.all(userUpdatePromises); + + return reply.redirectWithMessage( + `${origin}/unsubscribed/${unsubscribeId}`, + { + type: 'success', + content: "We've successfully updated your email preferences." + } + ); + } catch (error) { + fastify.log.error(error); + void reply.code(302); + return reply.redirectWithMessage(origin, { + type: 'danger', + content: 'Something went wrong.' + }); + } + } + ); + + fastify.get( + '/resubscribe/:unsubscribeId', + { + schema: schemas.resubscribe, + errorHandler(error, request, reply) { + if (error.validation) { + const { origin } = getRedirectParams(request); + void reply.code(302); + void reply.redirectWithMessage(origin, { + type: 'info', + content: + 'We were unable to process this request, please check and try again.' + }); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const { origin } = getRedirectParams(req); + const { unsubscribeId } = req.params; + const user = await fastify.prisma.user.findFirst({ + where: { unsubscribeId } + }); + + if (!user) { + void reply.code(302); + return reply.redirectWithMessage(origin, { + type: 'info', + content: 'We could not find an account to resubscribe.' + }); + } + + await fastify.prisma.user.update({ + where: { id: user.id }, + data: { + sendQuincyEmail: true + } + }); + + return reply.redirectWithMessage(origin, { + type: 'success', + content: + "We've successfully updated your email preferences. Thank you for resubscribing." + }); + } catch (error) { + fastify.log.error(error); + void reply.code(302); + return reply.redirectWithMessage(origin, { + type: 'danger', + content: 'Something went wrong.' + }); + } + } + ); + + done(); +}; diff --git a/api/src/schema.test.ts b/api/src/schema.test.ts index d417599e17d..a6fdc122234 100644 --- a/api/src/schema.test.ts +++ b/api/src/schema.test.ts @@ -39,11 +39,13 @@ describe('Schemas do not use obviously dangerous validation', () => { }); } - Object.entries(schema.response).forEach(([code, codeSchema]) => { - test(`response ${code} is secure`, () => { - expect(isSchemaSecure(codeSchema)).toBeTruthy(); + if ('response' in schema) { + Object.entries(schema.response).forEach(([code, codeSchema]) => { + test(`response ${code} is secure`, () => { + expect(isSchemaSecure(codeSchema)).toBeTruthy(); + }); }); - }); + } }); }); }); diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 90af034fb80..d81d3242bcc 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -10,6 +10,8 @@ import { projectCompleted } from './schemas/challenge/project-completed'; import { saveChallenge } from './schemas/challenge/save-challenge'; import { deprecatedEndpoints } from './schemas/deprecated'; import { chargeStripeCard } from './schemas/donate/charge-stripe-card'; +import { resubscribe } from './schemas/email-subscription/resubscribe'; +import { unsubscribe } from './schemas/email-subscription/unsubscribe'; import { updateMyAbout } from './schemas/settings/update-my-about'; import { updateMyClassroomMode } from './schemas/settings/update-my-classroom-mode'; import { updateMyEmail } from './schemas/settings/update-my-email'; @@ -52,6 +54,8 @@ export const schemas = { submitSurvey, reportUser, resetMyProgress, + resubscribe, + unsubscribe, updateMyAbout, updateMyClassroomMode, updateMyEmail, diff --git a/api/src/schemas/email-subscription/resubscribe.ts b/api/src/schemas/email-subscription/resubscribe.ts new file mode 100644 index 00000000000..5ef22254102 --- /dev/null +++ b/api/src/schemas/email-subscription/resubscribe.ts @@ -0,0 +1,9 @@ +import { Type } from '@fastify/type-provider-typebox'; + +export const resubscribe = { + params: Type.Object({ + unsubscribeId: Type.String({ + minLength: 1 + }) + }) +}; diff --git a/api/src/schemas/email-subscription/unsubscribe.ts b/api/src/schemas/email-subscription/unsubscribe.ts new file mode 100644 index 00000000000..3cf124780b0 --- /dev/null +++ b/api/src/schemas/email-subscription/unsubscribe.ts @@ -0,0 +1,9 @@ +import { Type } from '@fastify/type-provider-typebox'; + +export const unsubscribe = { + params: Type.Object({ + unsubscribeId: Type.String({ + minLength: 1 + }) + }) +}; diff --git a/api/src/utils/create-user.ts b/api/src/utils/create-user.ts index 0a136c08369..2af57cf652b 100644 --- a/api/src/utils/create-user.ts +++ b/api/src/utils/create-user.ts @@ -1,6 +1,11 @@ import crypto from 'node:crypto'; import { type Prisma } from '@prisma/client'; +import { customAlphabet } from 'nanoid'; + +export const nanoidCharSet = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const nanoid = customAlphabet(nanoidCharSet, 21); /** * Creates the necessary data to create a new user. @@ -52,7 +57,7 @@ export function createUserInput(email: string): Prisma.userCreateInput { keyboardShortcuts: false, location: '', name: '', - unsubscribeId: '', + unsubscribeId: nanoid(), partiallyCompletedChallenges: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return []) picture: '', portfolio: [], // TODO(Post-MVP): Omit this from the document? (prisma will always return [])