feat: manage homepage banner carousel
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 20s

This commit is contained in:
Cauê Faleiros
2026-09-04 16:44:34 -03:00
parent 09d85a7123
commit 5b86d6ef0d
7 changed files with 150 additions and 81 deletions

View File

@@ -0,0 +1,21 @@
-- Homepage banners are independent editorial content. A banner can either
-- reference a course (and always use its current cover) or use an uploaded
-- image stored in Bunny Storage.
create table home_banners (
id uuid primary key default gen_random_uuid(),
kind text not null check (kind in ('course', 'custom')),
course_id uuid references courses(id) on delete cascade,
image_url text,
title text not null default '',
description text not null default '',
position integer not null check (position > 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint home_banners_source_check check (
(kind = 'course' and course_id is not null and image_url is null) or
(kind = 'custom' and course_id is null and image_url is not null)
),
unique (position)
);
create trigger home_banners_set_updated_at before update on home_banners for each row execute function set_updated_at();

View File

@@ -24,6 +24,29 @@ const homeConfigurationSchema = z.object({
courseOrder: z.array(z.string().uuid()).max(500),
defaultCoverImageUrl: z.string().url().nullable(),
});
const homeBannersSchema = z.object({
banners: z.array(z.object({
kind: z.enum(['course', 'custom']),
courseId: z.string().uuid().nullable(),
imageUrl: z.string().url().nullable(),
title: z.string().trim().max(180).default(''),
description: z.string().trim().max(500).default(''),
}).superRefine((banner, context) => {
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Course banners need a course' });
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Custom banners need an image' });
})).max(10),
});
const selectHomeBanners = 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"
from home_banners b
left join courses c on c.id = b.course_id
order by b.position`,
);
return result.rows;
};
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
const learningPathSchema = z.object({
title: z.string().trim().min(1).max(160),
@@ -119,6 +142,32 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
app.put('/home-banners', adminAccess, async (request) => {
const input = homeBannersSchema.parse(request.body);
const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!);
if (courseIds.length) {
const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]);
if (courses.rowCount !== courseIds.length) return { error: 'Course banners must reference published courses' };
}
const client = await pool.connect();
try {
await client.query('begin');
await client.query('delete from home_banners');
for (const [index, banner] of input.banners.entries()) {
await client.query(
`insert into home_banners (kind, course_id, image_url, title, description, position)
values ($1, $2, $3, $4, $5, $6)`,
[banner.kind, banner.courseId, banner.imageUrl, banner.title, banner.description, index + 1],
);
}
await client.query('commit');
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
await recordAudit({ actorId: request.user.id, action: 'home.banners_updated', subjectType: 'platform', metadata: { count: input.banners.length }, ipAddress: request.ip });
return { data: await selectHomeBanners() };
});
app.post('/paths', adminAccess, async (request, reply) => {
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();

View File

@@ -176,6 +176,19 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
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"