mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): add PUT /certificate/verify (#51507)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
+3
-1
@@ -34,7 +34,8 @@
|
|||||||
"pino-pretty": "10.2.3",
|
"pino-pretty": "10.2.3",
|
||||||
"query-string": "7.1.3",
|
"query-string": "7.1.3",
|
||||||
"rate-limit-mongo": "^2.3.2",
|
"rate-limit-mongo": "^2.3.2",
|
||||||
"stripe": "8.222.0"
|
"stripe": "8.222.0",
|
||||||
|
"validator": "13.11.0"
|
||||||
},
|
},
|
||||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"@types/jsonwebtoken": "9.0.5",
|
"@types/jsonwebtoken": "9.0.5",
|
||||||
"@types/nodemailer": "6.4.14",
|
"@types/nodemailer": "6.4.14",
|
||||||
"@types/supertest": "2.0.16",
|
"@types/supertest": "2.0.16",
|
||||||
|
"@types/validator": "13.11.2",
|
||||||
"dotenv-cli": "7.3.0",
|
"dotenv-cli": "7.3.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"prisma": "5.5.2",
|
"prisma": "5.5.2",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ model user {
|
|||||||
isDataAnalysisPyCertV7 Boolean? // Undefined
|
isDataAnalysisPyCertV7 Boolean? // Undefined
|
||||||
isDataVisCert Boolean? // Undefined
|
isDataVisCert Boolean? // Undefined
|
||||||
isDonating Boolean
|
isDonating Boolean
|
||||||
|
isFoundationalCSharpCertV8 Boolean? // Undefined
|
||||||
isFrontEndCert Boolean? // Undefined
|
isFrontEndCert Boolean? // Undefined
|
||||||
isFrontEndLibsCert Boolean? // Undefined
|
isFrontEndLibsCert Boolean? // Undefined
|
||||||
isFullStackCert Boolean? // Undefined
|
isFullStackCert Boolean? // Undefined
|
||||||
@@ -104,6 +105,7 @@ model user {
|
|||||||
isInfosecCertV7 Boolean? // Undefined
|
isInfosecCertV7 Boolean? // Undefined
|
||||||
isInfosecQaCert Boolean? // Undefined
|
isInfosecQaCert Boolean? // Undefined
|
||||||
isJsAlgoDataStructCert Boolean? // Undefined
|
isJsAlgoDataStructCert Boolean? // Undefined
|
||||||
|
isJsAlgoDataStructCertV8 Boolean? // Undefined
|
||||||
isMachineLearningPyCertV7 Boolean? // Undefined
|
isMachineLearningPyCertV7 Boolean? // Undefined
|
||||||
isQaCertV7 Boolean? // Undefined
|
isQaCertV7 Boolean? // Undefined
|
||||||
isRelationalDatabaseCertV8 Boolean? // Undefined
|
isRelationalDatabaseCertV8 Boolean? // Undefined
|
||||||
@@ -112,6 +114,7 @@ model user {
|
|||||||
is2018DataVisCert Boolean? // Undefined
|
is2018DataVisCert Boolean? // Undefined
|
||||||
is2018FullStackCert Boolean? // Undefined
|
is2018FullStackCert Boolean? // Undefined
|
||||||
isCollegeAlgebraPyCertV8 Boolean? // Undefined
|
isCollegeAlgebraPyCertV8 Boolean? // Undefined
|
||||||
|
isUpcomingPythonCertV8 Boolean? // Undefined
|
||||||
keyboardShortcuts Boolean? // Undefined
|
keyboardShortcuts Boolean? // Undefined
|
||||||
linkedin String? // Null | Undefined
|
linkedin String? // Null | Undefined
|
||||||
location String? // Null
|
location String? // Null
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
SESSION_SECRET
|
SESSION_SECRET
|
||||||
} from './utils/env';
|
} from './utils/env';
|
||||||
import { isObjectID } from './utils/validation';
|
import { isObjectID } from './utils/validation';
|
||||||
|
import { certificateRoutes } from './routes/certificate';
|
||||||
|
|
||||||
export type FastifyInstanceWithTypeProvider = FastifyInstance<
|
export type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||||
RawServerDefault,
|
RawServerDefault,
|
||||||
@@ -196,6 +197,7 @@ export const build = async (
|
|||||||
void fastify.register(devLoginCallback, { prefix: '/auth' });
|
void fastify.register(devLoginCallback, { prefix: '/auth' });
|
||||||
void fastify.register(devLegacyAuthRoutes);
|
void fastify.register(devLegacyAuthRoutes);
|
||||||
}
|
}
|
||||||
|
void fastify.register(certificateRoutes);
|
||||||
void fastify.register(challengeRoutes);
|
void fastify.register(challengeRoutes);
|
||||||
void fastify.register(settingRoutes);
|
void fastify.register(settingRoutes);
|
||||||
void fastify.register(donateRoutes);
|
void fastify.register(donateRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { type PrismaPromise } from '@prisma/client';
|
||||||
|
import { Certification } from '../../../shared/config/certification-settings';
|
||||||
|
import {
|
||||||
|
defaultUserEmail,
|
||||||
|
defaultUserId,
|
||||||
|
devLogin,
|
||||||
|
setupServer,
|
||||||
|
superRequest
|
||||||
|
} from '../../jest.utils';
|
||||||
|
import { SHOW_UPCOMING_CHANGES } from '../utils/env';
|
||||||
|
|
||||||
|
describe('certificate routes', () => {
|
||||||
|
setupServer();
|
||||||
|
describe('Authenticated user', () => {
|
||||||
|
let setCookies: string[];
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
beforeAll(async () => {
|
||||||
|
setCookies = await devLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /certificate/verify', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.updateMany({
|
||||||
|
where: { email: defaultUserEmail },
|
||||||
|
data: {
|
||||||
|
completedChallenges: [],
|
||||||
|
name: 'fcc',
|
||||||
|
isRespWebDesignCert: false,
|
||||||
|
isJsAlgoDataStructCert: false,
|
||||||
|
isFrontEndLibsCert: false,
|
||||||
|
is2018DataVisCert: false,
|
||||||
|
isRelationalDatabaseCertV8: false,
|
||||||
|
isApisMicroservicesCert: false,
|
||||||
|
isQaCertV7: false,
|
||||||
|
isSciCompPyCertV7: false,
|
||||||
|
isDataAnalysisPyCertV7: false,
|
||||||
|
isInfosecCertV7: false,
|
||||||
|
isMachineLearningPyCertV7: false,
|
||||||
|
isCollegeAlgebraPyCertV8: false,
|
||||||
|
isFoundationalCSharpCertV8: false,
|
||||||
|
username: 'fcc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 if no certSlug', async () => {
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({});
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
response: {
|
||||||
|
message: 'flash.wrong-name',
|
||||||
|
variables: { name: '' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 if certSlug is invalid', async () => {
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: 'non-existant'
|
||||||
|
});
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
response: {
|
||||||
|
message: 'flash.wrong-name',
|
||||||
|
variables: { name: 'non-existant' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 500 if user not found in db', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
|
||||||
|
.mockImplementation(
|
||||||
|
() => Promise.resolve(null) as PrismaPromise<null>
|
||||||
|
);
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.body).toStrictEqual({
|
||||||
|
message: 'flash.went-wrong',
|
||||||
|
type: 'danger'
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 if user has not set a `name`', async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.update({
|
||||||
|
where: { id: defaultUserId },
|
||||||
|
data: {
|
||||||
|
name: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
response: {
|
||||||
|
type: 'info',
|
||||||
|
message: 'flash.name-needed'
|
||||||
|
},
|
||||||
|
isCertMap: {
|
||||||
|
is2018DataVisCert: false,
|
||||||
|
isApisMicroservicesCert: false,
|
||||||
|
isBackEndCert: false,
|
||||||
|
isCollegeAlgebraPyCertV8: false,
|
||||||
|
isDataAnalysisPyCertV7: false,
|
||||||
|
isDataVisCert: false,
|
||||||
|
isFoundationalCSharpCertV8: false,
|
||||||
|
isFrontEndCert: false,
|
||||||
|
isFrontEndLibsCert: false,
|
||||||
|
isFullStackCert: false,
|
||||||
|
isInfosecCertV7: false,
|
||||||
|
isInfosecQaCert: false,
|
||||||
|
isJsAlgoDataStructCert: false,
|
||||||
|
isMachineLearningPyCertV7: false,
|
||||||
|
isQaCertV7: false,
|
||||||
|
isRelationalDatabaseCertV8: false,
|
||||||
|
isRespWebDesignCert: false,
|
||||||
|
isSciCompPyCertV7: false
|
||||||
|
},
|
||||||
|
completedChallenges: []
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 200 if user already claimed cert', async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.updateMany({
|
||||||
|
where: { email: defaultUserEmail },
|
||||||
|
data: {
|
||||||
|
completedChallenges: [],
|
||||||
|
isRespWebDesignCert: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
expect(response.body.response).toStrictEqual({
|
||||||
|
type: 'info',
|
||||||
|
message: 'flash.already-claimed',
|
||||||
|
variables: {
|
||||||
|
name: 'Responsive Web Design'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 if not all requirements have been met to claim', async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.updateMany({
|
||||||
|
where: { email: defaultUserEmail },
|
||||||
|
data: {
|
||||||
|
completedChallenges: [
|
||||||
|
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||||
|
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||||
|
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||||
|
],
|
||||||
|
isRespWebDesignCert: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
expect(response.body.response).toStrictEqual({
|
||||||
|
message: 'flash.incomplete-steps',
|
||||||
|
type: 'info',
|
||||||
|
variables: { name: 'Responsive Web Design' }
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 500 if db update fails', async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.updateMany({
|
||||||
|
where: { email: defaultUserEmail },
|
||||||
|
data: {
|
||||||
|
completedChallenges: [
|
||||||
|
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||||
|
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||||
|
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
jest
|
||||||
|
.spyOn(fastifyTestInstance.prisma.user, 'update')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
throw new Error('test');
|
||||||
|
});
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.body).toStrictEqual({
|
||||||
|
message: 'flash.went-wrong',
|
||||||
|
type: 'danger'
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Email does not actually send (work) in development, but status should still be 200.
|
||||||
|
test('should send the certified email, if all current certifications are met', async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.updateMany({
|
||||||
|
where: { email: defaultUserEmail },
|
||||||
|
data: {
|
||||||
|
completedChallenges: [
|
||||||
|
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||||
|
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||||
|
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||||
|
],
|
||||||
|
isRespWebDesignCert: false,
|
||||||
|
isJsAlgoDataStructCertV8: true,
|
||||||
|
isFrontEndLibsCert: true,
|
||||||
|
is2018DataVisCert: true,
|
||||||
|
isRelationalDatabaseCertV8: true,
|
||||||
|
isApisMicroservicesCert: true,
|
||||||
|
isQaCertV7: true,
|
||||||
|
isSciCompPyCertV7: true,
|
||||||
|
isDataAnalysisPyCertV7: true,
|
||||||
|
isInfosecCertV7: true,
|
||||||
|
isMachineLearningPyCertV7: true,
|
||||||
|
isCollegeAlgebraPyCertV8: true,
|
||||||
|
isFoundationalCSharpCertV8: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const spy = jest.spyOn(fastifyTestInstance, 'sendEmail');
|
||||||
|
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 200 if all went well', async () => {
|
||||||
|
await fastifyTestInstance.prisma.user.updateMany({
|
||||||
|
where: { email: defaultUserEmail },
|
||||||
|
data: {
|
||||||
|
completedChallenges: [
|
||||||
|
{ id: 'bd7158d8c442eddfaeb5bd18', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b03', completedDate: 123456789 },
|
||||||
|
{ id: '587d78af367417b2b2512b04', completedDate: 123456789 },
|
||||||
|
{ id: '587d78b0367417b2b2512b05', completedDate: 123456789 },
|
||||||
|
{ id: 'bd7158d8c242eddfaeb5bd13', completedDate: 123456789 }
|
||||||
|
],
|
||||||
|
isRespWebDesignCert: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug: Certification.RespWebDesign
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await fastifyTestInstance.prisma.user.findFirst({
|
||||||
|
where: { email: defaultUserEmail }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user).toMatchObject({ isRespWebDesignCert: true });
|
||||||
|
expect(response.body).toStrictEqual({
|
||||||
|
response: {
|
||||||
|
message: 'flash.cert-claim-success',
|
||||||
|
type: 'success',
|
||||||
|
variables: {
|
||||||
|
name: 'Responsive Web Design',
|
||||||
|
username: 'fcc'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isCertMap: {
|
||||||
|
isRespWebDesignCert: true,
|
||||||
|
isJsAlgoDataStructCert: false,
|
||||||
|
isFrontEndLibsCert: false,
|
||||||
|
is2018DataVisCert: false,
|
||||||
|
isApisMicroservicesCert: false,
|
||||||
|
isInfosecQaCert: false,
|
||||||
|
isQaCertV7: false,
|
||||||
|
isInfosecCertV7: false,
|
||||||
|
isFrontEndCert: false,
|
||||||
|
isBackEndCert: false,
|
||||||
|
isDataVisCert: false,
|
||||||
|
isFullStackCert: false,
|
||||||
|
isSciCompPyCertV7: false,
|
||||||
|
isDataAnalysisPyCertV7: false,
|
||||||
|
isMachineLearningPyCertV7: false,
|
||||||
|
isRelationalDatabaseCertV8: false,
|
||||||
|
isCollegeAlgebraPyCertV8: false,
|
||||||
|
isFoundationalCSharpCertV8: false
|
||||||
|
},
|
||||||
|
completedChallenges: [
|
||||||
|
{
|
||||||
|
completedDate: 123456789,
|
||||||
|
files: [],
|
||||||
|
id: 'bd7158d8c442eddfaeb5bd18'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
completedDate: 123456789,
|
||||||
|
files: [],
|
||||||
|
id: '587d78af367417b2b2512b03'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
completedDate: 123456789,
|
||||||
|
files: [],
|
||||||
|
id: '587d78af367417b2b2512b04'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
completedDate: 123456789,
|
||||||
|
files: [],
|
||||||
|
id: '587d78b0367417b2b2512b05'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
completedDate: 123456789,
|
||||||
|
files: [],
|
||||||
|
id: 'bd7158d8c242eddfaeb5bd13'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
challengeType: 7,
|
||||||
|
// TODO: use matcher for date near now
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
completedDate: expect.any(Number),
|
||||||
|
files: [],
|
||||||
|
id: '561add10cb82ac38a17513bc'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tests for all certifications as to what may currently be claimed, and what may no longer be claimed
|
||||||
|
test('should return 400 if certSlug is not allowed', async () => {
|
||||||
|
const claimableCerts = [
|
||||||
|
Certification.RespWebDesign,
|
||||||
|
Certification.JsAlgoDataStruct,
|
||||||
|
Certification.FrontEndDevLibs,
|
||||||
|
Certification.DataVis,
|
||||||
|
Certification.RelationalDb,
|
||||||
|
Certification.BackEndDevApis,
|
||||||
|
Certification.QualityAssurance,
|
||||||
|
Certification.SciCompPy,
|
||||||
|
Certification.DataAnalysisPy,
|
||||||
|
Certification.InfoSec,
|
||||||
|
Certification.MachineLearningPy,
|
||||||
|
Certification.CollegeAlgebraPy,
|
||||||
|
Certification.FoundationalCSharp,
|
||||||
|
Certification.LegacyFrontEnd,
|
||||||
|
Certification.LegacyBackEnd,
|
||||||
|
Certification.LegacyDataVis,
|
||||||
|
Certification.LegacyInfoSecQa,
|
||||||
|
Certification.LegacyFullStack
|
||||||
|
];
|
||||||
|
const unclaimableCerts = ['fake-slug'];
|
||||||
|
|
||||||
|
if (SHOW_UPCOMING_CHANGES) {
|
||||||
|
claimableCerts.push(Certification.UpcomingPython);
|
||||||
|
} else {
|
||||||
|
unclaimableCerts.push(Certification.UpcomingPython);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const certSlug of claimableCerts) {
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
// `flash.incomplete-steps` comes after the check for whether a certification may be claimed or not.
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
response: { message: 'flash.incomplete-steps' }
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const certSlug of unclaimableCerts) {
|
||||||
|
const response = await superRequest('/certificate/verify', {
|
||||||
|
method: 'PUT',
|
||||||
|
setCookies
|
||||||
|
}).send({
|
||||||
|
certSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
response: {
|
||||||
|
variables: { name: certSlug },
|
||||||
|
message: 'flash.wrong-name'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||||
|
import isEmail from 'validator/lib/isEmail';
|
||||||
|
import { schemas } from '../schemas';
|
||||||
|
import { getChallenges } from '../utils/get-challenges';
|
||||||
|
import {
|
||||||
|
certIds,
|
||||||
|
certSlugTypeMap,
|
||||||
|
certTypeTitleMap,
|
||||||
|
certTypes,
|
||||||
|
currentCertifications,
|
||||||
|
legacyCertifications,
|
||||||
|
legacyFullStackCertification,
|
||||||
|
upcomingCertifications
|
||||||
|
} from '../../../shared/config/certification-settings';
|
||||||
|
import { normalizeChallenges, removeNulls } from '../utils/normalize';
|
||||||
|
import { CompletedChallenge } from '../utils/common-challenge-functions';
|
||||||
|
import { SHOW_UPCOMING_CHANGES } from '../utils/env';
|
||||||
|
|
||||||
|
const {
|
||||||
|
legacyFrontEndChallengeId,
|
||||||
|
legacyBackEndChallengeId,
|
||||||
|
legacyDataVisId,
|
||||||
|
legacyInfosecQaId,
|
||||||
|
legacyFullStackId,
|
||||||
|
respWebDesignId,
|
||||||
|
frontEndDevLibsId,
|
||||||
|
jsAlgoDataStructId,
|
||||||
|
jsAlgoDataStructV8Id,
|
||||||
|
dataVis2018Id,
|
||||||
|
apisMicroservicesId,
|
||||||
|
qaV7Id,
|
||||||
|
infosecV7Id,
|
||||||
|
sciCompPyV7Id,
|
||||||
|
dataAnalysisPyV7Id,
|
||||||
|
machineLearningPyV7Id,
|
||||||
|
relationalDatabaseV8Id,
|
||||||
|
collegeAlgebraPyV8Id,
|
||||||
|
foundationalCSharpV8Id,
|
||||||
|
upcomingPythonV8Id
|
||||||
|
} = certIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin for the certificate endpoints.
|
||||||
|
*
|
||||||
|
* @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 certificateRoutes: FastifyPluginCallbackTypebox = (
|
||||||
|
fastify,
|
||||||
|
_options,
|
||||||
|
done
|
||||||
|
) => {
|
||||||
|
const challenges = getChallenges();
|
||||||
|
const certTypeIds = createCertTypeIds(challenges);
|
||||||
|
|
||||||
|
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
||||||
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||||
|
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||||
|
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||||
|
|
||||||
|
// TODO(POST_MVP): Response should not include updated user. If a client wants the updated user, it should make a separate request
|
||||||
|
// OR: Always respond with current user - full user object - not random pieces.
|
||||||
|
fastify.put(
|
||||||
|
'/certificate/verify',
|
||||||
|
{
|
||||||
|
schema: schemas.certificateVerify,
|
||||||
|
errorHandler(error, request, reply) {
|
||||||
|
if (error.validation) {
|
||||||
|
void reply.code(400).send({
|
||||||
|
response: {
|
||||||
|
type: 'danger',
|
||||||
|
message: 'flash.wrong-name',
|
||||||
|
variables: { name: '' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fastify.errorHandler(error, request, reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
const { certSlug } = req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!assertCertSlugIsKeyofCertSlugTypeMap(certSlug) ||
|
||||||
|
!isCertAllowed(certSlug)
|
||||||
|
) {
|
||||||
|
void reply.code(400);
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
type: 'danger',
|
||||||
|
// message: 'Certificate type not found'
|
||||||
|
message: 'flash.wrong-name',
|
||||||
|
variables: { name: certSlug }
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certType = certSlugTypeMap[certSlug];
|
||||||
|
const certName = certTypeTitleMap[certType];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await fastify.prisma.user.findUnique({
|
||||||
|
where: { id: req.session.user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
void reply.code(500);
|
||||||
|
return {
|
||||||
|
type: 'danger',
|
||||||
|
// message: 'User not found'
|
||||||
|
message: 'flash.went-wrong'
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
const { completedChallenges } = user;
|
||||||
|
const isCertMap = getUserIsCertMap(removeNulls(user));
|
||||||
|
|
||||||
|
// TODO: Discuss if this is a requirement still
|
||||||
|
if (!user.name) {
|
||||||
|
void reply.code(400);
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
type: 'info',
|
||||||
|
message: 'flash.name-needed'
|
||||||
|
},
|
||||||
|
isCertMap,
|
||||||
|
completedChallenges: normalizeChallenges(completedChallenges)
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user[certType]) {
|
||||||
|
void reply.code(200);
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
type: 'info',
|
||||||
|
message: 'flash.already-claimed',
|
||||||
|
variables: {
|
||||||
|
name: certName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isCertMap,
|
||||||
|
completedChallenges: normalizeChallenges(completedChallenges)
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, tests, challengeType } = certTypeIds[certType];
|
||||||
|
const hasCompletedTestRequirements = hasCompletedTests(
|
||||||
|
tests,
|
||||||
|
user.completedChallenges
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasCompletedTestRequirements) {
|
||||||
|
void reply.code(400);
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
type: 'info',
|
||||||
|
message: 'flash.incomplete-steps',
|
||||||
|
variables: {
|
||||||
|
name: certName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isCertMap,
|
||||||
|
completedChallenges: normalizeChallenges(completedChallenges)
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await fastify.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
[certType]: true,
|
||||||
|
completedChallenges: {
|
||||||
|
push: {
|
||||||
|
id,
|
||||||
|
completedDate: Date.now(),
|
||||||
|
challengeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
completedChallenges: true,
|
||||||
|
is2018DataVisCert: true,
|
||||||
|
is2018FullStackCert: true,
|
||||||
|
isApisMicroservicesCert: true,
|
||||||
|
isBackEndCert: true,
|
||||||
|
isDataVisCert: true,
|
||||||
|
isCollegeAlgebraPyCertV8: true,
|
||||||
|
isDataAnalysisPyCertV7: true,
|
||||||
|
isFoundationalCSharpCertV8: true,
|
||||||
|
isFrontEndCert: true,
|
||||||
|
isFrontEndLibsCert: true,
|
||||||
|
isFullStackCert: true,
|
||||||
|
isInfosecCertV7: true,
|
||||||
|
isInfosecQaCert: true,
|
||||||
|
isJsAlgoDataStructCert: true,
|
||||||
|
isJsAlgoDataStructCertV8: true,
|
||||||
|
isMachineLearningPyCertV7: true,
|
||||||
|
isQaCertV7: true,
|
||||||
|
isRelationalDatabaseCertV8: true,
|
||||||
|
isRespWebDesignCert: true,
|
||||||
|
isSciCompPyCertV7: true,
|
||||||
|
isUpcomingPythonCertV8: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedUserSansNull = removeNulls(updatedUser);
|
||||||
|
|
||||||
|
const updatedIsCertMap = getUserIsCertMap(updatedUserSansNull);
|
||||||
|
|
||||||
|
// TODO(POST-MVP): Consider sending email based on `user.isEmailVerified` as well
|
||||||
|
const hasCompletedAllCerts = currentCertifications
|
||||||
|
.map(x => certSlugTypeMap[x])
|
||||||
|
.every(certType => updatedIsCertMap[certType]);
|
||||||
|
const shouldSendCertifiedEmailToCamper =
|
||||||
|
isEmail(updatedUser.email) && hasCompletedAllCerts;
|
||||||
|
|
||||||
|
if (shouldSendCertifiedEmailToCamper) {
|
||||||
|
const notifyUser = {
|
||||||
|
to: updatedUser.email,
|
||||||
|
from: 'quincy@freecodecamp.org',
|
||||||
|
subject:
|
||||||
|
'Congratulations on completing all of the freeCodeCamp certifications!',
|
||||||
|
text: renderCertifiedEmail({
|
||||||
|
username: updatedUser.username,
|
||||||
|
// Safety: `user.name` is required to exist earlier. TODO: Assert
|
||||||
|
name: updatedUser.name as string
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Failed email should not prevent successful response.
|
||||||
|
try {
|
||||||
|
// TODO(POST-MVP): Ensure Camper knows they **have** claimed the cert, but the email failed to send.
|
||||||
|
await fastify.sendEmail(notifyUser);
|
||||||
|
} catch (e) {
|
||||||
|
fastify.log.error(e);
|
||||||
|
// TODO: Log to Sentry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void reply.code(200);
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
type: 'success',
|
||||||
|
message: 'flash.cert-claim-success',
|
||||||
|
variables: {
|
||||||
|
username: updatedUser.username,
|
||||||
|
name: certName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isCertMap: updatedIsCertMap,
|
||||||
|
completedChallenges: normalizeChallenges(
|
||||||
|
updatedUserSansNull.completedChallenges
|
||||||
|
)
|
||||||
|
} as const;
|
||||||
|
} catch (e) {
|
||||||
|
fastify.log.error(e);
|
||||||
|
void reply.code(500);
|
||||||
|
throw {
|
||||||
|
type: 'danger',
|
||||||
|
// message: 'Oops! Something went wrong. Please try again in a moment or contact
|
||||||
|
message: 'flash.went-wrong'
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
done();
|
||||||
|
};
|
||||||
|
|
||||||
|
function isCertAllowed(certSlug: string): boolean {
|
||||||
|
if (
|
||||||
|
currentCertifications.includes(certSlug) ||
|
||||||
|
legacyCertifications.includes(certSlug) ||
|
||||||
|
legacyFullStackCertification.includes(certSlug)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (SHOW_UPCOMING_CHANGES && upcomingCertifications.includes(certSlug)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCertifiedEmail({
|
||||||
|
username,
|
||||||
|
name
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const certifiedEmailTemplate = `Hi ${name || username},
|
||||||
|
|
||||||
|
Congratulations on completing all of the freeCodeCamp certifications!
|
||||||
|
|
||||||
|
All of your certifications are now live at at: https://www.freecodecamp.org/${username}
|
||||||
|
|
||||||
|
Please tell me a bit more about you and your near-term goals.
|
||||||
|
|
||||||
|
Are you interested in contributing to our open source projects used by nonprofits?
|
||||||
|
|
||||||
|
Also, check out https://contribute.freecodecamp.org/ for some fun and convenient ways you can contribute to the community.
|
||||||
|
|
||||||
|
Happy coding,
|
||||||
|
|
||||||
|
- Quincy Larson, teacher at freeCodeCamp
|
||||||
|
`;
|
||||||
|
return certifiedEmailTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompletedTests(
|
||||||
|
tests: { id: string }[],
|
||||||
|
completedChallenges: CompletedChallenge[]
|
||||||
|
) {
|
||||||
|
return tests.every(({ id }) =>
|
||||||
|
completedChallenges.some(({ id: completedId }) => completedId === id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCertSlugIsKeyofCertSlugTypeMap(
|
||||||
|
certSlug: string
|
||||||
|
): certSlug is keyof typeof certSlugTypeMap {
|
||||||
|
return certSlug in certSlugTypeMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCertTypeIds(challenges: ReturnType<typeof getChallenges>) {
|
||||||
|
return {
|
||||||
|
// legacy
|
||||||
|
[certTypes.frontEnd]: getCertById(legacyFrontEndChallengeId, challenges),
|
||||||
|
[certTypes.jsAlgoDataStruct]: getCertById(jsAlgoDataStructId, challenges),
|
||||||
|
[certTypes.backEnd]: getCertById(legacyBackEndChallengeId, challenges),
|
||||||
|
[certTypes.dataVis]: getCertById(legacyDataVisId, challenges),
|
||||||
|
[certTypes.infosecQa]: getCertById(legacyInfosecQaId, challenges),
|
||||||
|
[certTypes.fullStack]: getCertById(legacyFullStackId, challenges),
|
||||||
|
|
||||||
|
// modern
|
||||||
|
[certTypes.respWebDesign]: getCertById(respWebDesignId, challenges),
|
||||||
|
[certTypes.frontEndDevLibs]: getCertById(frontEndDevLibsId, challenges),
|
||||||
|
[certTypes.dataVis2018]: getCertById(dataVis2018Id, challenges),
|
||||||
|
[certTypes.jsAlgoDataStructV8]: getCertById(
|
||||||
|
jsAlgoDataStructV8Id,
|
||||||
|
challenges
|
||||||
|
),
|
||||||
|
[certTypes.apisMicroservices]: getCertById(apisMicroservicesId, challenges),
|
||||||
|
[certTypes.qaV7]: getCertById(qaV7Id, challenges),
|
||||||
|
[certTypes.infosecV7]: getCertById(infosecV7Id, challenges),
|
||||||
|
[certTypes.sciCompPyV7]: getCertById(sciCompPyV7Id, challenges),
|
||||||
|
[certTypes.dataAnalysisPyV7]: getCertById(dataAnalysisPyV7Id, challenges),
|
||||||
|
[certTypes.machineLearningPyV7]: getCertById(
|
||||||
|
machineLearningPyV7Id,
|
||||||
|
challenges
|
||||||
|
),
|
||||||
|
[certTypes.relationalDatabaseV8]: getCertById(
|
||||||
|
relationalDatabaseV8Id,
|
||||||
|
challenges
|
||||||
|
),
|
||||||
|
[certTypes.collegeAlgebraPyV8]: getCertById(
|
||||||
|
collegeAlgebraPyV8Id,
|
||||||
|
challenges
|
||||||
|
),
|
||||||
|
[certTypes.foundationalCSharpV8]: getCertById(
|
||||||
|
foundationalCSharpV8Id,
|
||||||
|
challenges
|
||||||
|
),
|
||||||
|
|
||||||
|
// upcoming
|
||||||
|
[certTypes.upcomingPythonV8]: getCertById(upcomingPythonV8Id, challenges)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCertById(
|
||||||
|
challengeId: string,
|
||||||
|
challenges: ReturnType<typeof getChallenges>
|
||||||
|
): { id: string; tests: { id: string }[]; challengeType: number } {
|
||||||
|
const challengeById = challenges.filter(({ id }) => id === challengeId)[0];
|
||||||
|
if (!challengeById) {
|
||||||
|
throw new Error(`Challenge with id '${challengeId}' not found`);
|
||||||
|
}
|
||||||
|
const { id, tests, challengeType } = challengeById;
|
||||||
|
assertTestsExist(tests);
|
||||||
|
return { id, tests, challengeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTestsExist(
|
||||||
|
tests: ReturnType<typeof getChallenges>[number]['tests']
|
||||||
|
): asserts tests is { id: string }[] {
|
||||||
|
if (!Array.isArray(tests)) {
|
||||||
|
throw new Error('Tests is not an array');
|
||||||
|
}
|
||||||
|
if (!tests.every(test => typeof test === 'object' && test !== null)) {
|
||||||
|
throw new Error('Tests contains non-object values');
|
||||||
|
}
|
||||||
|
if (!tests.every(test => typeof test.id === 'string')) {
|
||||||
|
throw new Error('Tests contain non-string ids');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertI {
|
||||||
|
isRespWebDesignCert?: boolean;
|
||||||
|
isJsAlgoDataStructCert?: boolean;
|
||||||
|
isJsAlgoDataStructCertV8?: boolean;
|
||||||
|
isFrontEndLibsCert?: boolean;
|
||||||
|
is2018DataVisCert?: boolean;
|
||||||
|
isApisMicroservicesCert?: boolean;
|
||||||
|
isInfosecQaCert?: boolean;
|
||||||
|
isQaCertV7?: boolean;
|
||||||
|
isInfosecCertV7?: boolean;
|
||||||
|
isFrontEndCert?: boolean;
|
||||||
|
isBackEndCert?: boolean;
|
||||||
|
isDataVisCert?: boolean;
|
||||||
|
isFullStackCert?: boolean;
|
||||||
|
isSciCompPyCertV7?: boolean;
|
||||||
|
isDataAnalysisPyCertV7?: boolean;
|
||||||
|
isMachineLearningPyCertV7?: boolean;
|
||||||
|
isRelationalDatabaseCertV8?: boolean;
|
||||||
|
isCollegeAlgebraPyCertV8?: boolean;
|
||||||
|
isFoundationalCSharpCertV8?: boolean;
|
||||||
|
isUpcomingPythonCertV8?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserIsCertMap(user: CertI) {
|
||||||
|
const {
|
||||||
|
isRespWebDesignCert = false,
|
||||||
|
isJsAlgoDataStructCert = false,
|
||||||
|
isJsAlgoDataStructCertV8 = false,
|
||||||
|
isFrontEndLibsCert = false,
|
||||||
|
is2018DataVisCert = false,
|
||||||
|
isApisMicroservicesCert = false,
|
||||||
|
isInfosecQaCert = false,
|
||||||
|
isQaCertV7 = false,
|
||||||
|
isInfosecCertV7 = false,
|
||||||
|
isFrontEndCert = false,
|
||||||
|
isBackEndCert = false,
|
||||||
|
isDataVisCert = false,
|
||||||
|
isFullStackCert = false,
|
||||||
|
isSciCompPyCertV7 = false,
|
||||||
|
isDataAnalysisPyCertV7 = false,
|
||||||
|
isMachineLearningPyCertV7 = false,
|
||||||
|
isRelationalDatabaseCertV8 = false,
|
||||||
|
isCollegeAlgebraPyCertV8 = false,
|
||||||
|
isFoundationalCSharpCertV8 = false,
|
||||||
|
isUpcomingPythonCertV8 = false
|
||||||
|
} = user;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRespWebDesignCert,
|
||||||
|
isJsAlgoDataStructCert,
|
||||||
|
isJsAlgoDataStructCertV8,
|
||||||
|
isFrontEndLibsCert,
|
||||||
|
is2018DataVisCert,
|
||||||
|
isApisMicroservicesCert,
|
||||||
|
isInfosecQaCert,
|
||||||
|
isQaCertV7,
|
||||||
|
isInfosecCertV7,
|
||||||
|
isFrontEndCert,
|
||||||
|
isBackEndCert,
|
||||||
|
isDataVisCert,
|
||||||
|
isFullStackCert,
|
||||||
|
isSciCompPyCertV7,
|
||||||
|
isDataAnalysisPyCertV7,
|
||||||
|
isMachineLearningPyCertV7,
|
||||||
|
isRelationalDatabaseCertV8,
|
||||||
|
isCollegeAlgebraPyCertV8,
|
||||||
|
isFoundationalCSharpCertV8,
|
||||||
|
isUpcomingPythonCertV8
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,27 @@ const generic500 = Type.Object({
|
|||||||
type: Type.Literal('danger')
|
type: Type.Literal('danger')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isCertMap = Type.Object({
|
||||||
|
isRespWebDesignCert: Type.Boolean(),
|
||||||
|
isJsAlgoDataStructCert: Type.Boolean(),
|
||||||
|
isFrontEndLibsCert: Type.Boolean(),
|
||||||
|
is2018DataVisCert: Type.Boolean(),
|
||||||
|
isApisMicroservicesCert: Type.Boolean(),
|
||||||
|
isInfosecQaCert: Type.Boolean(),
|
||||||
|
isQaCertV7: Type.Boolean(),
|
||||||
|
isInfosecCertV7: Type.Boolean(),
|
||||||
|
isFrontEndCert: Type.Boolean(),
|
||||||
|
isBackEndCert: Type.Boolean(),
|
||||||
|
isDataVisCert: Type.Boolean(),
|
||||||
|
isFullStackCert: Type.Boolean(),
|
||||||
|
isSciCompPyCertV7: Type.Boolean(),
|
||||||
|
isDataAnalysisPyCertV7: Type.Boolean(),
|
||||||
|
isMachineLearningPyCertV7: Type.Boolean(),
|
||||||
|
isRelationalDatabaseCertV8: Type.Boolean(),
|
||||||
|
isCollegeAlgebraPyCertV8: Type.Boolean(),
|
||||||
|
isFoundationalCSharpCertV8: Type.Boolean()
|
||||||
|
});
|
||||||
|
|
||||||
const file = Type.Object({
|
const file = Type.Object({
|
||||||
contents: Type.String(),
|
contents: Type.String(),
|
||||||
key: Type.String(),
|
key: Type.String(),
|
||||||
@@ -760,6 +781,133 @@ export const schemas = {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// /certificate/
|
||||||
|
certificateVerify: {
|
||||||
|
// TODO(POST_MVP): Remove partial validation from route for schema validation
|
||||||
|
body: Type.Object({
|
||||||
|
certSlug: Type.String({ maxLength: 1024 })
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: Type.Object({
|
||||||
|
response: Type.Union([
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal('info'),
|
||||||
|
message: Type.Union([Type.Literal('flash.already-claimed')]),
|
||||||
|
variables: Type.Object({
|
||||||
|
name: Type.String()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
Type.Object({
|
||||||
|
type: Type.Literal('success'),
|
||||||
|
message: Type.Literal('flash.cert-claim-success'),
|
||||||
|
variables: Type.Object({
|
||||||
|
username: Type.String(),
|
||||||
|
name: Type.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
isCertMap,
|
||||||
|
completedChallenges: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
id: Type.String(),
|
||||||
|
completedDate: Type.Number(),
|
||||||
|
solution: Type.Optional(Type.String()),
|
||||||
|
githubLink: Type.Optional(Type.String()),
|
||||||
|
challengeType: Type.Optional(Type.Number()),
|
||||||
|
// Technically, files is optional, but the db default was [] and
|
||||||
|
// the client treats null, undefined and [] equivalently.
|
||||||
|
// TODO(Post-MVP): make this optional.
|
||||||
|
files: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
contents: Type.String(),
|
||||||
|
key: Type.String(),
|
||||||
|
ext: Type.String(),
|
||||||
|
name: Type.String(),
|
||||||
|
path: Type.Optional(Type.String())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
isManuallyApproved: Type.Optional(Type.Boolean())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
400: Type.Union([
|
||||||
|
Type.Object({
|
||||||
|
response: Type.Object({
|
||||||
|
type: Type.Literal('info'),
|
||||||
|
message: Type.Union([Type.Literal('flash.incomplete-steps')]),
|
||||||
|
variables: Type.Object({
|
||||||
|
name: Type.String()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
isCertMap,
|
||||||
|
completedChallenges: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
id: Type.String(),
|
||||||
|
completedDate: Type.Number(),
|
||||||
|
solution: Type.Optional(Type.String()),
|
||||||
|
githubLink: Type.Optional(Type.String()),
|
||||||
|
challengeType: Type.Optional(Type.Number()),
|
||||||
|
// Technically, files is optional, but the db default was [] and
|
||||||
|
// the client treats null, undefined and [] equivalently.
|
||||||
|
// TODO(Post-MVP): make this optional.
|
||||||
|
files: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
contents: Type.String(),
|
||||||
|
key: Type.String(),
|
||||||
|
ext: Type.String(),
|
||||||
|
name: Type.String(),
|
||||||
|
path: Type.Optional(Type.String())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
isManuallyApproved: Type.Optional(Type.Boolean())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
Type.Object({
|
||||||
|
response: Type.Object({
|
||||||
|
type: Type.Literal('danger'),
|
||||||
|
message: Type.Union([Type.Literal('flash.wrong-name')]),
|
||||||
|
variables: Type.Object({
|
||||||
|
name: Type.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
Type.Object({
|
||||||
|
response: Type.Object({
|
||||||
|
type: Type.Literal('info'),
|
||||||
|
message: Type.Union([Type.Literal('flash.name-needed')])
|
||||||
|
}),
|
||||||
|
isCertMap,
|
||||||
|
completedChallenges: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
id: Type.String(),
|
||||||
|
completedDate: Type.Number(),
|
||||||
|
solution: Type.Optional(Type.String()),
|
||||||
|
githubLink: Type.Optional(Type.String()),
|
||||||
|
challengeType: Type.Optional(Type.Number()),
|
||||||
|
// Technically, files is optional, but the db default was [] and
|
||||||
|
// the client treats null, undefined and [] equivalently.
|
||||||
|
// TODO(Post-MVP): make this optional.
|
||||||
|
files: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
contents: Type.String(),
|
||||||
|
key: Type.String(),
|
||||||
|
ext: Type.String(),
|
||||||
|
name: Type.String(),
|
||||||
|
path: Type.Optional(Type.String())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
isManuallyApproved: Type.Optional(Type.Boolean())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
500: Type.Object({
|
||||||
|
type: Type.Literal('danger'),
|
||||||
|
message: Type.Literal('flash.went-wrong')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
examChallengeCompleted: {
|
examChallengeCompleted: {
|
||||||
body: Type.Object({
|
body: Type.Object({
|
||||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function createUserInput(email: string): Prisma.userCreateInput {
|
|||||||
isDataAnalysisPyCertV7: false,
|
isDataAnalysisPyCertV7: false,
|
||||||
isDataVisCert: false,
|
isDataVisCert: false,
|
||||||
isDonating: false,
|
isDonating: false,
|
||||||
|
isFoundationalCSharpCertV8: false,
|
||||||
isFrontEndCert: false,
|
isFrontEndCert: false,
|
||||||
isFrontEndLibsCert: false,
|
isFrontEndLibsCert: false,
|
||||||
isFullStackCert: false,
|
isFullStackCert: false,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ assert.ok(process.env.FCC_ENABLE_SWAGGER_UI);
|
|||||||
assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
|
assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
|
||||||
assert.ok(process.env.JWT_SECRET);
|
assert.ok(process.env.JWT_SECRET);
|
||||||
assert.ok(process.env.STRIPE_SECRET_KEY);
|
assert.ok(process.env.STRIPE_SECRET_KEY);
|
||||||
|
assert.ok(process.env.SHOW_UPCOMING_CHANGES);
|
||||||
|
|
||||||
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||||
assert.ok(process.env.SES_ID);
|
assert.ok(process.env.SES_ID);
|
||||||
@@ -109,4 +110,6 @@ export const SES_ID = process.env.SES_ID;
|
|||||||
export const SES_SECRET = process.env.SES_SECRET;
|
export const SES_SECRET = process.env.SES_SECRET;
|
||||||
export const SES_REGION = process.env.SES_REGION;
|
export const SES_REGION = process.env.SES_REGION;
|
||||||
export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER;
|
export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER;
|
||||||
|
export const SHOW_UPCOMING_CHANGES =
|
||||||
|
process.env.SHOW_UPCOMING_CHANGES === 'true';
|
||||||
export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
// redirectToCurrentChallenge and, instead, only report the current challenge id
|
// redirectToCurrentChallenge and, instead, only report the current challenge id
|
||||||
// via the user object, then we should *not* store this so it can be garbage
|
// via the user object, then we should *not* store this so it can be garbage
|
||||||
// collected.
|
// collected.
|
||||||
import curriculum from '../../../shared/config/curriculum.json';
|
import { readFileSync } from 'fs';
|
||||||
import { SuperBlocks } from '../../../shared/config/superblocks';
|
import { join } from 'path';
|
||||||
|
|
||||||
type Curriculum = { [keyValue in SuperBlocks]?: CurriculumProps };
|
const CURRICULUM_PATH = '../shared/config/curriculum.json';
|
||||||
|
|
||||||
|
// Curriculum is read using fs, because it is too large for VSCode's LSP to handle type inference which causes anoying behaviour.
|
||||||
|
const curriculum = JSON.parse(
|
||||||
|
readFileSync(join(process.cwd(), CURRICULUM_PATH), 'utf-8')
|
||||||
|
) as Curriculum;
|
||||||
|
|
||||||
interface Block {
|
interface Block {
|
||||||
challenges: {
|
challenges: {
|
||||||
@@ -18,25 +23,30 @@ interface Block {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurriculumProps {
|
type SuperBlock = {
|
||||||
blocks: Record<string, Block>;
|
blocks: Record<string, Block>;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
type Curriculum = Record<string, SuperBlock>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the challenges from the curriculum.
|
* Get all challenges including all certifications as "challenges" (ids and tests).
|
||||||
* @returns An array of challenges.
|
* @returns The whole curricula reduced to an array.
|
||||||
*/
|
*/
|
||||||
export function getChallenges() {
|
export function getChallenges(): Block['challenges'] {
|
||||||
const superBlockKeys = Object.values(SuperBlocks);
|
const curricula = Object.values(curriculum);
|
||||||
const typedCurriculum: Curriculum = curriculum as Curriculum;
|
|
||||||
|
|
||||||
return superBlockKeys
|
return curricula
|
||||||
.map(key => typedCurriculum[key]?.blocks)
|
.map(v => v.blocks)
|
||||||
.reduce((accumulator: Block['challenges'], superBlock) => {
|
.reduce((acc: Block['challenges'], superBlock) => {
|
||||||
const blockKeys = Object.keys(superBlock ?? {});
|
const blockKeys = Object.keys(superBlock);
|
||||||
const challengesForBlock = blockKeys.map(
|
const challengesForBlock = blockKeys.map(k => {
|
||||||
key => superBlock?.[key]?.challenges ?? []
|
const block = superBlock[k];
|
||||||
);
|
if (!block) {
|
||||||
return [...accumulator, ...challengesForBlock.flat()];
|
return [];
|
||||||
|
}
|
||||||
|
return block.challenges;
|
||||||
|
});
|
||||||
|
return [...acc, ...challengesForBlock.flat()];
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+10
@@ -261,6 +261,9 @@ importers:
|
|||||||
stripe:
|
stripe:
|
||||||
specifier: 8.222.0
|
specifier: 8.222.0
|
||||||
version: 8.222.0
|
version: 8.222.0
|
||||||
|
validator:
|
||||||
|
specifier: 13.11.0
|
||||||
|
version: 13.11.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@total-typescript/ts-reset':
|
'@total-typescript/ts-reset':
|
||||||
specifier: 0.5.1
|
specifier: 0.5.1
|
||||||
@@ -277,6 +280,9 @@ importers:
|
|||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: 2.0.16
|
specifier: 2.0.16
|
||||||
version: 2.0.16
|
version: 2.0.16
|
||||||
|
'@types/validator':
|
||||||
|
specifier: 13.11.2
|
||||||
|
version: 13.11.2
|
||||||
dotenv-cli:
|
dotenv-cli:
|
||||||
specifier: 7.3.0
|
specifier: 7.3.0
|
||||||
version: 7.3.0
|
version: 7.3.0
|
||||||
@@ -10169,6 +10175,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/validator@13.11.2:
|
||||||
|
resolution: {integrity: sha512-nIKVVQKT6kGKysnNt+xLobr+pFJNssJRi2s034wgWeFBUx01fI8BeHTW2TcRp7VcFu9QCYG8IlChTuovcm0oKQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/validator@13.7.12:
|
/@types/validator@13.7.12:
|
||||||
resolution: {integrity: sha512-YVtyAPqpefU+Mm/qqnOANW6IkqKpCSrarcyV269C8MA8Ux0dbkEuQwM/4CjL47kVEM2LgBef/ETfkH+c6+moFA==}
|
resolution: {integrity: sha512-YVtyAPqpefU+Mm/qqnOANW6IkqKpCSrarcyV269C8MA8Ux0dbkEuQwM/4CjL47kVEM2LgBef/ETfkH+c6+moFA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
Reference in New Issue
Block a user