From 7cfc8fb12edcd3847eafc6ecc7e37c493463d033 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 15:05:44 +0300 Subject: [PATCH 1/3] feat: add admin questions routes Made-with: Cursor --- src/plugins/auth.ts | 20 +++++- src/routes/admin/questions.ts | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/routes/admin/questions.ts diff --git a/src/plugins/auth.ts b/src/plugins/auth.ts index 815f093..9dbea7f 100644 --- a/src/plugins/auth.ts +++ b/src/plugins/auth.ts @@ -1,11 +1,14 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; +import { eq } from 'drizzle-orm'; import { verifyToken, isAccessPayload } from '../utils/jwt.js'; -import { unauthorized } from '../utils/errors.js'; +import { unauthorized, forbidden } from '../utils/errors.js'; +import { users } from '../db/schema/users.js'; declare module 'fastify' { interface FastifyInstance { authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise; + authenticateAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise; } interface FastifyRequest { user?: { id: string; email: string }; @@ -34,9 +37,22 @@ export async function authenticate(req: FastifyRequest, _reply: FastifyReply): P } } +export async function authenticateAdmin(req: FastifyRequest, _reply: FastifyReply): Promise { + if (!req.user) { + throw unauthorized('Authentication required'); + } + + const [user] = await req.server.db.select({ role: users.role }).from(users).where(eq(users.id, req.user.id)); + + if (!user || user.role !== 'admin') { + throw forbidden('Admin access required'); + } +} + const authPlugin = async (app: FastifyInstance) => { app.decorateRequest('user', undefined); app.decorate('authenticate', authenticate); + app.decorate('authenticateAdmin', authenticateAdmin); }; -export default fp(authPlugin, { name: 'auth' }); +export default fp(authPlugin, { name: 'auth', dependencies: ['database'] }); diff --git a/src/routes/admin/questions.ts b/src/routes/admin/questions.ts new file mode 100644 index 0000000..8c0f042 --- /dev/null +++ b/src/routes/admin/questions.ts @@ -0,0 +1,128 @@ +import type { FastifyInstance } from 'fastify'; +import { AdminQuestionService } from '../../services/admin/admin-question.service.js'; +import type { EditQuestionInput } from '../../services/admin/admin-question.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 QUESTION_TYPES = ['single_choice', 'multiple_select', 'true_false', 'short_text'] as const; + +const listPendingQuerySchema = { + querystring: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100 }, + offset: { type: 'integer', minimum: 0 }, + }, + additionalProperties: false, + }, +}; + +const questionIdParamsSchema = { + params: { + type: 'object', + required: ['questionId'], + properties: { + questionId: { type: 'string', format: 'uuid' }, + }, + }, +}; + +const editQuestionSchema = { + params: { + type: 'object', + required: ['questionId'], + properties: { + questionId: { type: 'string', format: 'uuid' }, + }, + }, + body: { + type: 'object', + properties: { + stack: { type: 'string', enum: [...STACKS] }, + level: { type: 'string', enum: [...LEVELS] }, + type: { type: 'string', enum: [...QUESTION_TYPES] }, + questionText: { type: 'string', minLength: 1 }, + options: { + type: 'array', + items: { + type: 'object', + required: ['key', 'text'], + properties: { + key: { type: 'string' }, + text: { type: 'string' }, + }, + }, + }, + correctAnswer: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + explanation: { type: 'string', minLength: 1 }, + }, + additionalProperties: false, + }, +}; + +export async function adminQuestionsRoutes(app: FastifyInstance) { + const adminQuestionService = new AdminQuestionService(app.db); + const { rateLimitOptions } = app; + + app.get( + '/questions/pending', + { + schema: listPendingQuerySchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate, app.authenticateAdmin], + }, + async (req, reply) => { + const { limit, offset } = (req.query as { limit?: number; offset?: number }) ?? {}; + const result = await adminQuestionService.listPending(limit, offset); + return reply.send(result); + }, + ); + + app.post( + '/questions/:questionId/approve', + { + schema: questionIdParamsSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate, app.authenticateAdmin], + }, + async (req, reply) => { + const { questionId } = req.params as { questionId: string }; + await adminQuestionService.approve(questionId); + return reply.status(204).send(); + }, + ); + + app.post( + '/questions/:questionId/reject', + { + schema: questionIdParamsSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate, app.authenticateAdmin], + }, + async (req, reply) => { + const { questionId } = req.params as { questionId: string }; + await adminQuestionService.reject(questionId); + return reply.status(204).send(); + }, + ); + + app.patch( + '/questions/:questionId', + { + schema: editQuestionSchema, + config: { rateLimit: rateLimitOptions.apiAuthed }, + preHandler: [app.authenticate, app.authenticateAdmin], + }, + async (req, reply) => { + const { questionId } = req.params as { questionId: string }; + const body = req.body as EditQuestionInput; + const question = await adminQuestionService.edit(questionId, body); + return reply.send(question); + }, + ); +} From 7bea8585c551ac816f427defb0de389c7997d875 Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 15:06:22 +0300 Subject: [PATCH 2/3] feat: add audit logging to admin actions Made-with: Cursor --- src/routes/admin/questions.ts | 9 ++++-- src/services/admin/admin-question.service.ts | 30 +++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/routes/admin/questions.ts b/src/routes/admin/questions.ts index 8c0f042..0306f8c 100644 --- a/src/routes/admin/questions.ts +++ b/src/routes/admin/questions.ts @@ -91,8 +91,9 @@ export async function adminQuestionsRoutes(app: FastifyInstance) { preHandler: [app.authenticate, app.authenticateAdmin], }, async (req, reply) => { + const adminId = req.user!.id; const { questionId } = req.params as { questionId: string }; - await adminQuestionService.approve(questionId); + await adminQuestionService.approve(questionId, adminId); return reply.status(204).send(); }, ); @@ -105,8 +106,9 @@ export async function adminQuestionsRoutes(app: FastifyInstance) { preHandler: [app.authenticate, app.authenticateAdmin], }, async (req, reply) => { + const adminId = req.user!.id; const { questionId } = req.params as { questionId: string }; - await adminQuestionService.reject(questionId); + await adminQuestionService.reject(questionId, adminId); return reply.status(204).send(); }, ); @@ -119,9 +121,10 @@ export async function adminQuestionsRoutes(app: FastifyInstance) { preHandler: [app.authenticate, app.authenticateAdmin], }, async (req, reply) => { + const adminId = req.user!.id; const { questionId } = req.params as { questionId: string }; const body = req.body as EditQuestionInput; - const question = await adminQuestionService.edit(questionId, body); + const question = await adminQuestionService.edit(questionId, body, adminId); return reply.send(question); }, ); diff --git a/src/services/admin/admin-question.service.ts b/src/services/admin/admin-question.service.ts index 70c4080..882cd97 100644 --- a/src/services/admin/admin-question.service.ts +++ b/src/services/admin/admin-question.service.ts @@ -1,7 +1,7 @@ import { eq, asc, count } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '../../db/schema/index.js'; -import { questionBank } from '../../db/schema/index.js'; +import { questionBank, auditLogs } from '../../db/schema/index.js'; import { notFound } from '../../utils/errors.js'; import type { Stack, Level, QuestionType } from '../../db/schema/enums.js'; @@ -80,7 +80,7 @@ export class AdminQuestionService { }; } - async approve(questionId: string): Promise { + async approve(questionId: string, adminId: string): Promise { const [updated] = await this.db .update(questionBank) .set({ @@ -93,9 +93,16 @@ export class AdminQuestionService { if (!updated) { throw notFound('Question not found'); } + + await this.db.insert(auditLogs).values({ + adminId, + action: 'question_approved', + targetType: 'question', + targetId: questionId, + }); } - async reject(questionId: string): Promise { + async reject(questionId: string, adminId: string): Promise { const [updated] = await this.db .update(questionBank) .set({ status: 'rejected' }) @@ -105,9 +112,16 @@ export class AdminQuestionService { if (!updated) { throw notFound('Question not found'); } + + await this.db.insert(auditLogs).values({ + adminId, + action: 'question_rejected', + targetType: 'question', + targetId: questionId, + }); } - async edit(questionId: string, input: EditQuestionInput): Promise { + async edit(questionId: string, input: EditQuestionInput, adminId: string): Promise { const updates: Partial<{ stack: Stack; level: Level; @@ -156,6 +170,14 @@ export class AdminQuestionService { throw notFound('Question not found'); } + await this.db.insert(auditLogs).values({ + adminId, + action: 'question_edited', + targetType: 'question', + targetId: questionId, + details: updates as Record, + }); + return { id: updated.id, stack: updated.stack, From 5e207ee9b61a5a2c81f8d66d2c0b18fbf0936aef Mon Sep 17 00:00:00 2001 From: Anton Date: Wed, 4 Mar 2026 15:07:22 +0300 Subject: [PATCH 3/3] feat: register admin routes Made-with: Cursor --- src/app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app.ts b/src/app.ts index 757f1da..26e0b6c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ import subscriptionPlugin from './plugins/subscription.js'; import { authRoutes } from './routes/auth.js'; import { profileRoutes } from './routes/profile.js'; import { testsRoutes } from './routes/tests.js'; +import { adminQuestionsRoutes } from './routes/admin/questions.js'; import { env } from './config/env.js'; import { randomUUID } from 'node:crypto'; @@ -79,6 +80,7 @@ export async function buildApp(): Promise { await app.register(authRoutes, { prefix: '/auth' }); await app.register(profileRoutes, { prefix: '/profile' }); await app.register(testsRoutes, { prefix: '/tests' }); + await app.register(adminQuestionsRoutes, { prefix: '/admin' }); app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));