mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-16 16:00:45 -04:00
feat(api): add PUT /certificate/verify (#51507)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -34,7 +34,8 @@
|
||||
"pino-pretty": "10.2.3",
|
||||
"query-string": "7.1.3",
|
||||
"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",
|
||||
"devDependencies": {
|
||||
@@ -43,6 +44,7 @@
|
||||
"@types/jsonwebtoken": "9.0.5",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/supertest": "2.0.16",
|
||||
"@types/validator": "13.11.2",
|
||||
"dotenv-cli": "7.3.0",
|
||||
"jest": "29.7.0",
|
||||
"prisma": "5.5.2",
|
||||
|
||||
@@ -97,6 +97,7 @@ model user {
|
||||
isDataAnalysisPyCertV7 Boolean? // Undefined
|
||||
isDataVisCert Boolean? // Undefined
|
||||
isDonating Boolean
|
||||
isFoundationalCSharpCertV8 Boolean? // Undefined
|
||||
isFrontEndCert Boolean? // Undefined
|
||||
isFrontEndLibsCert Boolean? // Undefined
|
||||
isFullStackCert Boolean? // Undefined
|
||||
@@ -104,6 +105,7 @@ model user {
|
||||
isInfosecCertV7 Boolean? // Undefined
|
||||
isInfosecQaCert Boolean? // Undefined
|
||||
isJsAlgoDataStructCert Boolean? // Undefined
|
||||
isJsAlgoDataStructCertV8 Boolean? // Undefined
|
||||
isMachineLearningPyCertV7 Boolean? // Undefined
|
||||
isQaCertV7 Boolean? // Undefined
|
||||
isRelationalDatabaseCertV8 Boolean? // Undefined
|
||||
@@ -112,6 +114,7 @@ model user {
|
||||
is2018DataVisCert Boolean? // Undefined
|
||||
is2018FullStackCert Boolean? // Undefined
|
||||
isCollegeAlgebraPyCertV8 Boolean? // Undefined
|
||||
isUpcomingPythonCertV8 Boolean? // Undefined
|
||||
keyboardShortcuts Boolean? // Undefined
|
||||
linkedin String? // Null | Undefined
|
||||
location String? // Null
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
SESSION_SECRET
|
||||
} from './utils/env';
|
||||
import { isObjectID } from './utils/validation';
|
||||
import { certificateRoutes } from './routes/certificate';
|
||||
|
||||
export type FastifyInstanceWithTypeProvider = FastifyInstance<
|
||||
RawServerDefault,
|
||||
@@ -196,6 +197,7 @@ export const build = async (
|
||||
void fastify.register(devLoginCallback, { prefix: '/auth' });
|
||||
void fastify.register(devLegacyAuthRoutes);
|
||||
}
|
||||
void fastify.register(certificateRoutes);
|
||||
void fastify.register(challengeRoutes);
|
||||
void fastify.register(settingRoutes);
|
||||
void fastify.register(donateRoutes);
|
||||
|
||||
435
api/src/routes/certificate.test.ts
Normal file
435
api/src/routes/certificate.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
469
api/src/routes/certificate.ts
Normal file
469
api/src/routes/certificate.ts
Normal file
@@ -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')
|
||||
});
|
||||
|
||||
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({
|
||||
contents: 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: {
|
||||
body: Type.Object({
|
||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createUserInput(email: string): Prisma.userCreateInput {
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isDonating: false,
|
||||
isFoundationalCSharpCertV8: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: 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.JWT_SECRET);
|
||||
assert.ok(process.env.STRIPE_SECRET_KEY);
|
||||
assert.ok(process.env.SHOW_UPCOMING_CHANGES);
|
||||
|
||||
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
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_REGION = process.env.SES_REGION;
|
||||
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;
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
// redirectToCurrentChallenge and, instead, only report the current challenge id
|
||||
// via the user object, then we should *not* store this so it can be garbage
|
||||
// collected.
|
||||
import curriculum from '../../../shared/config/curriculum.json';
|
||||
import { SuperBlocks } from '../../../shared/config/superblocks';
|
||||
import { readFileSync } from 'fs';
|
||||
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 {
|
||||
challenges: {
|
||||
@@ -18,25 +23,30 @@ interface Block {
|
||||
}[];
|
||||
}
|
||||
|
||||
interface CurriculumProps {
|
||||
type SuperBlock = {
|
||||
blocks: Record<string, Block>;
|
||||
}
|
||||
};
|
||||
|
||||
type Curriculum = Record<string, SuperBlock>;
|
||||
|
||||
/**
|
||||
* Get all the challenges from the curriculum.
|
||||
* @returns An array of challenges.
|
||||
* Get all challenges including all certifications as "challenges" (ids and tests).
|
||||
* @returns The whole curricula reduced to an array.
|
||||
*/
|
||||
export function getChallenges() {
|
||||
const superBlockKeys = Object.values(SuperBlocks);
|
||||
const typedCurriculum: Curriculum = curriculum as Curriculum;
|
||||
export function getChallenges(): Block['challenges'] {
|
||||
const curricula = Object.values(curriculum);
|
||||
|
||||
return superBlockKeys
|
||||
.map(key => typedCurriculum[key]?.blocks)
|
||||
.reduce((accumulator: Block['challenges'], superBlock) => {
|
||||
const blockKeys = Object.keys(superBlock ?? {});
|
||||
const challengesForBlock = blockKeys.map(
|
||||
key => superBlock?.[key]?.challenges ?? []
|
||||
);
|
||||
return [...accumulator, ...challengesForBlock.flat()];
|
||||
return curricula
|
||||
.map(v => v.blocks)
|
||||
.reduce((acc: Block['challenges'], superBlock) => {
|
||||
const blockKeys = Object.keys(superBlock);
|
||||
const challengesForBlock = blockKeys.map(k => {
|
||||
const block = superBlock[k];
|
||||
if (!block) {
|
||||
return [];
|
||||
}
|
||||
return block.challenges;
|
||||
});
|
||||
return [...acc, ...challengesForBlock.flat()];
|
||||
}, []);
|
||||
}
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -261,6 +261,9 @@ importers:
|
||||
stripe:
|
||||
specifier: 8.222.0
|
||||
version: 8.222.0
|
||||
validator:
|
||||
specifier: 13.11.0
|
||||
version: 13.11.0
|
||||
devDependencies:
|
||||
'@total-typescript/ts-reset':
|
||||
specifier: 0.5.1
|
||||
@@ -277,6 +280,9 @@ importers:
|
||||
'@types/supertest':
|
||||
specifier: 2.0.16
|
||||
version: 2.0.16
|
||||
'@types/validator':
|
||||
specifier: 13.11.2
|
||||
version: 13.11.2
|
||||
dotenv-cli:
|
||||
specifier: 7.3.0
|
||||
version: 7.3.0
|
||||
@@ -10169,6 +10175,10 @@ packages:
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
dev: true
|
||||
|
||||
/@types/validator@13.11.2:
|
||||
resolution: {integrity: sha512-nIKVVQKT6kGKysnNt+xLobr+pFJNssJRi2s034wgWeFBUx01fI8BeHTW2TcRp7VcFu9QCYG8IlChTuovcm0oKQ==}
|
||||
dev: true
|
||||
|
||||
/@types/validator@13.7.12:
|
||||
resolution: {integrity: sha512-YVtyAPqpefU+Mm/qqnOANW6IkqKpCSrarcyV269C8MA8Ux0dbkEuQwM/4CjL47kVEM2LgBef/ETfkH+c6+moFA==}
|
||||
dev: true
|
||||
|
||||
Reference in New Issue
Block a user