diff --git a/api/package.json b/api/package.json index 25c70c9bde7..785ac48c13d 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 33e5f155f1b..34bbbb34393 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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 diff --git a/api/src/app.ts b/api/src/app.ts index 77e9d706de0..991a069a5b6 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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); diff --git a/api/src/routes/certificate.test.ts b/api/src/routes/certificate.test.ts new file mode 100644 index 00000000000..759f875792b --- /dev/null +++ b/api/src/routes/certificate.test.ts @@ -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 + ); + 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); + } + }); + }); + }); +}); diff --git a/api/src/routes/certificate.ts b/api/src/routes/certificate.ts new file mode 100644 index 00000000000..c1c125b9fbc --- /dev/null +++ b/api/src/routes/certificate.ts @@ -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) { + 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 +): { 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[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 + }; +} diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 1ac10dc67a9..6f3ec12021a 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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 }), diff --git a/api/src/utils/create-user.ts b/api/src/utils/create-user.ts index 10ab199ce36..e4bea66a024 100644 --- a/api/src/utils/create-user.ts +++ b/api/src/utils/create-user.ts @@ -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, diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index d1fe7eb2e00..b971a182c43 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -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; diff --git a/api/src/utils/get-challenges.ts b/api/src/utils/get-challenges.ts index f27672b56fd..8eb2457167c 100644 --- a/api/src/utils/get-challenges.ts +++ b/api/src/utils/get-challenges.ts @@ -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; -} +}; + +type Curriculum = Record; /** - * 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()]; }, []); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6481f30606..d600858a73f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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