feat: add TestsService create flow
Made-with: Cursor
This commit is contained in:
149
src/services/tests/tests.service.ts
Normal file
149
src/services/tests/tests.service.ts
Normal file
@@ -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<typeof schema>;
|
||||||
|
|
||||||
|
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<TestWithQuestions> {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user