11import crypto from "node:crypto" ;
22import { eq , and } from "drizzle-orm" ;
33import { db } from "../../config/database.js" ;
4- import { quizzes , quizSubmissions , enrollments } from "../../database/schema.js" ;
4+ import { quizzes , quizSubmissions , quizFeedback , enrollments } from "../../database/schema.js" ;
55import {
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
4445const 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
0 commit comments