Skip to content

Commit 690dc5a

Browse files
authored
Merge pull request #455 from davidishere1/feat/issues-328-329-330-331
quizzes: add question feedback endpoints (#331) + fix a broken build
2 parents 997a17c + d284666 commit 690dc5a

9 files changed

Lines changed: 478 additions & 7 deletions

File tree

‎src/audit/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ type AuditEvent =
3232
| "course.module.created"
3333
| "course.module.updated"
3434
| "course.module.deleted"
35+
| "quiz.feedback.submitted"
3536
| "announcement.created"
3637
| "announcement.updated"
37-
| "announcement.deleted";
38+
| "announcement.deleted"
3839
| "webhook.created"
3940
| "webhook.updated"
4041
| "webhook.deleted"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Quiz feedback: users can flag a specific question as unclear, wrong, or
2+
-- other. One feedback submission per user per (quiz, question) — a second
3+
-- submission is rejected rather than silently overwriting the first.
4+
CREATE TABLE IF NOT EXISTS "quiz_feedback" (
5+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6+
"quiz_id" uuid NOT NULL REFERENCES "quizzes"("id") ON DELETE CASCADE,
7+
"question_id" varchar(100) NOT NULL,
8+
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
9+
"type" varchar(20) NOT NULL,
10+
"comment" text,
11+
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
12+
CONSTRAINT "chk_quiz_feedback_type" CHECK ("type" IN ('unclear', 'wrong', 'other'))
13+
);
14+
15+
CREATE UNIQUE INDEX IF NOT EXISTS "idx_quiz_feedback_unique"
16+
ON "quiz_feedback" ("quiz_id", "question_id", "user_id");
17+
CREATE INDEX IF NOT EXISTS "idx_quiz_feedback_quiz_question"
18+
ON "quiz_feedback" ("quiz_id", "question_id");

‎src/database/schema.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,44 @@ export const quizSubmissions = pgTable(
211211
]
212212
);
213213

214+
// ─── Quiz Feedback ──────────────────────────────────────────────────────────
215+
216+
export const quizFeedback = pgTable(
217+
"quiz_feedback",
218+
{
219+
id: uuid("id").primaryKey().defaultRandom(),
220+
quizId: uuid("quiz_id")
221+
.notNull()
222+
.references(() => quizzes.id, { onDelete: "cascade" }),
223+
questionId: varchar("question_id", { length: 100 }).notNull(),
224+
userId: uuid("user_id")
225+
.notNull()
226+
.references(() => users.id, { onDelete: "cascade" }),
227+
type: varchar("type", { length: 20 }).notNull(),
228+
comment: text("comment"),
229+
createdAt: timestamp("created_at", { withTimezone: true })
230+
.notNull()
231+
.defaultNow(),
232+
},
233+
(table) => [
234+
// One feedback submission per (quiz, question, user) — a second
235+
// submission is rejected rather than silently overwriting the first.
236+
uniqueIndex("idx_quiz_feedback_unique").on(
237+
table.quizId,
238+
table.questionId,
239+
table.userId
240+
),
241+
index("idx_quiz_feedback_quiz_question").on(
242+
table.quizId,
243+
table.questionId
244+
),
245+
check(
246+
"chk_quiz_feedback_type",
247+
sql`type IN ('unclear', 'wrong', 'other')`
248+
),
249+
]
250+
);
251+
214252
// ─── Credentials (NFT Certificates) ────────────────────────────────────────
215253

