Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ type AuditEvent =
| "course.module.created"
| "course.module.updated"
| "course.module.deleted"
| "quiz.feedback.submitted"
| "announcement.created"
| "announcement.updated"
| "announcement.deleted";
| "announcement.deleted"
| "webhook.created"
| "webhook.updated"
| "webhook.deleted"
Expand Down
18 changes: 18 additions & 0 deletions src/database/migrations/0022_quiz_feedback.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Quiz feedback: users can flag a specific question as unclear, wrong, or
-- other. One feedback submission per user per (quiz, question) — a second
-- submission is rejected rather than silently overwriting the first.
CREATE TABLE IF NOT EXISTS "quiz_feedback" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"quiz_id" uuid NOT NULL REFERENCES "quizzes"("id") ON DELETE CASCADE,
"question_id" varchar(100) NOT NULL,
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"type" varchar(20) NOT NULL,
"comment" text,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "chk_quiz_feedback_type" CHECK ("type" IN ('unclear', 'wrong', 'other'))
);

CREATE UNIQUE INDEX IF NOT EXISTS "idx_quiz_feedback_unique"
ON "quiz_feedback" ("quiz_id", "question_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_quiz_feedback_quiz_question"
ON "quiz_feedback" ("quiz_id", "question_id");
38 changes: 38 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,44 @@ export const quizSubmissions = pgTable(
]
);

// ─── Quiz Feedback ──────────────────────────────────────────────────────────

export const quizFeedback = pgTable(
"quiz_feedback",
{
id: uuid("id").primaryKey().defaultRandom(),
quizId: uuid("quiz_id")
.notNull()
.references(() => quizzes.id, { onDelete: "cascade" }),
questionId: varchar("question_id", { length: 100 }).notNull(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 20 }).notNull(),
comment: text("comment"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
// One feedback submission per (quiz, question, user) — a second
// submission is rejected rather than silently overwriting the first.
uniqueIndex("idx_quiz_feedback_unique").on(
table.quizId,
table.questionId,
table.userId
),
index("idx_quiz_feedback_quiz_question").on(
table.quizId,
table.questionId
),
check(
"chk_quiz_feedback_type",
sql`type IN ('unclear', 'wrong', 'other')`
),
]
);

// ─── Credentials (NFT Certificates) ────────────────────────────────────────

export const credentials = pgTable(
Expand Down
2 changes: 0 additions & 2 deletions src/modules/courses/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1510,8 +1510,6 @@ export class CourseService {
? { ...data, prerequisites: data.prerequisites.filter((id) => id !== courseId) }
: data;

const [updated] = await db
): Promise<AdminCourse> {
const [course] = await db
.update(courses)
.set(sanitized)
Expand Down
33 changes: 33 additions & 0 deletions src/modules/quizzes/quiz.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
SubmitQuizBody,
QuizIdParams,
QuizStatsQuery,
SubmitQuizFeedbackBody,
QuizFeedbackSummaryQuery,
} from "./quiz.types.js";

export class QuizController {
Expand Down Expand Up @@ -85,6 +87,37 @@ export class QuizController {

reply.send({ success: true, data: stats });
}

/**
* POST /api/v1/quizzes/:id/feedback
* Submit feedback on a specific quiz question.
*/
async submitFeedback(
request: FastifyRequest<{ Params: QuizIdParams; Body: SubmitQuizFeedbackBody }>,
reply: FastifyReply
): Promise<void> {
const { authUser } = request as AuthenticatedRequest;
const { id } = request.params;
const data = request.body;
const feedback = await quizService.submitFeedback(authUser.id, id, data);

reply.status(201).send({ success: true, data: feedback });
}

/**
* GET /api/v1/quizzes/:id/feedback/summary
* Per-question feedback counts for a quiz (admin only).
*/
async feedbackSummary(
request: FastifyRequest<{ Params: QuizIdParams; Querystring: QuizFeedbackSummaryQuery }>,
reply: FastifyReply
): Promise<void> {
const { id } = request.params;
const { questionId } = request.query;
const summary = await quizService.getFeedbackSummary(id, questionId);

reply.send({ success: true, data: summary });
}
}

export const quizController = new QuizController();
50 changes: 49 additions & 1 deletion src/modules/quizzes/quiz.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifySchema } from "fastify";
import { quizController } from "./quiz.controller.js";
import { authGuard } from "../../middleware/auth.js";
import { authGuard, adminGuard } from "../../middleware/auth.js";
import { validate } from "../../middleware/validation.js";
import { quizBatchGenerationRateLimit } from "../../middleware/rate-limit.js";
import { config } from "../../config/index.js";
Expand All @@ -10,6 +10,8 @@ import {
submitQuizSchema,
quizIdParamsSchema,
quizStatsQuerySchema,
submitQuizFeedbackSchema,
quizFeedbackSummaryQuerySchema,
MAX_BATCH_GENERATE_MODULES,
} from "./quiz.types.js";

