feat: complete platform operations roadmap
All checks were successful
CI / Validate frontend and API (push) Successful in 1m58s
CI / Build and publish Docker images (push) Successful in 22s

This commit is contained in:
Cauê Faleiros
2026-09-04 10:07:07 -03:00
parent 2c137529bc
commit 9b6fea4b81
19 changed files with 589 additions and 66 deletions

View File

@@ -10,10 +10,24 @@ import { manageCourseRoutes } from './routes/manage-courses.js';
import { learningRoutes } from './routes/learning.js';
import { adminRoutes } from './routes/admin.js';
import { mediaRoutes } from './routes/media.js';
import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js';
export function buildApp() {
const app = Fastify({ logger: true });
// Bunny signs the exact webhook byte sequence. Preserve the raw JSON body
// before parsing it so the signature can be verified server-side.
app.removeContentTypeParser('application/json');
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => {
const rawBody = Buffer.isBuffer(body) ? body : Buffer.from(body);
(request as typeof request & { rawBody?: Buffer }).rawBody = rawBody;
try {
done(null, JSON.parse(rawBody.toString('utf8')));
} catch {
done(new Error('Invalid JSON body'));
}
});
// Bunny uploads are streamed through the authenticated API. Keeping the body
// as a stream avoids loading a whole course video into Node's memory.
const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
@@ -50,6 +64,7 @@ export function buildApp() {
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });
app.register(bunnyWebhookRoutes, { prefix: '/api/v1/webhooks' });
app.register(learningRoutes, { prefix: '/api/v1' });
app.register(adminRoutes, { prefix: '/api/v1/admin' });
return app;

View File

@@ -1,7 +1,7 @@
import { pool } from './db/pool.js';
type AuditInput = {
actorId: string;
actorId?: string;
action: string;
subjectType: string;
subjectId?: string;
@@ -13,6 +13,6 @@ export const recordAudit = async (input: AuditInput) => {
await pool.query(
`insert into audit_logs (actor_id, action, subject_type, subject_id, metadata, ip_address)
values ($1, $2, $3, $4::uuid, $5::jsonb, $6::inet)`,
[input.actorId, input.action, input.subjectType, input.subjectId ?? null, JSON.stringify(input.metadata ?? {}), input.ipAddress ?? null],
[input.actorId ?? null, input.action, input.subjectType, input.subjectId ?? null, JSON.stringify(input.metadata ?? {}), input.ipAddress ?? null],
);
};

View File

@@ -15,9 +15,15 @@ const environmentSchema = z.object({
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
JWT_SESSION_TTL: z.string().regex(/^\d+[smhd]$/).default('7d'),
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_WEBHOOK_SECRET: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86_400).default(600),
BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120),
});

View File

