feat: add managed learning paths
This commit is contained in:
32
server/migrations/009_learning_paths.sql
Normal file
32
server/migrations/009_learning_paths.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- A learning path is deliberately only a curated, ordered collection of
|
||||
-- existing courses. Lessons remain owned by their courses, so a course edit
|
||||
-- is reflected everywhere that course appears.
|
||||
create table learning_paths (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
title text not null check (char_length(trim(title)) between 1 and 160),
|
||||
description text not null default '',
|
||||
cover_image_url text,
|
||||
cover_image_zoom real not null default 1 check (cover_image_zoom between 1 and 2.5),
|
||||
cover_image_position_x real not null default 50 check (cover_image_position_x between 0 and 100),
|
||||
cover_image_position_y real not null default 50 check (cover_image_position_y between 0 and 100),
|
||||
status course_status not null default 'draft',
|
||||
published_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint learning_paths_published_at_check check (
|
||||
(status = 'published' and published_at is not null) or status <> 'published'
|
||||
)
|
||||
);
|
||||
|
||||
create table learning_path_courses (
|
||||
learning_path_id uuid not null references learning_paths(id) on delete cascade,
|
||||
course_id uuid not null references courses(id) on delete restrict,
|
||||
position integer not null check (position > 0),
|
||||
primary key (learning_path_id, course_id),
|
||||
unique (learning_path_id, position)
|
||||
);
|
||||
|
||||
create index learning_paths_published_index on learning_paths (published_at desc) where status = 'published';
|
||||
create index learning_path_courses_position_index on learning_path_courses (learning_path_id, position);
|
||||
|
||||
create trigger learning_paths_set_updated_at before update on learning_paths for each row execute function set_updated_at();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { createRawToken, hashToken } from '../auth/account-tokens.js';
|
||||
@@ -23,6 +24,64 @@ const homeConfigurationSchema = z.object({
|
||||
courseOrder: z.array(z.string().uuid()).max(500),
|
||||
defaultCoverImageUrl: z.string().url().nullable(),
|
||||
});
|
||||
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
|
||||
const learningPathSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
description: z.string().trim().max(2_000).default(''),
|
||||
coverImageUrl: z.string().url().nullable().default(null),
|
||||
coverImageZoom: z.number().min(1).max(2.5).default(1),
|
||||
coverImagePositionX: z.number().min(0).max(100).default(50),
|
||||
coverImagePositionY: z.number().min(0).max(100).default(50),
|
||||
status: z.enum(['draft', 'published']).default('draft'),
|
||||
courseIds: z.array(z.string().uuid()).min(1).max(100),
|
||||
});
|
||||
|
||||
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
||||
|
||||
const getLearningPaths = async () => {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
p.id, p.title, p.description, p.cover_image_url as "coverImageUrl",
|
||||
p.cover_image_zoom as "coverImageZoom",
|
||||
p.cover_image_position_x as "coverImagePositionX",
|
||||
p.cover_image_position_y as "coverImagePositionY",
|
||||
p.status, p.published_at as "publishedAt", p.created_at as "createdAt",
|
||||
coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'title', c.title, 'status', c.status,
|
||||
'coverImageUrl', c.cover_image_url, 'position', pc.position
|
||||
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
|
||||
from learning_paths p
|
||||
left join learning_path_courses pc on pc.learning_path_id = p.id
|
||||
left join courses c on c.id = pc.course_id
|
||||
group by p.id
|
||||
order by p.created_at desc`,
|
||||
);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
|
||||
const courseIds = [...new Set(input.courseIds)];
|
||||
if (courseIds.length !== input.courseIds.length) throw new Error('A course can only appear once in a path');
|
||||
const courses = await client.query<{ id: string; status: string }>(
|
||||
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
|
||||
[courseIds],
|
||||
);
|
||||
if (courses.rowCount !== courseIds.length) throw new Error('Every path course must exist and cannot be archived');
|
||||
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
|
||||
throw new Error('Publish every course in this path before publishing the path');
|
||||
}
|
||||
return courseIds;
|
||||
};
|
||||
|
||||
const writePathCourses = async (client: PoolClient, pathId: string, courseIds: string[]) => {
|
||||
await client.query('delete from learning_path_courses where learning_path_id = $1', [pathId]);
|
||||
for (const [index, courseId] of courseIds.entries()) {
|
||||
await client.query(
|
||||
`insert into learning_path_courses (learning_path_id, course_id, position) values ($1, $2, $3)`,
|
||||
[pathId, courseId, index + 1],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
||||
@@ -57,6 +116,77 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
||||
|
||||
app.post('/paths', adminAccess, async (request, reply) => {
|
||||
const input = learningPathSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const courseIds = await validatePathCourses(client, input);
|
||||
const path = await client.query<{ id: string }>(
|
||||
`insert into learning_paths (
|
||||
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end)
|
||||
returning id`,
|
||||
[input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
||||
);
|
||||
await writePathCourses(client, path.rows[0].id, courseIds);
|
||||
await client.query('commit');
|
||||
await recordAudit({ actorId: request.user.id, action: 'path.created', subjectType: 'learning_path', subjectId: path.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
|
||||
const paths = await getLearningPaths();
|
||||
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/paths/:pathId', adminAccess, async (request, reply) => {
|
||||
const { pathId } = learningPathParamsSchema.parse(request.params);
|
||||
const input = learningPathSchema.parse(request.body);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const courseIds = await validatePathCourses(client, input);
|
||||
const path = await client.query<{ id: string }>(
|
||||
`update learning_paths set
|
||||
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5,
|
||||
cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status,
|
||||
published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
|
||||
where id = $1
|
||||
returning id`,
|
||||
[pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
|
||||
);
|
||||
if (!path.rows[0]) {
|
||||
await client.query('rollback');
|
||||
return reply.code(404).send({ error: 'Learning path not found' });
|
||||
}
|
||||
await writePathCourses(client, pathId, courseIds);
|
||||
await client.query('commit');
|
||||
await recordAudit({ actorId: request.user.id, action: 'path.updated', subjectType: 'learning_path', subjectId: pathId, metadata: { title: input.title }, ipAddress: request.ip });
|
||||
const paths = await getLearningPaths();
|
||||
return { data: paths.find((item) => item.id === pathId) };
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
|
||||
const { pathId } = learningPathParamsSchema.parse(request.params);
|
||||
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
|
||||
if (!result.rows[0]) return reply.code(404).send({ error: 'Learning path not found' });
|
||||
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.post('/categories', adminAccess, async (request, reply) => {
|
||||
const input = categorySchema.parse(request.body);
|
||||
const result = await pool.query(
|
||||
|
||||
@@ -148,6 +148,34 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
|
||||
});
|
||||
|
||||
// Paths intentionally reference existing courses instead of copying them.
|
||||
// Only a path and its published courses are visible to visitors.
|
||||
app.get('/paths', async () => {
|
||||
const result = await pool.query(
|
||||
`select
|
||||
p.id, p.title, p.description,
|
||||
coalesce(p.cover_image_url, (
|
||||
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||
)) as "coverImageUrl",
|
||||
p.cover_image_zoom as "coverImageZoom",
|
||||
p.cover_image_position_x as "coverImagePositionX",
|
||||
p.cover_image_position_y as "coverImagePositionY",
|
||||
coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'title', c.title, 'category', c.category,
|
||||
'coverImageUrl', coalesce(c.cover_image_url, (
|
||||
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||
)), 'position', pc.position
|
||||
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
|
||||
from learning_paths p
|
||||
join learning_path_courses pc on pc.learning_path_id = p.id
|
||||
join courses c on c.id = pc.course_id and c.status = 'published'
|
||||
where p.status = 'published'
|
||||
group by p.id
|
||||
order by p.published_at desc`,
|
||||
);
|
||||
return { data: result.rows };
|
||||
});
|
||||
|
||||
app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
|
||||
const result = await pool.query(
|
||||
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"
|
||||
|
||||
Reference in New Issue
Block a user