feat(api): submit survey endpoint (#53934)

Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Niraj Nandish
2024-03-11 10:56:34 +01:00
committed by GitHub
co-authored by Tom
parent 0225713b2a
commit 0577deb64f
5 changed files with 251 additions and 16 deletions
+24 -10
View File
@@ -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
}
+69 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29 -1
View File
@@ -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 };
});
};