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

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);