@@ -1,4 +1,4 @@
import { createHash } from 'node:crypto';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
const BUNNY_VIDEO_API = 'https://video.bunnycdn.com';
@@ -41,7 +41,19 @@ export function isBunnyConfigured() {
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_STREAM_API_KEY && config.BUNNY_EMBED_TOKEN_KEY);
}
function toMediaStatus(status: number): BunnyMediaStatus {
export function isBunnyWebhookConfigured() {
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_WEBHOOK_SECRET);
}
export function verifyBunnyWebhookSignature(rawBody: Buffer, signature: string | undefined) {
if (!config.BUNNY_WEBHOOK_SECRET || !signature) return false;
const expected = createHmac('sha256', config.BUNNY_WEBHOOK_SECRET).update(rawBody).digest('hex');
const supplied = signature.trim().replace(/^sha256=/i, '');
if (supplied.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(supplied, 'utf8'), Buffer.from(expected, 'utf8'));
}
export function bunnyMediaStatus(status: number): BunnyMediaStatus {
if (status === 3 || status === 4) return 'ready';
if (status === 5 || status === 8) return 'failed';
return 'processing';
@@ -51,7 +63,7 @@ function toVideo(video: BunnyApiVideo): BunnyVideo {
return {
id: video.guid,
title: video.title,
status: toMediaStatus(video.status),
status: bunnyMediaStatus(video.status),
providerStatus: video.status,
encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))),
durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.round(video.length) : null,
@@ -102,7 +114,7 @@ export async function getBunnyVideo(videoId: string) {
return toVideo(await response.json() as BunnyApiVideo);
}
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = 10 * 60) {
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) {
const { libraryId, embedTokenKey } = getBunnyConfiguration();
const expires = Math.floor(Date.now() / 1000) + validitySeconds;
const token = createHash('sha256').update(`${embedTokenKey}${videoId}${expires}`).digest('hex');

View File

@@ -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}` } };

View File

@@ -33,27 +33,35 @@ const serializeUser = (user: UserRow): AuthUser => ({
});
export const authRoutes: FastifyPluginAsync = async (app) => {
const publicAttempts = new Map<string, number[]>();
const allowPublicAuthAttempt = (ip: string) => {
const now = Date.now();
const earliestAllowed = now - config.AUTH_RATE_LIMIT_WINDOW_SECONDS * 1000;
const attempts = (publicAttempts.get(ip) || []).filter((timestamp) => timestamp > earliestAllowed);
if (attempts.length >= config.AUTH_RATE_LIMIT_MAX) {
publicAttempts.set(ip, attempts);
return false;
}
attempts.push(now);
publicAttempts.set(ip, attempts);
return true;
// This is stored in PostgreSQL rather than process memory so the limit keeps
// working if the API is restarted or later scaled to more than one replica.
const allowPublicAuthAttempt = async (ip: string) => {
const result = await pool.query<{ attempts: number }>(
`insert into auth_rate_limits (key, window_started_at, attempts)
values ($1, now(), 1)
on conflict (key) do update set
window_started_at = case
when auth_rate_limits.window_started_at <= now() - ($2::int * interval '1 second') then now()
else auth_rate_limits.window_started_at
end,
attempts = case
when auth_rate_limits.window_started_at <= now() - ($2::int * interval '1 second') then 1
else auth_rate_limits.attempts + 1
end,
updated_at = now()
returning attempts`,
[`public-auth:${ip}`, config.AUTH_RATE_LIMIT_WINDOW_SECONDS],
);
return result.rows[0].attempts <= config.AUTH_RATE_LIMIT_MAX;
};
const rejectIfRateLimited = (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
if (allowPublicAuthAttempt(ip)) return false;
const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
if (await allowPublicAuthAttempt(ip)) return false;
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
return true;
};
app.post('/accept-invitation', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
if (await rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
const client = await pool.connect();
try {
@@ -74,7 +82,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
);
await client.query('commit');
const user = serializeUser(result.rows[0]);
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user });
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
} catch {
await client.query('rollback');
return reply.code(409).send({ error: 'This invitation email already has an account' });
@@ -84,7 +92,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
});
app.post('/reset-password', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
if (await rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.parse(request.body);
const client = await pool.connect();
try {
@@ -109,7 +117,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
});
app.post('/register', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
if (await rejectIfRateLimited(request.ip, reply)) return;
const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password);
@@ -121,7 +129,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
[input.email, passwordHash, input.name],
);
const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
return reply.code(201).send({ token, user });
} catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
@@ -132,7 +140,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
});
app.post('/login', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
if (await rejectIfRateLimited(request.ip, reply)) return;
const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>(
`select id, email, display_name, role, password_hash, is_active from users where email = $1`,
@@ -145,7 +153,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
const user = serializeUser(account);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
return { token, user };
});

View File

@@ -0,0 +1,40 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { recordAudit } from '../audit.js';
import { config } from '../config.js';
import { pool } from '../db/pool.js';
import { bunnyMediaStatus, isBunnyWebhookConfigured, verifyBunnyWebhookSignature } from '../providers/bunny.js';
const webhookSchema = z.object({
VideoGuid: z.string().uuid(),
VideoLibraryId: z.coerce.number().int().positive(),
Status: z.coerce.number().int().nonnegative(),
Length: z.coerce.number().nonnegative().optional(),
});
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
app.post('/bunny', async (request, reply) => {
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Bunny webhooks are not configured' });
const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody;
const signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) {
return reply.code(401).send({ error: 'Invalid Bunny webhook signature' });
}
const input = webhookSchema.parse(request.body);
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
return reply.code(400).send({ error: 'Unexpected Bunny video library' });
}
const status = bunnyMediaStatus(input.Status);
await pool.query(
`update lesson_media
set status = $2::media_status,
duration_seconds = coalesce($3, duration_seconds),
metadata = metadata || jsonb_build_object('bunnyStatus', $4, 'bunnyWebhookAt', now())
where provider = 'bunny' and external_id = $1`,
[input.VideoGuid, status, input.Length ? Math.round(input.Length) : null, input.Status],
);
await recordAudit({ action: 'media.bunny.webhook_received', subjectType: 'video', subjectId: input.VideoGuid, metadata: { status, providerStatus: input.Status }, ipAddress: request.ip });
return reply.code(204).send();
});
};

View File

@@ -18,7 +18,9 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
c.title,
c.description,
c.category,
c.cover_image_url as "coverImageUrl",
coalesce(c.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl",
c.status,
c.published_at as "publishedAt",
jsonb_build_object(
@@ -100,9 +102,27 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
}
};
app.get('/categories', async () => {
const result = await pool.query<{ name: string }>(`select name from course_categories where is_active order by position, name`);
return { data: result.rows.map((row) => row.name) };
});
app.get('/', async (request) => {
const authenticated = await hasSession(request);
const result = await pool.query(`${courseSelect()} where c.status = 'published' order by c.published_at desc`);
const result = await pool.query(
`${courseSelect()}
left join platform_settings featured_setting on featured_setting.key = 'home.featuredCourseId'
left join platform_settings order_setting on order_setting.key = 'home.courseOrder'
where c.status = 'published'
order by
case when c.id::text = coalesce(featured_setting.value #>> '{}', '') then 0 else 1 end,
coalesce((
select position::int
from jsonb_array_elements_text(coalesce(order_setting.value, '[]'::jsonb)) with ordinality as ordered(id, position)
where ordered.id = c.id::text
), 999999),
c.published_at desc`,
);
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
});

View File

@@ -48,6 +48,7 @@ const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>;
class CourseContentConflict extends Error {}
class CoursePublicationBlocked extends Error {}
function slugify(value: string) {
return value
@@ -82,6 +83,21 @@ async function insertAssets(client: PoolClient, parent: { courseId?: string; les
}
}
async function assertActiveCategory(category: string) {
const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]);
if (!result.rowCount) throw new CourseContentConflict('Select an active category created by the superadmin.');
}
function assertPublishable(input: CourseInput) {
if (input.status !== 'published') return;
const unplayableLesson = input.lessons.find((lesson) =>
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
);
if (unplayableLesson) {
throw new CoursePublicationBlocked(`The course cannot be published while "${unplayableLesson.title}" has no ready video.`);
}
}
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]);
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
@@ -184,6 +200,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
app.post('/', manageAccess, async (request, reply) => {
const input = courseSchema.parse(request.body);
await assertActiveCategory(input.category);
assertPublishable(input);
const client = await pool.connect();
try {
await client.query('begin');
@@ -200,7 +218,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
return reply.code(201).send({ data: { id: course.rows[0].id } });
} catch (error) {
await client.query('rollback');
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
if (error instanceof CourseContentConflict || error instanceof CoursePublicationBlocked) return reply.code(409).send({ error: error.message });
throw error;
} finally {
client.release();
@@ -212,6 +230,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const input = courseSchema.parse(request.body);
const accessError = await assertCanManageCourse(courseId, request.user);
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
await assertActiveCategory(input.category);
assertPublishable(input);
const client = await pool.connect();
try {
@@ -230,7 +250,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
return { data: { id: courseId } };
} catch (error) {
await client.query('rollback');
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
if (error instanceof CourseContentConflict || error instanceof CoursePublicationBlocked) return reply.code(409).send({ error: error.message });
throw error;
} finally {
client.release();
@@ -245,4 +265,65 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
return reply.code(204).send();
});
app.post('/:courseId/duplicate', manageAccess, async (request, reply) => {
const { courseId } = paramsSchema.parse(request.params);
const accessError = await assertCanManageCourse(courseId, request.user);
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
const client = await pool.connect();
try {
await client.query('begin');
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; instructor_id: string }>(
`select title, description, category, cover_image_url, instructor_id from courses where id = $1`, [courseId],
);
if (!original.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Course not found' });
}
const source = original.rows[0];
const title = `${source.title} (cópia)`;
const slug = await uniqueSlug(client, undefined, title);
const copiedCourse = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id)
values ($1, $2, $3, $4, $5, 'draft', $6) returning id`,
[slug, title, source.description, source.category, source.cover_image_url, source.instructor_id],
);
const newCourseId = copiedCourse.rows[0].id;
await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
select $2, null, name, kind, url, size_bytes, description, access_level from assets where course_id = $1`,
[courseId, newCourseId],
);
const sourceLessons = await client.query<{ id: string; title: string; description: string; position: number; duration_seconds: number | null; access_level: 'public' | 'enrolled' }>(
`select id, title, description, position, duration_seconds, access_level from lessons where course_id = $1 order by position`, [courseId],
);
for (const lesson of sourceLessons.rows) {
const copiedLesson = await client.query<{ id: string }>(
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
values ($1, $2, $3, $4, $5, $6) returning id`,
[newCourseId, lesson.title, lesson.description, lesson.position, lesson.duration_seconds, lesson.access_level],
);
const newLessonId = copiedLesson.rows[0].id;
await client.query(
`insert into lesson_media (lesson_id, provider, external_id, playback_url, embed_url, status, duration_seconds, metadata)
select $2, provider, external_id, playback_url, embed_url, status, duration_seconds, metadata from lesson_media where lesson_id = $1`,
[lesson.id, newLessonId],
);
await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
select null, $2, name, kind, url, size_bytes, description, access_level from assets where lesson_id = $1`,
[lesson.id, newLessonId],
);
}
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'course.duplicated', subjectType: 'course', subjectId: newCourseId, metadata: { sourceCourseId: courseId, title }, ipAddress: request.ip });
return reply.code(201).send({ data: { id: newCourseId } });
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
};