mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-09-24 23:01:18 +08:00
feat(api): submit survey endpoint (#53934)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
+24
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
+80
-3
@@ -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
|
||||
|
||||
+49
-1
@@ -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
|
||||
|
||||
@@ -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> = 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 };
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user