mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-22 05:01:23 -05:00
feat(api): add email subscription endpoints to new API (#54000)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com> Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
265
api/src/routes/email-subscription.test.ts
Normal file
265
api/src/routes/email-subscription.test.ts
Normal file
@@ -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 }
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
138
api/src/routes/email-subscription.ts
Normal file
138
api/src/routes/email-subscription.ts
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
api/src/schemas/email-subscription/resubscribe.ts
Normal file
9
api/src/schemas/email-subscription/resubscribe.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const resubscribe = {
|
||||
params: Type.Object({
|
||||
unsubscribeId: Type.String({
|
||||
minLength: 1
|
||||
})
|
||||
})
|
||||
};
|
||||
9
api/src/schemas/email-subscription/unsubscribe.ts
Normal file
9
api/src/schemas/email-subscription/unsubscribe.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const unsubscribe = {
|
||||
params: Type.Object({
|
||||
unsubscribeId: Type.String({
|
||||
minLength: 1
|
||||
})
|
||||
})
|
||||
};
|
||||
@@ -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 [])
|
||||
|
||||
Reference in New Issue
Block a user