diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 34bbbb34393..1d1a8be1d9f 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -19,7 +19,7 @@ type File { } type CompletedChallenge { - challengeType Int? @db.Int // Null | Undefined + challengeType Int? @db.Int // Null | Undefined completedDate Float // TODO(Post-MVP): Change to DateTime files File[] githubLink String? // Undefined @@ -220,18 +220,18 @@ model Exam { type CompletedExam { id String - challengeType Int - completedDate Float // TODO(Post-MVP): Change to DateTime? - examResults ExamResults + challengeType Int + completedDate Float // TODO(Post-MVP): Change to DateTime? + examResults ExamResults } type ExamResults { - numberOfCorrectAnswers Int - numberOfQuestionsInExam Int - percentCorrect Float - passingPercent Int - passed Boolean - examTimeInSeconds Int + numberOfCorrectAnswers Int + numberOfQuestionsInExam Int + percentCorrect Float + passingPercent Int + passed Boolean + examTimeInSeconds Int } type Question { @@ -262,3 +262,17 @@ type DonationStartDate { date DateTime @map("_date") @db.Date when String @map("_when") } + +model Survey { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + title String + responses SurveyResponse[] + + @@index([userId], map: "userId_1") +} + +type SurveyResponse { + question String + response String +} diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index d691ef61f73..63786cd3d16 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -177,6 +177,7 @@ const publicUserData = { } ], completedExams: testUserData.completedExams, + completedSurveys: [], githubProfile: testUserData.githubProfile, isApisMicroservicesCert: testUserData.isApisMicroservicesCert, isBackEndCert: testUserData.isBackEndCert, @@ -273,6 +274,21 @@ const tokenData = [ { created: new Date(), id: '789', ttl: 1000, userId: otherUserId } ]; +const mockSurveyResults = { + title: 'Foundational C# with Microsoft Survey', + responses: [ + { + question: 'Please describe your role:', + response: 'Beginner developer (less than 2 years experience)' + }, + { + question: + 'Prior to this course, how experienced were you with .NET and C#?', + response: 'Novice (no prior experience)' + } + ] +}; + describe('userRoutes', () => { setupServer(); @@ -593,6 +609,7 @@ describe('userRoutes', () => { // missing in the user document. completedChallenges: [], completedExams: [], + completedSurveys: [], partiallyCompletedChallenges: [], portfolio: [], savedChallenges: [], @@ -960,6 +977,56 @@ Thanks and regards, }); }); }); + + describe('/user/submit-survey', () => { + afterEach(async () => { + await fastifyTestInstance.prisma.survey.deleteMany({ + where: { userId: defaultUserId } + }); + }); + + test('POST returns 400 for invalid survey title', async () => { + const response = await superPost('/user/submit-survey').send({ + surveyResults: { ...mockSurveyResults, title: 'Invalid Survey' } + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toStrictEqual({ + type: 'error', + message: 'flash.survey.err-1' + }); + }); + + test('POST returns 400 if user already submitted survey', async () => { + // Submit survey for first time + await superPost('/user/submit-survey').send({ + surveyResults: mockSurveyResults + }); + + // Submit same survey again to get failed response + const response = await superPost('/user/submit-survey').send({ + surveyResults: mockSurveyResults + }); + + expect(response.statusCode).toBe(400); + expect(response.body).toStrictEqual({ + type: 'error', + message: 'flash.survey.err-2' + }); + }); + + test('POST returns 200 status code with "success" message', async () => { + const response = await superPost('/user/submit-survey').send({ + surveyResults: mockSurveyResults + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toStrictEqual({ + type: 'success', + message: 'flash.survey.success' + }); + }); + }); }); describe('Unauthenticated user', () => { @@ -978,7 +1045,8 @@ Thanks and regards, { path: '/user/user-token', method: 'POST' }, { path: '/user/ms-username', method: 'DELETE' }, { path: '/user/report-user', method: 'POST' }, - { path: '/user/ms-username', method: 'POST' } + { path: '/user/ms-username', method: 'POST' }, + { path: '/user/submit-survey', method: 'POST' } ]; endpoints.forEach(({ path, method }) => { diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index 2f8be5219d2..ca2214b7908 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -7,7 +7,8 @@ import { normalizeChallenges, normalizeProfileUI, normalizeTwitter, - removeNulls + removeNulls, + normalizeSurveys } from '../utils/normalize'; import { getCalendar, @@ -74,6 +75,9 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( await fastify.prisma.msUsername.deleteMany({ where: { userId: req.session.user.id } }); + await fastify.prisma.survey.deleteMany({ + where: { userId: req.session.user.id } + }); await fastify.prisma.user.delete({ where: { id: req.session.user.id } }); @@ -106,6 +110,9 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( await fastify.prisma.msUsername.deleteMany({ where: { userId: req.session.user.id } }); + await fastify.prisma.survey.deleteMany({ + where: { userId: req.session.user.id } + }); await fastify.prisma.user.update({ where: { id: req.session.user.id }, data: { @@ -362,6 +369,67 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.post( + '/user/submit-survey', + { + schema: schemas.submitSurvey, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400).send({ + type: 'error', + message: 'flash.survey.err-1' + }); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + try { + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: req.session.user.id } + }); + const { surveyResults } = req.body; + const { title } = surveyResults; + + const completedSurveys = await fastify.prisma.survey.findMany({ + where: { userId: user.id } + }); + + const surveyAlreadyTaken = completedSurveys.some( + s => s.title === title + ); + if (surveyAlreadyTaken) { + return reply.code(400).send({ + type: 'error', + message: 'flash.survey.err-2' + }); + } + + const newSurvey = { + ...surveyResults, + userId: user.id + }; + + await fastify.prisma.survey.create({ + data: newSurvey + }); + + return { + type: 'success', + message: 'flash.survey.success' + } as const; + } catch (err) { + fastify.log.error(err); + void reply.code(500); + return { + type: 'error', + message: 'flash.survey.err-3' + } as const; + } + } + ); + done(); }; @@ -443,7 +511,15 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( } }); - const [userToken, user] = await Promise.all([userTokenP, userP]); + const completedSurveysP = fastify.prisma.survey.findMany({ + where: { userId: req.session.user.id } + }); + + const [userToken, user, completedSurveys] = await Promise.all([ + userTokenP, + userP, + completedSurveysP + ]); if (!user?.username) { void res.code(500); @@ -484,7 +560,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( joinDate: new ObjectId(user.id).getTimestamp().toISOString(), twitter: normalizeTwitter(twitter), username: usernameDisplay || username, - userToken: encodedToken + userToken: encodedToken, + completedSurveys: normalizeSurveys(completedSurveys) } }, result: user.username diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 6f3ec12021a..827dbacc6e2 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -54,6 +54,10 @@ const examResults = Type.Object({ examTimeInSeconds: Type.Number() }); +const surveyTitles = Type.Union([ + Type.Literal('Foundational C# with Microsoft Survey') +]); + export const schemas = { // Settings: updateMyProfileUI: { @@ -421,7 +425,18 @@ export const schemas = { ) ), username: Type.String(), - userToken: Type.Optional(Type.String()) + userToken: Type.Optional(Type.String()), + completedSurveys: Type.Array( + Type.Object({ + title: Type.String(), + responses: Type.Array( + Type.Object({ + question: Type.String(), + response: Type.String() + }) + ) + }) + ) }) ), result: Type.String() @@ -781,6 +796,39 @@ export const schemas = { ]) } }, + submitSurvey: { + body: Type.Object({ + surveyResults: Type.Object({ + title: surveyTitles, + responses: Type.Array( + Type.Object({ + question: Type.String(), + response: Type.String() + }) + ) + }) + }), + response: { + 200: Type.Object({ + type: Type.Literal('success'), + message: Type.Literal('flash.survey.success') + }), + 400: Type.Union([ + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.survey.err-1') + }), + Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.survey.err-2') + }) + ]), + 500: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('flash.survey.err-3') + }) + } + }, // /certificate/ certificateVerify: { // TODO(POST_MVP): Remove partial validation from route for schema validation diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts index ae61830993e..30d34667654 100644 --- a/api/src/utils/normalize.ts +++ b/api/src/utils/normalize.ts @@ -1,6 +1,11 @@ /* This module's job is to parse the database output and prepare it for serialization */ -import { ProfileUI, CompletedChallenge, ExamResults } from '@prisma/client'; +import { + ProfileUI, + CompletedChallenge, + ExamResults, + type Survey +} from '@prisma/client'; import _ from 'lodash'; type NullToUndefined = T extends null ? undefined : T; @@ -106,3 +111,26 @@ export const normalizeChallenges = ( return noNullPath; }; + +type NormalizedSurvey = { + title: string; + responses: { + question: string; + response: string; + }[]; +}; + +/** + * Remove the extra properties from the SurveyResults array. + * + * @param surveyResults The SurveyResults array. + * @returns The input without the id and userid. + */ +export const normalizeSurveys = ( + surveyResults: Survey[] +): NormalizedSurvey[] => { + return surveyResults.map(survey => { + const { title, responses } = survey; + return { title, responses }; + }); +};