From b9f3663621b460cbed9d05feba6637da14d08f14 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 14:46:04 +0300 Subject: [PATCH] feat: add test answer and finish flow Made-with: Cursor --- src/services/tests/tests.service.ts | 256 +++++++++++++++++++++++++++- 1 file changed, 253 insertions(+), 3 deletions(-) diff --git a/src/services/tests/tests.service.ts b/src/services/tests/tests.service.ts index e2bca90..93cb2d2 100644 --- a/src/services/tests/tests.service.ts +++ b/src/services/tests/tests.service.ts @@ -1,8 +1,8 @@ -import { eq } from 'drizzle-orm'; +import { eq, and, desc } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '../../db/schema/index.js'; -import { tests, testQuestions } from '../../db/schema/index.js'; -import { AppError, ERROR_CODES } from '../../utils/errors.js'; +import { tests, testQuestions, userStats } from '../../db/schema/index.js'; +import { notFound, conflict, AppError, ERROR_CODES } from '../../utils/errors.js'; import type { Stack, Level, TestMode } from '../../db/schema/enums.js'; import type { QuestionService } from '../questions/question.service.js'; @@ -114,6 +114,256 @@ export class TestsService { return this.toTestWithQuestions(test, questionsRows); } + /** + * Submit an answer for a question in an in-progress test. + */ + async answerQuestion( + userId: string, + testId: string, + testQuestionId: string, + userAnswer: string | string[] + ): Promise { + const [test] = await this.db + .select() + .from(tests) + .where(and(eq(tests.id, testId), eq(tests.userId, userId))) + .limit(1); + + if (!test) { + throw notFound('Test not found'); + } + if (test.status !== 'in_progress') { + throw conflict(ERROR_CODES.TEST_ALREADY_FINISHED, 'Test is already finished'); + } + + const [tq] = await this.db + .select() + .from(testQuestions) + .where( + and( + eq(testQuestions.id, testQuestionId), + eq(testQuestions.testId, testId) + ) + ) + .limit(1); + + if (!tq) { + throw conflict(ERROR_CODES.WRONG_QUESTION, 'Question does not belong to this test'); + } + if (tq.userAnswer !== null && tq.userAnswer !== undefined) { + throw conflict(ERROR_CODES.QUESTION_ALREADY_ANSWERED, 'Question already answered'); + } + + const isCorrect = this.checkAnswer(tq.correctAnswer, userAnswer); + + const [updated] = await this.db + .update(testQuestions) + .set({ + userAnswer, + isCorrect, + answeredAt: new Date(), + }) + .where(eq(testQuestions.id, testQuestionId)) + .returning(); + + if (!updated) { + throw notFound('Question not found'); + } + + return { + id: updated.id, + testId: updated.testId, + questionBankId: updated.questionBankId, + orderNumber: updated.orderNumber, + type: updated.type, + questionText: updated.questionText, + options: updated.options, + correctAnswer: updated.correctAnswer, + explanation: updated.explanation, + userAnswer: updated.userAnswer, + isCorrect: updated.isCorrect, + answeredAt: updated.answeredAt?.toISOString() ?? null, + }; + } + + private checkAnswer( + correct: string | string[], + user: string | string[] + ): boolean { + if (Array.isArray(correct) && Array.isArray(user)) { + if (correct.length !== user.length) return false; + const a = [...correct].sort(); + const b = [...user].sort(); + return a.every((v, i) => v === b[i]); + } + if (typeof correct === 'string' && typeof user === 'string') { + return correct.trim().toLowerCase() === user.trim().toLowerCase(); + } + return false; + } + + /** + * Finish a test: calculate score, update user_stats. + */ + async finishTest(userId: string, testId: string): Promise { + const [test] = await this.db + .select() + .from(tests) + .where(and(eq(tests.id, testId), eq(tests.userId, userId))) + .limit(1); + + if (!test) { + throw notFound('Test not found'); + } + if (test.status !== 'in_progress') { + throw conflict(ERROR_CODES.TEST_ALREADY_FINISHED, 'Test is already finished'); + } + + const questions = await this.db + .select() + .from(testQuestions) + .where(eq(testQuestions.testId, testId)); + + const unanswered = questions.filter( + (q) => q.userAnswer === null || q.userAnswer === undefined + ); + if (unanswered.length > 0) { + throw new AppError( + ERROR_CODES.NO_ANSWERS, + `Cannot finish: ${unanswered.length} question(s) not answered`, + 422 + ); + } + + const correctCount = questions.filter((q) => q.isCorrect === true).length; + const score = Math.round((correctCount / questions.length) * 100); + + const [updatedTest] = await this.db + .update(tests) + .set({ + status: 'completed', + score, + finishedAt: new Date(), + }) + .where(eq(tests.id, testId)) + .returning(); + + if (!updatedTest) { + throw notFound('Test not found'); + } + + await this.upsertUserStats(userId, test.stack as Stack, test.level as Level, { + totalQuestions: questions.length, + correctAnswers: correctCount, + testsTaken: 1, + }); + + const questionsRows = await this.db + .select() + .from(testQuestions) + .where(eq(testQuestions.testId, testId)) + .orderBy(testQuestions.orderNumber); + + return this.toTestWithQuestions(updatedTest, questionsRows); + } + + private async upsertUserStats( + userId: string, + stack: Stack, + level: Level, + delta: { totalQuestions: number; correctAnswers: number; testsTaken: number } + ): Promise { + const [existing] = await this.db + .select() + .from(userStats) + .where( + and( + eq(userStats.userId, userId), + eq(userStats.stack, stack), + eq(userStats.level, level) + ) + ) + .limit(1); + + const now = new Date(); + + if (existing) { + await this.db + .update(userStats) + .set({ + totalQuestions: existing.totalQuestions + delta.totalQuestions, + correctAnswers: existing.correctAnswers + delta.correctAnswers, + testsTaken: existing.testsTaken + delta.testsTaken, + lastTestAt: now, + }) + .where(eq(userStats.id, existing.id)); + } else { + await this.db.insert(userStats).values({ + userId, + stack, + level, + totalQuestions: delta.totalQuestions, + correctAnswers: delta.correctAnswers, + testsTaken: delta.testsTaken, + lastTestAt: now, + }); + } + } + + /** + * Get a single test by ID (must belong to user). + */ + async getById(userId: string, testId: string): Promise { + const [test] = await this.db + .select() + .from(tests) + .where(and(eq(tests.id, testId), eq(tests.userId, userId))) + .limit(1); + + if (!test) return null; + + const questionsRows = await this.db + .select() + .from(testQuestions) + .where(eq(testQuestions.testId, testId)) + .orderBy(testQuestions.orderNumber); + + return this.toTestWithQuestions(test, questionsRows); + } + + /** + * Get test history for a user (most recent first). + */ + async getHistory( + userId: string, + options?: { limit?: number; offset?: number } + ): Promise<{ tests: TestWithQuestions[]; total: number }> { + const limit = Math.min(options?.limit ?? 20, 100); + const offset = options?.offset ?? 0; + + const all = await this.db + .select() + .from(tests) + .where(eq(tests.userId, userId)) + .orderBy(desc(tests.startedAt)); + + const total = all.length; + const page = all.slice(offset, offset + limit); + + const result: TestWithQuestions[] = []; + + for (const test of page) { + const questionsRows = await this.db + .select() + .from(testQuestions) + .where(eq(testQuestions.testId, test.id)) + .orderBy(testQuestions.orderNumber); + result.push(this.toTestWithQuestions(test, questionsRows)); + } + + return { tests: result, total }; + } + private toTestWithQuestions( test: (typeof tests.$inferSelect), questionsRows: (typeof testQuestions.$inferSelect)[]