first commit
Some checks failed
CI / Validate frontend and API (push) Failing after 7m27s

This commit is contained in:
Cauê Faleiros
2026-08-31 12:38:34 -03:00
parent 684f929f28
commit 701788ea08
40 changed files with 5396 additions and 380 deletions

43
server/src/app.ts Normal file
View File

@@ -0,0 +1,43 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import { authPlugin } from './auth/plugin.js';
import { config } from './config.js';
import { pool } from './db/pool.js';
import { authRoutes } from './routes/auth.js';
import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js';
export function buildApp() {
const app = Fastify({ logger: true });
app.register(cors, {
origin: config.FRONTEND_ORIGIN,
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
});
app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid request', details: error.flatten() });
}
app.log.error(error);
return reply.code(500).send({ error: 'Internal server error' });
});
app.get('/api/v1/health', async () => ({ status: 'ok' }));
app.get('/api/v1/ready', async (_request, reply) => {
try {
await pool.query('select 1');
return { status: 'ready' };
} catch {
return reply.code(503).send({ status: 'unavailable' });
}
});
app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
return app;
}

View File

@@ -0,0 +1,20 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCallback);
const keyLength = 64;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const derivedKey = await scrypt(password, salt, keyLength) as Buffer;
return `scrypt$${salt}$${derivedKey.toString('hex')}`;
}
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [algorithm, salt, encodedKey] = storedHash.split('$');
if (algorithm !== 'scrypt' || !salt || !encodedKey) return false;
const storedKey = Buffer.from(encodedKey, 'hex');
const derivedKey = await scrypt(password, salt, keyLength) as Buffer;
return storedKey.length === derivedKey.length && timingSafeEqual(storedKey, derivedKey);
}

53
server/src/auth/plugin.ts Normal file
View File

@@ -0,0 +1,53 @@
import fastifyJwt from '@fastify/jwt';
import type { FastifyPluginAsync, preHandlerHookHandler } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { config } from '../config.js';
export type UserRole = 'student' | 'instructor' | 'admin';
export interface AuthUser {
id: string;
email: string;
role: UserRole;
name: string;
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AuthUser;
user: AuthUser;
}
}
declare module 'fastify' {
interface FastifyInstance {
authenticate: preHandlerHookHandler;
requireRoles: (roles: UserRole[]) => preHandlerHookHandler;
}
}
const registerAuth: FastifyPluginAsync = async (app) => {
await app.register(fastifyJwt, { secret: config.JWT_SECRET });
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
});
app.decorate('requireRoles', (roles: UserRole[]) => async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' });
}
});
};
export const authPlugin = fastifyPlugin(registerAuth, { name: 'auth' });

19
server/src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
import 'dotenv/config';
import { z } from 'zod';
const environmentSchema = z.object({
API_PORT: z.coerce.number().int().positive().default(3001),
DATABASE_URL: z.string().url().default('postgres://compor:compor_local_password@localhost:5433/compor_hub'),
FRONTEND_ORIGIN: z.string().url().default('http://localhost:3000'),
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
BOOTSTRAP_ADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Admin'),
});
export const config = environmentSchema.parse(process.env);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
throw new Error('JWT_SECRET must be set to a unique value in production.');
}

View File

@@ -0,0 +1,29 @@
import { hashPassword } from '../auth/passwords.js';
import { config } from '../config.js';
import { closePool, pool } from './pool.js';
async function bootstrapAdmin() {
if (!config.BOOTSTRAP_ADMIN_EMAIL || !config.BOOTSTRAP_ADMIN_PASSWORD) {
throw new Error('Set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD before running this command.');
}
const passwordHash = await hashPassword(config.BOOTSTRAP_ADMIN_PASSWORD);
const result = await pool.query<{ email: string }>(
`insert into users (email, password_hash, display_name, role)
values ($1, $2, $3, 'admin')
on conflict (email) do update
set password_hash = excluded.password_hash,
display_name = excluded.display_name,
role = 'admin'
returning email`,
[config.BOOTSTRAP_ADMIN_EMAIL, passwordHash, config.BOOTSTRAP_ADMIN_NAME],
);
console.log(`Administrator ready: ${result.rows[0].email}`);
}
bootstrapAdmin()
.catch((error: unknown) => {
console.error('Admin bootstrap failed', error);
process.exitCode = 1;
})
.finally(closePool);

