This commit is contained in:
140
server/migrations/001_initial_schema.sql
Normal file
140
server/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create type user_role as enum ('student', 'instructor', 'admin');
|
||||
create type course_status as enum ('draft', 'published', 'archived');
|
||||
create type lesson_access_level as enum ('public', 'enrolled');
|
||||
create type media_status as enum ('processing', 'ready', 'failed');
|
||||
create type asset_kind as enum ('document', 'spreadsheet', 'archive', 'image', 'link');
|
||||
|
||||
create table users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
email text not null unique,
|
||||
password_hash text not null,
|
||||
display_name text not null,
|
||||
role user_role not null default 'student',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table courses (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
slug text not null unique,
|
||||
title text not null,
|
||||
description text not null default '',
|
||||
category text not null,
|
||||
cover_image_url text,
|
||||
status course_status not null default 'draft',
|
||||
instructor_id uuid not null references users(id),
|
||||
published_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint courses_published_at_check check (
|
||||
(status = 'published' and published_at is not null) or status <> 'published'
|
||||
)
|
||||
);
|
||||
|
||||
create table lessons (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
course_id uuid not null references courses(id) on delete cascade,
|
||||
title text not null,
|
||||
description text not null default '',
|
||||
position integer not null check (position > 0),
|
||||
duration_seconds integer check (duration_seconds is null or duration_seconds >= 0),
|
||||
access_level lesson_access_level not null default 'enrolled',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (course_id, position)
|
||||
);
|
||||
|
||||
-- Video delivery is intentionally provider-neutral. A lesson can be moved from
|
||||
-- one provider to another without changing the lesson itself.
|
||||
create table lesson_media (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
lesson_id uuid not null references lessons(id) on delete cascade,
|
||||
provider text not null,
|
||||
external_id text not null,
|
||||
playback_url text,
|
||||
embed_url text,
|
||||
status media_status not null default 'processing',
|
||||
duration_seconds integer check (duration_seconds is null or duration_seconds >= 0),
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (provider, external_id)
|
||||
);
|
||||
|
||||
create table course_enrollments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
course_id uuid not null references courses(id) on delete cascade,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
enrolled_at timestamptz not null default now(),
|
||||
revoked_at timestamptz,
|
||||
unique (course_id, user_id)
|
||||
);
|
||||
|
||||
create table lesson_progress (
|
||||
lesson_id uuid not null references lessons(id) on delete cascade,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
watched_seconds integer not null default 0 check (watched_seconds >= 0),
|
||||
completed_at timestamptz,
|
||||
updated_at timestamptz not null default now(),
|
||||
primary key (lesson_id, user_id)
|
||||
);
|
||||
|
||||
create table assets (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
course_id uuid references courses(id) on delete cascade,
|
||||
lesson_id uuid references lessons(id) on delete cascade,
|
||||
name text not null,
|
||||
kind asset_kind not null,
|
||||
url text not null,
|
||||
size_bytes bigint check (size_bytes is null or size_bytes >= 0),
|
||||
description text not null default '',
|
||||
access_level lesson_access_level not null default 'enrolled',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint assets_parent_check check (
|
||||
(course_id is not null and lesson_id is null) or (course_id is null and lesson_id is not null)
|
||||
)
|
||||
);
|
||||
|
||||
create table comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
course_id uuid not null references courses(id) on delete cascade,
|
||||
lesson_id uuid references lessons(id) on delete set null,
|
||||
author_id uuid not null references users(id),
|
||||
body text not null check (char_length(body) between 1 and 4000),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table comment_replies (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
comment_id uuid not null unique references comments(id) on delete cascade,
|
||||
author_id uuid not null references users(id),
|
||||
body text not null check (char_length(body) between 1 and 4000),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index courses_published_index on courses (published_at desc) where status = 'published';
|
||||
create index lessons_course_position_index on lessons (course_id, position);
|
||||
create index lesson_media_lesson_index on lesson_media (lesson_id);
|
||||
create index enrollments_active_index on course_enrollments (user_id, course_id) where revoked_at is null;
|
||||
create index comments_course_created_index on comments (course_id, created_at desc);
|
||||
|
||||
create function set_updated_at() returns trigger language plpgsql as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger users_set_updated_at before update on users for each row execute function set_updated_at();
|
||||
create trigger courses_set_updated_at before update on courses for each row execute function set_updated_at();
|
||||
create trigger lessons_set_updated_at before update on lessons for each row execute function set_updated_at();
|
||||
create trigger lesson_media_set_updated_at before update on lesson_media for each row execute function set_updated_at();
|
||||
create trigger lesson_progress_set_updated_at before update on lesson_progress for each row execute function set_updated_at();
|
||||
create trigger assets_set_updated_at before update on assets for each row execute function set_updated_at();
|
||||
create trigger comments_set_updated_at before update on comments for each row execute function set_updated_at();
|
||||
create trigger comment_replies_set_updated_at before update on comment_replies for each row execute function set_updated_at();
|
||||
2
server/migrations/002_allow_reused_media_references.sql
Normal file
2
server/migrations/002_allow_reused_media_references.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
alter table lesson_media drop constraint lesson_media_provider_external_id_key;
|
||||
alter table lesson_media add constraint lesson_media_lesson_provider_external_id_key unique (lesson_id, provider, external_id);
|
||||
43
server/src/app.ts
Normal file
43
server/src/app.ts
Normal 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;
|
||||
}
|
||||
20
server/src/auth/passwords.ts
Normal file
20
server/src/auth/passwords.ts
Normal 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
53
server/src/auth/plugin.ts
Normal 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
19
server/src/config.ts
Normal 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.');
|
||||
}
|
||||
29
server/src/db/bootstrap-admin.ts
Normal file
29
server/src/db/bootstrap-admin.ts
Normal 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
50
server/src/db/migrate.ts
Normal 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
8
server/src/db/pool.ts
Normal 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();
|
||||
71
server/src/db/seed-demo-content.ts
Normal file
71
server/src/db/seed-demo-content.ts
Normal 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
25
server/src/index.ts
Normal 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
72
server/src/routes/auth.ts
Normal 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 }));
|
||||
};
|
||||
115
server/src/routes/courses.ts
Normal file
115
server/src/routes/courses.ts
Normal 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) };
|
||||
});
|
||||
};
|
||||
188
server/src/routes/manage-courses.ts
Normal file
188
server/src/routes/manage-courses.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
13
server/tsconfig.json
Normal file
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user