diff --git a/src/routes/tests.ts b/src/routes/tests.ts new file mode 100644 index 0000000..c704f38 --- /dev/null +++ b/src/routes/tests.ts @@ -0,0 +1,185 @@ +import type { FastifyInstance } from 'fastify'; +import { LlmService } from '../services/llm/llm.service.js'; +import { QuestionService } from '../services/questions/question.service.js'; +import { TestsService } from '../services/tests/tests.service.js'; +import { notFound } from '../utils/errors.js'; +import type { CreateTestInput } from '../services/tests/tests.service.js'; +import type { TestSnapshot } from '../services/tests/tests.service.js'; + +const STACKS = ['html', 'css', 'js', 'ts', 'react', 'vue', 'nodejs', 'git', 'web_basics'] as const; +const LEVELS = ['basic', 'beginner', 'intermediate', 'advanced', 'expert'] as const; + +const createTestSchema = { + body: { + type: 'object', + required: ['stack', 'level', 'questionCount'], + properties: { + stack: { type: 'string', enum: STACKS }, + level: { type: 'string', enum: LEVELS }, + questionCount: { type: 'integer', minimum: 1, maximum: 50 }, + mode: { type: 'string', enum: ['fixed', 'infinite', 'marathon'] }, + }, + additionalProperties: false, + }, +}; + +const testIdParamsSchema = { + params: { + type: 'object', + required: ['testId'], + properties: { + testId: { type: 'string', format: 'uuid' }, + }, + }, +}; + +const answerSchema = { + params: { + type: 'object', + required: ['testId'], + properties: { + testId: { type: 'string', format: 'uuid' }, + }, + }, + body: { + type: 'object', + required: ['questionId', 'answer'], + properties: { + questionId: { type: 'string', format: 'uuid' }, + answer: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }, + additionalProperties: false, + }, +}; + +const historyQuerySchema = { + querystring: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100 }, + offset: { type: 'integer', minimum: 0 }, + }, + additionalProperties: false, + }, +}; + +function stripAnswersForClient( + q: TestSnapshot +): Omit { + const { correctAnswer: _, explanation: __, ...rest } = q; + return rest; +} + +export async function testsRoutes(app: FastifyInstance) { + const llmService = new LlmService(); + const questionService = new QuestionService(app.db, llmService); + const testsService = new TestsService(app.db, questionService); + const { rateLimitOptions } = app; + + app.get( + '/', + { + schema: historyQuerySchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate], + }, + async (req, reply) => { + const userId = req.user!.id; + const { limit, offset } = (req.query as { limit?: number; offset?: number }) ?? {}; + const { tests: testList, total } = await testsService.getHistory( + userId, + { limit, offset } + ); + return reply.send({ tests: testList, total }); + } + ); + + app.post( + '/', + { + schema: createTestSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate, app.withSubscription], + }, + async (req, reply) => { + const userId = req.user!.id; + const body = req.body as CreateTestInput; + const test = await testsService.createTest(userId, body); + const hideAnswers = test.status === 'in_progress'; + const response = { + ...test, + questions: hideAnswers + ? test.questions.map(stripAnswersForClient) + : test.questions, + }; + return reply.status(201).send(response); + } + ); + + app.post( + '/:testId/answer', + { + schema: answerSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate], + }, + async (req, reply) => { + const userId = req.user!.id; + const { testId } = req.params as { testId: string }; + const { questionId, answer } = req.body as { questionId: string; answer: string | string[] }; + const snapshot = await testsService.answerQuestion( + userId, + testId, + questionId, + answer + ); + return reply.send(snapshot); + } + ); + + app.post( + '/:testId/finish', + { + schema: testIdParamsSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate], + }, + async (req, reply) => { + const userId = req.user!.id; + const { testId } = req.params as { testId: string }; + const test = await testsService.finishTest(userId, testId); + return reply.send(test); + } + ); + + app.get( + '/:testId', + { + schema: testIdParamsSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate], + }, + async (req, reply) => { + const userId = req.user!.id; + const { testId } = req.params as { testId: string }; + const test = await testsService.getById(userId, testId); + if (!test) { + throw notFound('Test not found'); + } + const hideAnswers = test.status === 'in_progress'; + const response = { + ...test, + questions: hideAnswers + ? test.questions.map(stripAnswersForClient) + : test.questions, + }; + return reply.send(response); + } + ); + +}