216254
export const credentials = pgTable(

‎src/modules/courses/course.service.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,8 +1609,6 @@ export class CourseService {
16091609
? { ...data, prerequisites: data.prerequisites.filter((id) => id !== courseId) }
16101610
: data;
16111611

1612-
const [updated] = await db
1613-
): Promise<AdminCourse> {
16141612
const [course] = await db
16151613
.update(courses)
16161614
.set(sanitized)

‎src/modules/quizzes/quiz.controller.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
SubmitQuizBody,
88
QuizIdParams,
99
QuizStatsQuery,
10+
SubmitQuizFeedbackBody,
11+
QuizFeedbackSummaryQuery,
1012
} from "./quiz.types.js";
1113

1214
export class QuizController {
@@ -85,6 +87,37 @@ export class QuizController {
8587

8688
reply.send({ success: true, data: stats });
8789
}
90+
91+
/**
92+
* POST /api/v1/quizzes/:id/feedback
93+
* Submit feedback on a specific quiz question.
94+
*/
95+
async submitFeedback(
96+
request: FastifyRequest<{ Params: QuizIdParams; Body: SubmitQuizFeedbackBody }>,
97+
reply: FastifyReply
98+
): Promise<void> {
99+
const { authUser } = request as AuthenticatedRequest;
100+
const { id } = request.params;
101+
const data = request.body;
102+
const feedback = await quizService.submitFeedback(authUser.id, id, data);
103+
104+
reply.status(201).send({ success: true, data: feedback });
105+
}
106+
107+
/**
108+
* GET /api/v1/quizzes/:id/feedback/summary
109+
* Per-question feedback counts for a quiz (admin only).
110+
*/
111+
async feedbackSummary(
112+
request: FastifyRequest<{ Params: QuizIdParams; Querystring: QuizFeedbackSummaryQuery }>,
113+
reply: FastifyReply
114+
): Promise<void> {
115+
const { id } = request.params;
116+
const { questionId } = request.query;
117+
const summary = await quizService.getFeedbackSummary(id, questionId);
118+
119+
reply.send({ success: true, data: summary });
120+
}
88121
}
89122

90123
export const quizController = new QuizController();

‎src/modules/quizzes/quiz.routes.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { FastifyInstance, FastifySchema } from "fastify";
22
import { quizController } from "./quiz.controller.js";
3-
import { authGuard } from "../../middleware/auth.js";
3+
import { authGuard, adminGuard } from "../../middleware/auth.js";
44
import { validate } from "../../middleware/validation.js";
55
import { quizBatchGenerationRateLimit } from "../../middleware/rate-limit.js";
66
import { config } from "../../config/index.js";
@@ -10,6 +10,8 @@ import {
1010
submitQuizSchema,
1111
quizIdParamsSchema,
1212
quizStatsQuerySchema,
13+
submitQuizFeedbackSchema,
14+
quizFeedbackSummaryQuerySchema,
1315
MAX_BATCH_GENERATE_MODULES,
1416
} from "./quiz.types.js";
1517

@@ -149,4 +151,50 @@ export async function quizRoutes(app: FastifyInstance): Promise<void> {
149151
},
150152
(request, reply) => quizController.retry(request, reply)
151153
);
154+
155+
app.post<{ Params: { id: string }, Body: import("./quiz.types.js").SubmitQuizFeedbackBody }>(
156+
"/:id/feedback",
157+
{
158+
preHandler: [
159+
validate({ params: quizIdParamsSchema, body: submitQuizFeedbackSchema }),
160+
],
161+
schema: {
162+
description: "Submit feedback on a specific quiz question (#331)",
163+
tags: ["quizzes"],
164+
security: [{ bearerAuth: [] }],
165+
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
166+
body: {
167+
type: "object",
168+
required: ["questionId", "type"],
169+
properties: {
170+
questionId: { type: "string", minLength: 1, maxLength: 100 },
171+
type: { type: "string", enum: ["unclear", "wrong", "other"] },
172+
comment: { type: "string", maxLength: 2000 },
173+
},
174+
},
175+
} as FastifySchema,
176+
},
177+
(request, reply) => quizController.submitFeedback(request, reply)
178+
);
179+
180+
app.get<{ Params: { id: string }, Querystring: import("./quiz.types.js").QuizFeedbackSummaryQuery }>(
181+
"/:id/feedback/summary",
182+
{
183+
preHandler: [
184+
adminGuard,
185+
validate({ params: quizIdParamsSchema, querystring: quizFeedbackSummaryQuerySchema }),
186+
],
187+
schema: {
188+
description: "Per-question feedback counts for a quiz (admin only) (#331)",
189+
tags: ["quizzes", "admin"],
190+
security: [{ bearerAuth: [] }],
191+
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
192+
querystring: {
193+
type: "object",
194+
properties: { questionId: { type: "string", minLength: 1, maxLength: 100 } },
195+
},
196+
} as FastifySchema,
197+
},
198+
(request, reply) => quizController.feedbackSummary(request, reply)
199+
);
152200
}

