feat: complete platform operations roadmap
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}` } };
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
40
server/src/routes/bunny-webhooks.ts
Normal file
40
server/src/routes/bunny-webhooks.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user