import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { pool } from '../db/pool.js'; import { createRawToken, hashToken } from '../auth/account-tokens.js'; import { config } from '../config.js'; import { recordAudit } from '../audit.js'; const userParamsSchema = z.object({ userId: z.string().uuid(), }); const updateUserSchema = z.object({ role: z.enum(['student', 'instructor', 'admin']).optional(), isActive: z.boolean().optional(), }).refine((input) => input.role !== undefined || input.isActive !== undefined, { message: 'Provide at least one field to update', }); const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') }); const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() }); const categoryParamsSchema = z.object({ categoryId: z.string().uuid() }); const homeConfigurationSchema = z.object({ featuredCourseId: z.string().uuid().nullable(), courseOrder: z.array(z.string().uuid()).max(500), defaultCoverImageUrl: z.string().url().nullable(), }); export const adminRoutes: FastifyPluginAsync = async (app) => { const adminAccess = { preHandler: app.requireRoles(['admin']) }; app.get('/users', adminAccess, async () => { const result = await pool.query( `select id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt" from users order by created_at desc`, ); return { data: result.rows }; }); app.get('/dashboard', adminAccess, async () => { const result = await pool.query( `select (select count(*)::int from users) as "totalUsers", (select count(*)::int from users where is_active) as "activeUsers", (select count(*)::int from courses where status = 'published') as "publishedCourses", (select count(*)::int from lesson_progress where completed_at is not null) as "completedLessons", (select count(*)::int from comments) as "comments"`, ); return { data: result.rows[0] }; }); app.get('/categories', adminAccess, async () => { const result = await pool.query( `select id, name, position, is_active as "isActive" from course_categories order by position, name`, ); return { data: result.rows }; }); app.post('/categories', adminAccess, async (request, reply) => { const input = categorySchema.parse(request.body); const result = await pool.query( `insert into course_categories (name, position, is_active) values ($1, coalesce($2, (select coalesce(max(position), 0) + 10 from course_categories)), coalesce($3, true)) returning id, name, position, is_active as "isActive"`, [input.name, input.position ?? null, input.isActive ?? null], ); await recordAudit({ actorId: request.user.id, action: 'category.created', subjectType: 'category', subjectId: result.rows[0].id, metadata: { name: input.name }, ipAddress: request.ip }); return reply.code(201).send({ data: result.rows[0] }); }); app.patch('/categories/:categoryId', adminAccess, async (request, reply) => { const { categoryId } = categoryParamsSchema.parse(request.params); const input = categorySchema.parse(request.body); const result = await pool.query( `update course_categories set name = $2, position = coalesce($3, position), is_active = coalesce($4, is_active) where id = $1 returning id, name, position, is_active as "isActive"`, [categoryId, input.name, input.position ?? null, input.isActive ?? null], ); if (!result.rows[0]) return reply.code(404).send({ error: 'Category not found' }); await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip }); return { data: result.rows[0] }; }); app.get('/home-configuration', adminAccess, async () => { const [settings, courses] = await Promise.all([ pool.query<{ key: string; value: unknown }>(`select key, value from platform_settings where key in ('home.featuredCourseId', 'home.courseOrder', 'media.defaultCoverImageUrl')`), pool.query<{ id: string; title: string; status: string }>(`select id, title, status from courses where status <> 'archived' order by title`), ]); const values = new Map(settings.rows.map((row) => [row.key, row.value])); return { data: { featuredCourseId: values.get('home.featuredCourseId') ?? null, courseOrder: values.get('home.courseOrder') ?? [], defaultCoverImageUrl: values.get('media.defaultCoverImageUrl') ?? null, courses: courses.rows, } }; }); app.put('/home-configuration', adminAccess, async (request, reply) => { const input = homeConfigurationSchema.parse(request.body); const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])]; if (ids.length) { const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]); if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Featured courses must exist and be published' }); } const client = await pool.connect(); try { await client.query('begin'); for (const [key, value] of Object.entries({ 'home.featuredCourseId': input.featuredCourseId, 'home.courseOrder': input.courseOrder, 'media.defaultCoverImageUrl': input.defaultCoverImageUrl, })) { await client.query( `insert into platform_settings (key, value, updated_at, updated_by) values ($1, $2::jsonb, now(), $3) on conflict (key) do update set value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`, [key, JSON.stringify(value), request.user.id], ); } await client.query('commit'); } catch (error) { await client.query('rollback'); throw error; } finally { client.release(); } await recordAudit({ actorId: request.user.id, action: 'home.configuration_updated', subjectType: 'platform', metadata: input, ipAddress: request.ip }); return { data: input }; }); app.get('/audit-log', adminAccess, async () => { const result = await pool.query( `select a.id, a.action, a.subject_type as "subjectType", a.subject_id as "subjectId", a.metadata, a.created_at as "createdAt", coalesce(u.display_name, 'Sistema') as "actorName" from audit_logs a left join users u on u.id = a.actor_id order by a.created_at desc limit $1`, [config.AUDIT_LOG_PAGE_SIZE], ); return { data: result.rows }; }); app.get('/users/:userId', adminAccess, async (request, reply) => { const { userId } = userParamsSchema.parse(request.params); const result = await pool.query( `select u.id, u.email, u.display_name as name, u.role, u.is_active as "isActive", u.created_at as "createdAt", (select count(*)::int from lesson_progress lp where lp.user_id = u.id and lp.completed_at is not null) as "completedLessons", (select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt" from users u where u.id = $1`, [userId], ); if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' }); return { data: result.rows[0] }; }); app.post('/invitations', adminAccess, async (request, reply) => { const input = invitationSchema.parse(request.body); const existing = await pool.query('select 1 from users where email = $1', [input.email]); if (existing.rowCount) return reply.code(409).send({ error: 'This email already has an account' }); const rawToken = createRawToken(); await pool.query( `insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by) values ($1, $2::user_role, 'invitation', $3, now() + ($4::int * interval '1 hour'), $5)`, [input.email, input.role, hashToken(rawToken), config.INVITATION_TTL_HOURS, request.user.id], ); await recordAudit({ actorId: request.user.id, action: 'invitation.created', subjectType: 'invitation', metadata: { email: input.email, role: input.role }, ipAddress: request.ip }); return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } }); }); app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => { const { userId } = userParamsSchema.parse(request.params); const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]); if (!account.rows[0]) return reply.code(404).send({ error: 'User not found' }); const rawToken = createRawToken(); await pool.query( `insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by) values ($1, $2::user_role, 'password_reset', $3, now() + ($4::int * interval '1 hour'), $5)`, [account.rows[0].email, account.rows[0].role, hashToken(rawToken), config.PASSWORD_RESET_TTL_HOURS, request.user.id], ); await recordAudit({ actorId: request.user.id, action: 'password_reset.created', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email }, ipAddress: request.ip }); return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } }; }); app.patch('/users/:userId', adminAccess, async (request, reply) => { const { userId } = userParamsSchema.parse(request.params); const input = updateUserSchema.parse(request.body); if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) { return reply.code(400).send({ error: 'You cannot remove your own superadmin access' }); } const result = await pool.query( `update users set role = coalesce($2::user_role, role), is_active = coalesce($3, is_active) where id = $1 returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`, [userId, input.role ?? null, input.isActive ?? null], ); const account = result.rows[0]; if (!account) return reply.code(404).send({ error: 'User not found' }); await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip }); return { data: account }; }); };