‎src/modules/quizzes/quiz.service.ts‎

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import crypto from "node:crypto";
22
import { eq, and } from "drizzle-orm";
33
import { db } from "../../config/database.js";
4-
import { quizzes, quizSubmissions, enrollments } from "../../database/schema.js";
4+
import { quizzes, quizSubmissions, quizFeedback, enrollments } from "../../database/schema.js";
55
import {
66
NotFoundError,
77
ForbiddenError,
@@ -22,8 +22,6 @@ import {
2222
cacheSet,
2323
cacheDel,
2424
cacheKey,
25-
cacheGet,
26-
cacheSet,
2725
cacheKeyPattern,
2826
cacheInvalidatePattern,
2927
} from "../../cache/index.js";
@@ -39,6 +37,9 @@ import {
3937
type QuizSubmissionResult,
4038
type QuizQuestion,
4139
type QuizStats,
40+
type SubmitQuizFeedbackBody,
41+
type QuizFeedbackEntry,
42+
type QuizFeedbackSummaryEntry,
4243
} from "./quiz.types.js";
4344

4445
const QUIZ_STATS_TTL_SECONDS = 300;
@@ -733,6 +734,117 @@ export class QuizService {
733734
return stats;
734735
}
735736

737+
/**
738+
* Submit feedback on a specific quiz question (#331): "this question is
739+
* unclear", "wrong answer marked as correct", or something else.
740+
*
741+
* One submission per (quiz, question, user) — a second attempt is
742+
* rejected with a ConflictError rather than overwriting the first, so a
743+
* question's feedback count reflects distinct reporters.
744+
*/
745+
async submitFeedback(
746+
userId: string,
747+
quizId: string,
748+
data: SubmitQuizFeedbackBody,
749+
): Promise<QuizFeedbackEntry> {
750+
const quiz = await db.query.quizzes.findFirst({
751+
where: eq(quizzes.id, quizId),
752+
});
753+
if (!quiz) {
754+
throw new NotFoundError("Quiz");
755+
}
756+
757+
const questions = (quiz.questions ?? []) as StoredQuestion[];
758+
if (!questions.some((q) => q.id === data.questionId)) {
759+
throw new NotFoundError("Question");
760+
}
761+
762+
const existing = await db.query.quizFeedback.findFirst({
763+
where: and(
764+
eq(quizFeedback.quizId, quizId),
765+
eq(quizFeedback.questionId, data.questionId),
766+
eq(quizFeedback.userId, userId),
767+
),
768+
});
769+
if (existing) {
770+
throw new ConflictError("Feedback already submitted for this question");
771+
}
772+
773+
try {
774+
const [row] = await db
775+
.insert(quizFeedback)
776+
.values({
777+
quizId,
778+
questionId: data.questionId,
779+
userId,
780+
type: data.type,
781+
comment: data.comment ?? null,
782+
})
783+
.returning();
784+
785+
await auditLog("quiz.feedback.submitted", {
786+
userId,
787+
courseId: quiz.courseId,
788+
});
789+
790+
return row as QuizFeedbackEntry;
791+
} catch (err) {
792+
const code = (err as { code?: string }).code;
793+
// 23505 = unique_violation — a concurrent submission for the same
794+
// (quiz, question, user) beat this one to the pre-check above.
795+
if (code === "23505") {
796+
throw new ConflictError("Feedback already submitted for this question");
797+
}
798+
throw err;
799+
}
800+
}
801+
802+
/**
803+
* Per-question feedback counts for a quiz, for admins reviewing which
804+
* questions need work (#331).
805+
*/
806+
async getFeedbackSummary(
807+
quizId: string,
808+
questionId?: string,
809+
): Promise<QuizFeedbackSummaryEntry[]> {
810+
const quiz = await db.query.quizzes.findFirst({
811+
where: eq(quizzes.id, quizId),
812+
});
813+
if (!quiz) {
814+
throw new NotFoundError("Quiz");
815+
}
816+
817+
const conditions = [eq(quizFeedback.quizId, quizId)];
818+
if (questionId) {
819+
conditions.push(eq(quizFeedback.questionId, questionId));
820+
}
821+
822+
const rows = await db
823+
.select({
824+
questionId: quizFeedback.questionId,
825+
type: quizFeedback.type,
826+
})
827+
.from(quizFeedback)
828+
.where(and(...conditions));
829+
830+
const byQuestion = new Map<string, QuizFeedbackSummaryEntry>();
831+
for (const row of rows) {
832+
let entry = byQuestion.get(row.questionId);
833+
if (!entry) {
834+
entry = {
835+
questionId: row.questionId,
836+
total: 0,
837+
counts: { unclear: 0, wrong: 0, other: 0 },
838+
};
839+
byQuestion.set(row.questionId, entry);
840+
}
841+
entry.total++;
842+
entry.counts[row.type as QuizFeedbackEntry["type"]]++;
843+
}
844+
845+
return Array.from(byQuestion.values());
846+
}
847+
736848
private createPlaceholderQuestions(
737849
courseId: string,
738850
moduleId: string

‎src/modules/quizzes/quiz.types.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,27 @@ export const quizStatsQuerySchema = z.object({
6767
courseId: z.string().uuid("Invalid course ID").optional(),
6868
});
6969

70+
export const QUIZ_FEEDBACK_TYPES = ["unclear", "wrong", "other"] as const;
71+
72+
export const submitQuizFeedbackSchema = z.object({
73+
questionId: z.string().min(1).max(100),
74+
type: z.enum(QUIZ_FEEDBACK_TYPES),
75+
comment: z.string().max(2000).optional(),
76+
});
77+
78+
export const quizFeedbackSummaryQuerySchema = z.object({
79+
questionId: z.string().min(1).max(100).optional(),
80+
});
81+
7082
// ─── Types ──────────────────────────────────────────────────────────────────
7183

7284
export type GenerateQuizBody = z.infer<typeof generateQuizSchema>;
7385
export type GenerateQuizBatchBody = z.infer<typeof generateQuizBatchSchema>;
7486
export type SubmitQuizBody = z.infer<typeof submitQuizSchema>;
7587
export type QuizIdParams = z.infer<typeof quizIdParamsSchema>;
7688
export type QuizStatsQuery = z.infer<typeof quizStatsQuerySchema>;
89+
export type SubmitQuizFeedbackBody = z.infer<typeof submitQuizFeedbackSchema>;
90+
export type QuizFeedbackSummaryQuery = z.infer<typeof quizFeedbackSummaryQuerySchema>;
7791

7892
export interface QuizQuestion {
7993
id: string;
@@ -115,3 +129,19 @@ export interface QuizStats {
115129
totalSubmissions: number;
116130
submissionsPerCourse: Record<string, number>;
117131
}
132+
133+
export interface QuizFeedbackEntry {
134+
id: string;
135+
questionId: string;
136+
userId: string;
137+
type: (typeof QUIZ_FEEDBACK_TYPES)[number];
138+
comment: string | null;
139+
createdAt: Date;
140+
}
141+
142+
/** Per-question feedback counts, for admins reviewing which questions need work. */
143+
export interface QuizFeedbackSummaryEntry {
144+
questionId: string;
145+
total: number;
146+
counts: Record<(typeof QUIZ_FEEDBACK_TYPES)[number], number>;
147+
}

0 commit comments

Comments
 (0)