387 lines
20 KiB
TypeScript
387 lines
20 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify';
|
|
import type { PoolClient } from 'pg';
|
|
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(),
|
|
});
|
|
const homeBannersSchema = z.object({
|
|
banners: z.array(z.object({
|
|
kind: z.enum(['course', 'custom']),
|
|
courseId: z.string().uuid().nullable(),
|
|
imageUrl: z.string().url().nullable(),
|
|
title: z.string().trim().max(180).default(''),
|
|
description: z.string().trim().max(500).default(''),
|
|
}).superRefine((banner, context) => {
|
|
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Course banners need a course' });
|
|
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Custom banners need an image' });
|
|
})).max(10),
|
|
});
|
|
|
|
const selectHomeBanners = async () => {
|
|
const result = await pool.query(
|
|
`select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position,
|
|
c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl"
|
|
from home_banners b
|
|
left join courses c on c.id = b.course_id
|
|
order by b.position`,
|
|
);
|
|
return result.rows;
|
|
};
|
|
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
|
|
const learningPathSchema = z.object({
|
|
title: z.string().trim().min(1).max(160),
|
|
description: z.string().trim().max(2_000).default(''),
|
|
coverImageUrl: z.string().url().nullable().default(null),
|
|
coverImageZoom: z.number().min(1).max(2.5).default(1),
|
|
coverImagePositionX: z.number().min(0).max(100).default(50),
|
|
coverImagePositionY: z.number().min(0).max(100).default(50),
|
|
status: z.enum(['draft', 'published']).default('draft'),
|
|
courseIds: z.array(z.string().uuid()).min(1).max(100),
|
|
});
|
|
|
|
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
|
|
|
const getLearningPaths = async () => {
|
|
const result = await pool.query(
|
|
`select
|
|
p.id, p.title, p.description, p.cover_image_url as "coverImageUrl",
|
|
p.cover_image_zoom as "coverImageZoom",
|
|
p.cover_image_position_x as "coverImagePositionX",
|
|
p.cover_image_position_y as "coverImagePositionY",
|
|
p.status, p.published_at as "publishedAt", p.created_at as "createdAt",
|
|
coalesce(jsonb_agg(jsonb_build_object(
|
|
'id', c.id, 'title', c.title, 'status', c.status,
|
|
'coverImageUrl', c.cover_image_url, 'position', pc.position
|
|
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
|
|
from learning_paths p
|
|
left join learning_path_courses pc on pc.learning_path_id = p.id
|
|
left join courses c on c.id = pc.course_id
|
|
group by p.id
|
|
order by p.created_at desc`,
|
|
);
|
|
return result.rows;
|
|
};
|
|
|
|
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
|
|
const courseIds = [...new Set(input.courseIds)];
|
|
if (courseIds.length !== input.courseIds.length) throw new Error('A course can only appear once in a path');
|
|
const courses = await client.query<{ id: string; status: string }>(
|
|
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
|
|
[courseIds],
|
|
);
|
|
if (courses.rowCount !== courseIds.length) throw new Error('Every path course must exist and cannot be archived');
|
|
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
|
|
throw new Error('Publish every course in this path before publishing the path');
|
|
}
|
|
return courseIds;
|
|
};
|
|
|
|
const writePathCourses = async (client: PoolClient, pathId: string, courseIds: string[]) => {
|
|
await client.query('delete from learning_path_courses where learning_path_id = $1', [pathId]);
|
|
for (const [index, courseId] of courseIds.entries()) {
|
|
await client.query(
|
|
`insert into learning_path_courses (learning_path_id, course_id, position) values ($1, $2, $3)`,
|
|
[pathId, courseId, index + 1],
|
|
);
|
|
}
|
|
};
|
|
|
|
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.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
|
|
|
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
|
|
|
|
app.put('/home-banners', adminAccess, async (request) => {
|
|
const input = homeBannersSchema.parse(request.body);
|
|
const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!);
|
|
if (courseIds.length) {
|
|
const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]);
|
|
if (courses.rowCount !== courseIds.length) return { error: 'Course banners must reference published courses' };
|
|
}
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('begin');
|
|
await client.query('delete from home_banners');
|
|
for (const [index, banner] of input.banners.entries()) {
|
|
await client.query(
|
|
`insert into home_banners (kind, course_id, image_url, title, description, position)
|
|
values ($1, $2, $3, $4, $5, $6)`,
|
|
[banner.kind, banner.courseId, banner.imageUrl, banner.title, banner.description, index + 1],
|
|
);
|
|
}
|
|
await client.query('commit');
|
|
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
|
|
await recordAudit({ actorId: request.user.id, action: 'home.banners_updated', subjectType: 'platform', metadata: { count: input.banners.length }, ipAddress: request.ip });
|
|
return { data: await selectHomeBanners() };
|
|
});
|
|
|
|
app.post('/paths', adminAccess, async (request, reply) => {
|
|
const input = learningPathSchema.parse(request.body);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('begin');
|
|
const courseIds = await validatePathCourses(client, input);
|
|
const path = await client.query<{ id: string }>(
|
|
`insert into learning_paths (
|
|
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at
|
|
) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end)
|
|
returning id`,
|
|
[input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
|
);
|
|
await writePathCourses(client, path.rows[0].id, courseIds);
|
|
await client.query('commit');
|
|
await recordAudit({ actorId: request.user.id, action: 'path.created', subjectType: 'learning_path', subjectId: path.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
|
|
const paths = await getLearningPaths();
|
|
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
|
} catch (error) {
|
|
await client.query('rollback');
|
|
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.patch('/paths/:pathId', adminAccess, async (request, reply) => {
|
|
const { pathId } = learningPathParamsSchema.parse(request.params);
|
|
const input = learningPathSchema.parse(request.body);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('begin');
|
|
const courseIds = await validatePathCourses(client, input);
|
|
const path = await client.query<{ id: string }>(
|
|
`update learning_paths set
|
|
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5,
|
|
cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status,
|
|
published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
|
|
where id = $1
|
|
returning id`,
|
|
[pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
|
);
|
|
if (!path.rows[0]) {
|
|
await client.query('rollback');
|
|
return reply.code(404).send({ error: 'Learning path not found' });
|
|
}
|
|
await writePathCourses(client, pathId, courseIds);
|
|
await client.query('commit');
|
|
await recordAudit({ actorId: request.user.id, action: 'path.updated', subjectType: 'learning_path', subjectId: pathId, metadata: { title: input.title }, ipAddress: request.ip });
|
|
const paths = await getLearningPaths();
|
|
return { data: paths.find((item) => item.id === pathId) };
|
|
} catch (error) {
|
|
await client.query('rollback');
|
|
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
|
|
const { pathId } = learningPathParamsSchema.parse(request.params);
|
|
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
|
|
if (!result.rows[0]) return reply.code(404).send({ error: 'Learning path not found' });
|
|
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
|
|
return reply.code(204).send();
|
|
});
|
|
|
|
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 };
|
|
});
|
|
};
|