feat: complete platform operations roadmap
This commit is contained in:
@@ -16,6 +16,13 @@ const updateUserSchema = z.object({
|
||||
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']) };
|
||||
@@ -42,6 +49,88 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
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",
|
||||
@@ -49,7 +138,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
from audit_logs a
|
||||
left join users u on u.id = a.actor_id
|
||||
order by a.created_at desc
|
||||
limit 50`,
|
||||
limit $1`, [config.AUDIT_LOG_PAGE_SIZE],
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
@@ -73,8 +162,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
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() + interval '7 days', $4)`,
|
||||
[input.email, input.role, hashToken(rawToken), request.user.id],
|
||||
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}` } });
|
||||
@@ -87,8 +176,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
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() + interval '1 day', $4)`,
|
||||
[account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id],
|
||||
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}` } };
|
||||
|
||||
Reference in New Issue
Block a user