266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
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<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('/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 } };
|
|
});
|
|
};
|