This commit is contained in:
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);
|
||||
Reference in New Issue
Block a user