diff --git a/src/services/tests/tests.service.ts b/src/services/tests/tests.service.ts new file mode 100644 index 0000000..e2bca90 --- /dev/null +++ b/src/services/tests/tests.service.ts @@ -0,0 +1,149 @@ +import { eq } 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 type { Stack, Level, TestMode } from '../../db/schema/enums.js'; +import type { QuestionService } from '../questions/question.service.js'; + +type Db = NodePgDatabase; + +export type CreateTestInput = { + stack: Stack; + level: Level; + questionCount: number; + mode?: TestMode; +}; + +export type TestSnapshot = { + id: string; + testId: string; + questionBankId: string | null; + orderNumber: number; + type: string; + questionText: string; + options: Array<{ key: string; text: string }> | null; + correctAnswer: string | string[]; + explanation: string; + userAnswer?: string | string[] | null; + isCorrect?: boolean | null; + answeredAt?: string | null; +}; + +export type TestWithQuestions = { + id: string; + userId: string; + stack: string; + level: string; + questionCount: number; + mode: string; + status: string; + score: number | null; + startedAt: string; + finishedAt: string | null; + timeLimitSeconds: number | null; + questions: TestSnapshot[]; +}; + +export class TestsService { + constructor( + private readonly db: Db, + private readonly questionService: QuestionService + ) {} + + /** + * Create a new test: fetch questions, create test record, snapshot questions into test_questions. + */ + async createTest(userId: string, input: CreateTestInput): Promise { + const { stack, level, questionCount, mode = 'fixed' } = input; + + if (questionCount < 1 || questionCount > 50) { + throw new AppError( + ERROR_CODES.BAD_REQUEST, + 'questionCount must be between 1 and 50', + 400 + ); + } + + const questions = await this.questionService.getQuestionsForTest( + userId, + stack, + level, + questionCount + ); + + const [test] = await this.db + .insert(tests) + .values({ + userId, + stack, + level, + questionCount, + mode, + status: 'in_progress', + }) + .returning(); + + if (!test) { + throw new AppError( + ERROR_CODES.INTERNAL_ERROR, + 'Failed to create test', + 500 + ); + } + + const tqValues = questions.map((q, i) => ({ + testId: test.id, + questionBankId: q.questionBankId, + orderNumber: i + 1, + type: q.type, + questionText: q.questionText, + options: q.options, + correctAnswer: q.correctAnswer, + explanation: q.explanation, + })); + + await this.db.insert(testQuestions).values(tqValues); + + const questionsRows = await this.db + .select() + .from(testQuestions) + .where(eq(testQuestions.testId, test.id)) + .orderBy(testQuestions.orderNumber); + + return this.toTestWithQuestions(test, questionsRows); + } + + private toTestWithQuestions( + test: (typeof tests.$inferSelect), + questionsRows: (typeof testQuestions.$inferSelect)[] + ): TestWithQuestions { + return { + id: test.id, + userId: test.userId, + stack: test.stack, + level: test.level, + questionCount: test.questionCount, + mode: test.mode, + status: test.status, + score: test.score, + startedAt: test.startedAt.toISOString(), + finishedAt: test.finishedAt?.toISOString() ?? null, + timeLimitSeconds: test.timeLimitSeconds, + questions: questionsRows.map((q) => ({ + id: q.id, + testId: q.testId, + questionBankId: q.questionBankId, + orderNumber: q.orderNumber, + type: q.type, + questionText: q.questionText, + options: q.options, + correctAnswer: q.correctAnswer, + explanation: q.explanation, + userAnswer: q.userAnswer, + isCorrect: q.isCorrect, + answeredAt: q.answeredAt?.toISOString() ?? null, + })), + }; + } +}