Expand Down Expand Up @@ -149,4 +151,50 @@ export async function quizRoutes(app: FastifyInstance): Promise<void> {
},
(request, reply) => quizController.retry(request, reply)
);

app.post<{ Params: { id: string }, Body: import("./quiz.types.js").SubmitQuizFeedbackBody }>(
"/:id/feedback",
{
preHandler: [
validate({ params: quizIdParamsSchema, body: submitQuizFeedbackSchema }),
],
schema: {
description: "Submit feedback on a specific quiz question (#331)",
tags: ["quizzes"],
security: [{ bearerAuth: [] }],
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
body: {
type: "object",
required: ["questionId", "type"],
properties: {
questionId: { type: "string", minLength: 1, maxLength: 100 },
type: { type: "string", enum: ["unclear", "wrong", "other"] },
comment: { type: "string", maxLength: 2000 },
},
},
} as FastifySchema,
},
(request, reply) => quizController.submitFeedback(request, reply)
);

app.get<{ Params: { id: string }, Querystring: import("./quiz.types.js").QuizFeedbackSummaryQuery }>(
"/:id/feedback/summary",
{
preHandler: [
adminGuard,
validate({ params: quizIdParamsSchema, querystring: quizFeedbackSummaryQuerySchema }),
],
schema: {
description: "Per-question feedback counts for a quiz (admin only) (#331)",
tags: ["quizzes", "admin"],
security: [{ bearerAuth: [] }],
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
querystring: {
type: "object",
properties: { questionId: { type: "string", minLength: 1, maxLength: 100 } },
},
} as FastifySchema,
},
(request, reply) => quizController.feedbackSummary(request, reply)
);
}
118 changes: 115 additions & 3 deletions src/modules/quizzes/quiz.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import crypto from "node:crypto";
import { eq, and } from "drizzle-orm";
import { db } from "../../config/database.js";
import { quizzes, quizSubmissions, enrollments } from "../../database/schema.js";
import { quizzes, quizSubmissions, quizFeedback, enrollments } from "../../database/schema.js";
import {
NotFoundError,
ForbiddenError,
Expand All @@ -22,8 +22,6 @@ import {
cacheSet,
cacheDel,
cacheKey,
cacheGet,
cacheSet,
cacheKeyPattern,
cacheInvalidatePattern,
} from "../../cache/index.js";
Expand All @@ -39,6 +37,9 @@ import {
type QuizSubmissionResult,
type QuizQuestion,
type QuizStats,
type SubmitQuizFeedbackBody,
type QuizFeedbackEntry,
type QuizFeedbackSummaryEntry,
} from "./quiz.types.js";

const QUIZ_STATS_TTL_SECONDS = 300;
Expand Down Expand Up @@ -733,6 +734,117 @@ export class QuizService {
return stats;
}

/**
* Submit feedback on a specific quiz question (#331): "this question is
* unclear", "wrong answer marked as correct", or something else.
*
* One submission per (quiz, question, user) — a second attempt is
* rejected with a ConflictError rather than overwriting the first, so a
* question's feedback count reflects distinct reporters.
*/
async submitFeedback(
userId: string,
quizId: string,
data: SubmitQuizFeedbackBody,
): Promise<QuizFeedbackEntry> {
const quiz = await db.query.quizzes.findFirst({
where: eq(quizzes.id, quizId),
});
if (!quiz) {
throw new NotFoundError("Quiz");
}

const questions = (quiz.questions ?? []) as StoredQuestion[];
if (!questions.some((q) => q.id === data.questionId)) {
throw new NotFoundError("Question");
}

const existing = await db.query.quizFeedback.findFirst({
where: and(
eq(quizFeedback.quizId, quizId),
eq(quizFeedback.questionId, data.questionId),
eq(quizFeedback.userId, userId),
),
});
if (existing) {
throw new ConflictError("Feedback already submitted for this question");
}

try {
const [row] = await db
.insert(quizFeedback)
.values({
quizId,
questionId: data.questionId,
userId,
type: data.type,
comment: data.comment ?? null,
})
.returning();

await auditLog("quiz.feedback.submitted", {
userId,
courseId: quiz.courseId,
});

return row as QuizFeedbackEntry;
} catch (err) {
const code = (err as { code?: string }).code;
// 23505 = unique_violation — a concurrent submission for the same
// (quiz, question, user) beat this one to the pre-check above.
if (code === "23505") {
throw new ConflictError("Feedback already submitted for this question");
}
throw err;
}
}

/**
* Per-question feedback counts for a quiz, for admins reviewing which
* questions need work (#331).
*/
async getFeedbackSummary(
quizId: string,
questionId?: string,
): Promise<QuizFeedbackSummaryEntry[]> {
const quiz = await db.query.quizzes.findFirst({
where: eq(quizzes.id, quizId),
});
if (!quiz) {
throw new NotFoundError("Quiz");
}

const conditions = [eq(quizFeedback.quizId, quizId)];
if (questionId) {
conditions.push(eq(quizFeedback.questionId, questionId));
}

const rows = await db
.select({
questionId: quizFeedback.questionId,
type: quizFeedback.type,
})
.from(quizFeedback)
.where(and(...conditions));

const byQuestion = new Map<string, QuizFeedbackSummaryEntry>();
for (const row of rows) {
let entry = byQuestion.get(row.questionId);
if (!entry) {
entry = {
questionId: row.questionId,
total: 0,
counts: { unclear: 0, wrong: 0, other: 0 },
};
byQuestion.set(row.questionId, entry);
}
entry.total++;
entry.counts[row.type as QuizFeedbackEntry["type"]]++;
}

return Array.from(byQuestion.values());
}

private createPlaceholderQuestions(
courseId: string,
moduleId: string
Expand Down
30 changes: 30 additions & 0 deletions src/modules/quizzes/quiz.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,27 @@ export const quizStatsQuerySchema = z.object({
courseId: z.string().uuid("Invalid course ID").optional(),
});

export const QUIZ_FEEDBACK_TYPES = ["unclear", "wrong", "other"] as const;

export const submitQuizFeedbackSchema = z.object({
questionId: z.string().min(1).max(100),
type: z.enum(QUIZ_FEEDBACK_TYPES),
comment: z.string().max(2000).optional(),
});

export const quizFeedbackSummaryQuerySchema = z.object({
questionId: z.string().min(1).max(100).optional(),
});

// ─── Types ──────────────────────────────────────────────────────────────────

export type GenerateQuizBody = z.infer<typeof generateQuizSchema>;
export type GenerateQuizBatchBody = z.infer<typeof generateQuizBatchSchema>;
export type SubmitQuizBody = z.infer<typeof submitQuizSchema>;
export type QuizIdParams = z.infer<typeof quizIdParamsSchema>;
export type QuizStatsQuery = z.infer<typeof quizStatsQuerySchema>;
export type SubmitQuizFeedbackBody = z.infer<typeof submitQuizFeedbackSchema>;
export type QuizFeedbackSummaryQuery = z.infer<typeof quizFeedbackSummaryQuerySchema>;

export interface QuizQuestion {
id: string;
Expand Down Expand Up @@ -115,3 +129,19 @@ export interface QuizStats {
totalSubmissions: number;
submissionsPerCourse: Record<string, number>;
}

export interface QuizFeedbackEntry {
id: string;
questionId: string;
userId: string;
type: (typeof QUIZ_FEEDBACK_TYPES)[number];
comment: string | null;
createdAt: Date;
}

/** Per-question feedback counts, for admins reviewing which questions need work. */
export interface QuizFeedbackSummaryEntry {
questionId: string;
total: number;
counts: Record<(typeof QUIZ_FEEDBACK_TYPES)[number], number>;
}
Loading