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:
Tom
2024-05-07 06:31:39 -05:00
committed by GitHub
parent dea35985c1
commit acb38ca4e2
9 changed files with 442 additions and 6 deletions

View File

@@ -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);

View File

@@ -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,

View 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 }
]
}
});
});
});
});

View 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();
};

View File

@@ -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();
});
});
});
}
});
});
});

View File

@@ -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,

View File

@@ -0,0 +1,9 @@
import { Type } from '@fastify/type-provider-typebox';
export const resubscribe = {
params: Type.Object({
unsubscribeId: Type.String({
minLength: 1
})
})
};

View File

@@ -0,0 +1,9 @@
import { Type } from '@fastify/type-provider-typebox';
export const unsubscribe = {
params: Type.Object({
unsubscribeId: Type.String({
minLength: 1
})
})
};

View File

@@ -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 [])