import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { pool } from '../db/pool.js'; import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js'; import { BunnyStorageConfigurationError, BunnyStorageRequestError, downloadBunnyCover } from '../providers/bunny-storage.js'; const paramsSchema = z.object({ courseId: z.string().uuid(), }); const playbackParamsSchema = z.object({ courseId: z.string().uuid(), lessonId: z.string().uuid(), }); const coverParamsSchema = z.object({ filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i), }); 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.cover_image_position_x as "coverImagePositionX", c.cover_image_position_y as "coverImagePositionY", 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) => ({ ...course, assets: [], lessons: Array.isArray(course.lessons) ? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] })) : [], }); const hasSession = async (request: { jwtVerify: () => Promise }) => { 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('/covers/:filename', async (request, reply) => { const { filename } = coverParamsSchema.parse(request.params); try { const cover = await downloadBunnyCover(filename); reply.header('Content-Type', cover.contentType); reply.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800'); return reply.send(cover.body); } catch (error) { if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) { return reply.code(404).send({ error: 'Imagem de capa não encontrada.' }); } throw error; } }); 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) }; }); // 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('/home-banners', async () => { const result = await pool.query( `select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position, c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl", c.cover_image_zoom as "coverImageZoom", c.cover_image_position_x as "coverImagePositionX", c.cover_image_position_y as "coverImagePositionY" from home_banners b left join courses c on c.id = b.course_id and c.status = 'published' where b.kind = 'custom' or c.id is not null order by b.position`, ); 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" 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: 'Curso não encontrado.' }); } 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: 'Mídia da aula não encontrada.' }); if (media.status === 'processing') return reply.code(409).send({ error: 'Este vídeo ainda está sendo processado pelo Bunny Stream.' }); if (media.status === 'failed') return reply.code(409).send({ error: 'Este vídeo não pôde ser processado. Entre em contato com o instrutor.' }); 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: 'A reprodução do Bunny ainda não está configurada.' }); throw error; } } const source = media.playback_url || media.embed_url; if (!source) return reply.code(409).send({ error: 'Esta aula não possui um endereço de vídeo reproduzível.' }); return { data: { provider: media.provider, kind: 'video', source } }; }); };