Files
compor-academy/server/src/routes/courses.ts
Cauê Faleiros ad860074fd
All checks were successful
CI / Validate frontend and API (push) Successful in 1m10s
CI / Build and publish Docker images (push) Successful in 50s
feat: add resilient cover previews and zoom
2026-09-04 14:29:07 -03:00

204 lines
7.4 KiB
TypeScript

import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
});
const playbackParamsSchema = z.object({
courseId: z.string().uuid(),
lessonId: z.string().uuid(),
});
export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select
c.id,
c.slug,
c.title,
c.description,
c.category,
coalesce(c.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl",
c.cover_image_zoom as "coverImageZoom",
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,
'externalId', lm.external_id,
'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 ${includeUnreadyMedia ? '' : "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('/categories', async () => {
const result = await pool.query<{ name: string }>(`select name from course_categories where is_active order by position, name`);
return { data: result.rows.map((row) => row.name) };
});
app.get('/', async (request) => {
const authenticated = await hasSession(request);
const result = await pool.query(
`${courseSelect()}
left join platform_settings featured_setting on featured_setting.key = 'home.featuredCourseId'
left join platform_settings order_setting on order_setting.key = 'home.courseOrder'
where c.status = 'published'
order by
case when c.id::text = coalesce(featured_setting.value #>> '{}', '') then 0 else 1 end,
coalesce((
select position::int
from jsonb_array_elements_text(coalesce(order_setting.value, '[]'::jsonb)) with ordinality as ordered(id, position)
where ordered.id = c.id::text
), 999999),
c.published_at desc`,
);
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
});
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"
from (
${courseSelect()}
where c.status = 'published'
) base
left join lateral (
select
case when count(l.id) = 0 then 0
else floor(100.0 * count(l.id) filter (where lp.completed_at is not null) / count(l.id))::int end as progress,
max(lp.updated_at) as "lastActivity"
from lessons l
left join lesson_progress lp on lp.lesson_id = l.id and lp.user_id = $1
where l.course_id = base.id
) progress on true
where progress."lastActivity" is not null
order by progress."lastActivity" desc`,
[request.user.id],
);
return { data: result.rows };
});
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) };
});
app.get('/:courseId/lessons/:lessonId/playback', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId, lessonId } = playbackParamsSchema.parse(request.params);
const result = await pool.query<{
provider: string;
external_id: string;
playback_url: string | null;
embed_url: string | null;
status: string;
}>(
`select lm.provider, lm.external_id, lm.playback_url, lm.embed_url, lm.status
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where c.id = $1 and l.id = $2 and c.status = 'published'
order by lm.created_at
limit 1`,
[courseId, lessonId],
);
const media = result.rows[0];
if (!media) return reply.code(404).send({ error: 'Lesson media was not found' });
if (media.status === 'processing') return reply.code(409).send({ error: 'This video is still being processed by Bunny Stream.' });
if (media.status === 'failed') return reply.code(409).send({ error: 'This video could not be processed. Please contact the instructor.' });
if (media.provider === 'bunny') {
try {
const playback = signedBunnyEmbedUrl(media.external_id);
return { data: { provider: 'bunny', kind: 'embed', ...playback } };
} catch (error) {
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'Bunny playback is not configured yet.' });
throw error;
}
}
const source = media.playback_url || media.embed_url;
if (!source) return reply.code(409).send({ error: 'This lesson does not have a playable video URL.' });
return { data: { provider: media.provider, kind: 'video', source } };
});
};