feat: manage homepage banner carousel
This commit is contained in:
21
server/migrations/011_home_banners.sql
Normal file
21
server/migrations/011_home_banners.sql
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user