50
server/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,50 @@
import { readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool, closePool } from './pool.js';
const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
async function migrate() {
await pool.query(`
create table if not exists schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)
`);
const migrations = (await readdir(migrationsDirectory))
.filter((file) => file.endsWith('.sql'))
.sort();
for (const migration of migrations) {
const alreadyApplied = await pool.query<{ name: string }>(
'select name from schema_migrations where name = $1',
[migration],
);
if (alreadyApplied.rowCount) continue;
const sql = await readFile(join(migrationsDirectory, migration), 'utf8');
const client = await pool.connect();
try {
await client.query('begin');
await client.query(sql);
await client.query('insert into schema_migrations (name) values ($1)', [migration]);
await client.query('commit');
console.log(`Applied ${migration}`);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
}
migrate()
.catch((error: unknown) => {
console.error('Migration failed', error);
process.exitCode = 1;
})
.finally(closePool);

8
server/src/db/pool.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Pool } from 'pg';
import { config } from '../config.js';
export const pool = new Pool({
connectionString: config.DATABASE_URL,
});
export const closePool = () => pool.end();

View File

@@ -0,0 +1,71 @@
import { INITIAL_COURSES } from '../../../constants.js';
import { closePool, pool } from './pool.js';
const instructorEmail = process.env.SEED_INSTRUCTOR_EMAIL || 'admin@compor.local';
const slugify = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const durationToSeconds = (duration: string) => {
const hours = Number(duration.match(/(\d+)h/)?.[1] || 0);
const minutes = Number(duration.match(/(\d+)m/)?.[1] || 0);
return hours * 3600 + minutes * 60 || null;
};
async function seedDemoContent() {
const instructor = await pool.query<{ id: string }>('select id from users where email = $1', [instructorEmail]);
if (!instructor.rowCount) {
throw new Error(`No administrator found for ${instructorEmail}. Run db:bootstrap-admin first.`);
}
let created = 0;
for (const [courseIndex, course] of INITIAL_COURSES.entries()) {
const exists = await pool.query('select 1 from courses where title = $1', [course.title]);
if (exists.rowCount) continue;
const client = await pool.connect();
try {
await client.query('begin');
const insertedCourse = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, 'published', $6, now()) returning id`,
[`${slugify(course.title)}-${courseIndex + 1}`, course.title, course.description, course.category, course.thumbnail, instructor.rows[0].id],
);
const courseId = insertedCourse.rows[0].id;
for (const [lessonIndex, lesson] of course.lessons.entries()) {
const insertedLesson = await client.query<{ id: string }>(
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
values ($1, $2, $3, $4, $5, 'public') returning id`,
[courseId, lesson.title, lesson.description || '', lessonIndex + 1, durationToSeconds(lesson.duration)],
);
if (lesson.videoUrl.startsWith('http')) {
await client.query(
`insert into lesson_media (lesson_id, provider, external_id, playback_url, status)
values ($1, 'external', $2, $2, 'ready')`,
[insertedLesson.rows[0].id, lesson.videoUrl],
);
}
}
await client.query('commit');
created += 1;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
console.log(`Demo catalogue ready: ${created} courses added.`);
}
seedDemoContent()
.catch((error: unknown) => {
console.error('Demo catalogue seed failed', error);
process.exitCode = 1;
})
.finally(closePool);

25
server/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { buildApp } from './app.js';
import { config } from './config.js';
import { closePool } from './db/pool.js';
const app = buildApp();
async function start() {
try {
await app.listen({ host: '0.0.0.0', port: config.API_PORT });
} catch (error) {
app.log.error(error);
await closePool();
process.exit(1);
}
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
app.close()
.then(closePool)
.finally(() => process.exit(0));
});
}
void start();

72
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js';
const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()),
password: z.string().min(12).max(200),
});
const registerSchema = credentialsSchema.extend({
name: z.string().trim().min(2).max(120),
});
type UserRow = {
id: string;
email: string;
display_name: string;
role: AuthUser['role'];
password_hash: string;
};
const serializeUser = (user: UserRow): AuthUser => ({
id: user.id,
email: user.email,
name: user.display_name,
role: user.role,
});
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/register', async (request, reply) => {
const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password);
try {
const result = await pool.query<UserRow>(
`insert into users (email, password_hash, display_name)
values ($1, $2, $3)
returning id, email, display_name, role, password_hash`,
[input.email, passwordHash, input.name],
);
const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
return reply.code(201).send({ token, user });
} catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
return reply.code(409).send({ error: 'An account with this email already exists' });
}
throw error;
}
});
app.post('/login', async (request, reply) => {
const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>(
`select id, email, display_name, role, password_hash from users where email = $1`,
[input.email],
);
const account = result.rows[0];
if (!account || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' });
}
const user = serializeUser(account);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
return { token, user };
});
app.get('/me', { preHandler: app.authenticate }, async (request) => ({ user: request.user }));
};

View File

@@ -0,0 +1,115 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
});
export const courseSelect = (publicOnly = false) => `
select
c.id,
c.slug,
c.title,
c.description,
c.category,
c.cover_image_url as "coverImageUrl",
c.status,
c.published_at as "publishedAt",
jsonb_build_object(
'id', u.id,
'name', u.display_name
) as instructor,
coalesce((
select jsonb_agg(
jsonb_build_object(
'id', l.id,
'title', l.title,
'description', l.description,
'position', l.position,
'durationSeconds', l.duration_seconds,
'accessLevel', l.access_level,
'assets', coalesce((
select jsonb_agg(jsonb_build_object(
'id', a.id,
'name', a.name,
'kind', a.kind,
'url', a.url,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
from assets a
where a.lesson_id = l.id ${publicOnly ? "and a.access_level = 'public'" : ''}
), '[]'::jsonb),
'media', coalesce((
select jsonb_agg(
jsonb_build_object(
'id', lm.id,
'provider', lm.provider,
'status', lm.status,
'embedUrl', lm.embed_url,
'playbackUrl', lm.playback_url,
'durationSeconds', lm.duration_seconds
) order by lm.created_at
)
from lesson_media lm
where lm.lesson_id = l.id and lm.status = 'ready'
), '[]'::jsonb)
) order by l.position
)
from lessons l
where l.course_id = c.id ${publicOnly ? "and l.access_level = 'public'" : ''}
), '[]'::jsonb) as lessons
, coalesce((
select jsonb_agg(jsonb_build_object(
'id', a.id,
'name', a.name,
'kind', a.kind,
'url', a.url,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
from assets a
where a.course_id = c.id ${publicOnly ? "and a.access_level = 'public'" : ''}
), '[]'::jsonb) as assets
from courses c
join users u on u.id = c.instructor_id
`;
export const courseRoutes: FastifyPluginAsync = async (app) => {
const withoutProtectedMedia = (course: Record<string, unknown>) => ({
...course,
assets: [],
lessons: Array.isArray(course.lessons)
? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] }))
: [],
});
const hasSession = async (request: { jwtVerify: () => Promise<unknown> }) => {
try {
await request.jwtVerify();
return true;
} catch {
return false;
}
};
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`);
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
});
app.get('/:courseId', async (request, reply) => {
const authenticated = await hasSession(request);
const { courseId } = paramsSchema.parse(request.params);
const result = await pool.query(`${courseSelect()} where c.id = $1 and c.status = 'published'`, [courseId]);
const course = result.rows[0];
if (!course) {
return reply.code(404).send({ error: 'Course not found' });
}
return { data: authenticated ? course : withoutProtectedMedia(course) };
});
};

View File

@@ -0,0 +1,188 @@
import { randomUUID } from 'node:crypto';
import type { FastifyPluginAsync } from 'fastify';
import type { PoolClient } from 'pg';
import { z } from 'zod';
import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js';
const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80),
externalId: z.string().trim().min(1).max(500),
playbackUrl: z.string().url().nullable().optional(),
embedUrl: z.string().url().nullable().optional(),
status: z.enum(['processing', 'ready', 'failed']).default('ready'),
durationSeconds: z.number().int().nonnegative().nullable().optional(),
});
const assetSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(),
description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
});
const lessonSchema = z.object({
title: z.string().trim().min(1).max(255),
description: z.string().max(5000).default(''),
durationSeconds: z.number().int().nonnegative().nullable().optional(),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
media: z.array(mediaSchema).default([]),
assets: z.array(assetSchema).default([]),
});
const courseSchema = z.object({
title: z.string().trim().min(1).max(255),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120),
coverImageUrl: z.string().url().nullable().optional(),
status: z.enum(['draft', 'published']).default('draft'),
lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]),
});
const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>;
function slugify(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'course';
}
async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined, title: string, currentCourseId?: string) {
const base = requestedSlug ?? slugify(title);
let candidate = base;
while (true) {
const result = await client.query(
'select 1 from courses where slug = $1 and ($2::uuid is null or id <> $2::uuid)',
[candidate, currentCourseId ?? null],
);
if (!result.rowCount) return candidate;
candidate = `${base}-${randomUUID().slice(0, 8)}`;
}
}
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) {
await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel],
);
}
}
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
// This replacement strategy is safe before student progress exists. The next
// iteration will switch to per-lesson updates to preserve historical progress.
await client.query('delete from lessons where course_id = $1', [courseId]);
await client.query('delete from assets where course_id = $1', [courseId]);
await insertAssets(client, { courseId }, input.assets);
for (const [index, lesson] of input.lessons.entries()) {
const lessonResult = 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`,
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
);
const lessonId = lessonResult.rows[0].id;
for (const media of lesson.media) {
await client.query(
`insert into lesson_media
(lesson_id, provider, external_id, playback_url, embed_url, status, duration_seconds)
values ($1, $2, $3, $4, $5, $6, $7)`,
[lessonId, media.provider, media.externalId, media.playbackUrl ?? null, media.embedUrl ?? null, media.status, media.durationSeconds ?? null],
);
}
await insertAssets(client, { lessonId }, lesson.assets);
}
}
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0];
if (!course) return { error: 'Course not found', statusCode: 404 as const };
if (user.role !== 'admin' && course.instructor_id !== user.id) {
return { error: 'You can only manage your own courses', statusCode: 403 as const };
}
return null;
}
export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
app.get('/', manageAccess, async (request) => {
const filters = request.user.role === 'admin'
? { sql: "where c.status <> 'archived' order by c.created_at desc", values: [] as string[] }
: { sql: "where c.instructor_id = $1 and c.status <> 'archived' order by c.created_at desc", values: [request.user.id] };
const result = await pool.query(`${courseSelect()} ${filters.sql}`, filters.values);
return { data: result.rows };
});
app.post('/', manageAccess, async (request, reply) => {
const input = courseSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const slug = await uniqueSlug(client, input.slug, input.title);
const course = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, $6::course_status, $7, case when $6::course_status = 'published'::course_status then now() else null end)
returning id`,
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status, request.user.id],
);
await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit');
return reply.code(201).send({ data: { id: course.rows[0].id } });
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.patch('/:courseId', manageAccess, async (request, reply) => {
const { courseId } = paramsSchema.parse(request.params);
const input = courseSchema.parse(request.body);
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 slug = await uniqueSlug(client, input.slug, input.title, courseId);
await client.query(
`update courses
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6,
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
where id = $1`,
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status],
);
await replaceCourseContents(client, courseId, input);
await client.query('commit');
return { data: { id: courseId } };
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.delete('/:courseId', 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 });
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
return reply.code(204).send();
});
};