feat: add managed learning paths
All checks were successful
CI / Validate frontend and API (push) Successful in 41s
CI / Build and publish Docker images (push) Successful in 23s

This commit is contained in:
Cauê Faleiros
2026-09-04 16:22:09 -03:00
parent e18bb76630
commit 79d1c70c44
9 changed files with 358 additions and 337 deletions

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CourseRow } from './CourseRow'; import { CourseRow } from './CourseRow';
import { Section, Course } from '../types'; import { Section, Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react'; import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { LearningPathRow } from './LearningPathRow';
interface CourseGridProps { interface CourseGridProps {
onCourseSelect?: (course: Course) => void; onCourseSelect?: (course: Course) => void;
@@ -15,6 +16,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
selectedCategoryFilter selectedCategoryFilter
}) => { }) => {
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [paths, setPaths] = useState<PublicLearningPath[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState<string>('all'); const [activeCategory, setActiveCategory] = useState<string>('all');
const { user } = useAuth(); const { user } = useAuth();
@@ -39,7 +41,9 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setCourses(await getCourses()); const [loadedCourses, loadedPaths] = await Promise.all([getCourses(), getLearningPaths()]);
setCourses(loadedCourses);
setPaths(loadedPaths);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -138,11 +142,6 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
variant: 'landscape', variant: 'landscape',
courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0)) courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0))
}, },
{
title: 'Formações Recomendadas',
variant: 'portrait',
courses: courses
}
]; ];
return ( return (
@@ -238,6 +237,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
/> />
) )
))} ))}
{activeCategory === 'all' && paths.length > 0 && <LearningPathRow paths={paths} courses={courses} onCourseSelect={onCourseSelect} />}
</section> </section>
); );
}; };

View File

@@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { ArrowRight, Check, Layers3, X } from 'lucide-react';
import { Course } from '../types';
import { PublicLearningPath } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface LearningPathRowProps {
paths: PublicLearningPath[];
courses: Course[];
onCourseSelect?: (course: Course) => void;
}
export const LearningPathRow: React.FC<LearningPathRowProps> = ({ paths, courses, onCourseSelect }) => {
const [selectedPath, setSelectedPath] = useState<PublicLearningPath | null>(null);
const courseById = new Map(courses.map((course) => [course.id, course]));
return <>
<section className="mx-auto w-full max-w-[1440px] px-6 py-6 md:px-12">
<div className="mb-4 flex items-baseline gap-3">
<h2 className="font-display text-xl font-bold text-gray-100 md:text-2xl">Formações</h2>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">{paths.length} {paths.length === 1 ? 'formação' : 'formações'}</span>
</div>
<div className="flex gap-5 overflow-x-auto pb-4 hide-scrollbar snap-x snap-mandatory">
{paths.map((path) => <button key={path.id} onClick={() => setSelectedPath(path)} className="group relative w-[300px] shrink-0 snap-start overflow-hidden rounded-xl border border-white/10 bg-zinc-950 text-left shadow-xl transition hover:-translate-y-1 hover:border-orange-500/60">
<div className="relative aspect-[16/8] overflow-hidden bg-zinc-900">
{path.coverImageUrl ? <CourseCoverImage source={path.coverImageUrl} alt={path.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" style={{ transform: `scale(${path.coverImageZoom || 1})`, objectPosition: `${path.coverImagePositionX ?? 50}% ${path.coverImagePositionY ?? 50}%` }} /> : <div className="flex h-full items-center justify-center bg-gradient-to-br from-orange-500/30 to-zinc-900"><Layers3 className="h-9 w-9 text-orange-300" /></div>}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
<span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'}</span>
</div>
<div className="p-4"><h3 className="truncate font-semibold text-white group-hover:text-orange-300">{path.title}</h3><p className="mt-1 line-clamp-2 min-h-10 text-sm leading-relaxed text-gray-500">{path.description || 'Uma trilha organizada para avançar passo a passo.'}</p><span className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-orange-400">Ver formação <ArrowRight className="h-4 w-4" /></span></div>
</button>)}
</div>
</section>
{selectedPath && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={selectedPath.title}>
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-6"><div><div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400"><Layers3 className="h-4 w-4" /> Formação</div><h2 className="mt-2 text-2xl font-bold text-white">{selectedPath.title}</h2><p className="mt-2 text-sm leading-relaxed text-gray-400">{selectedPath.description}</p></div><button onClick={() => setSelectedPath(null)} className="rounded-full p-2 text-gray-400 hover:bg-white/10 hover:text-white" aria-label="Fechar"><X className="h-5 w-5" /></button></div><ol className="space-y-2 p-5">{selectedPath.courses.map((pathCourse, index) => { const course = courseById.get(pathCourse.id); return <li key={pathCourse.id}><button onClick={() => { if (course) { setSelectedPath(null); onCourseSelect?.(course); } }} disabled={!course} className="flex w-full items-center gap-4 rounded-xl bg-white/[0.03] px-4 py-3 text-left transition hover:bg-white/[0.07] disabled:cursor-default"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-500/15 text-xs font-bold text-orange-400">{index + 1}</span><span className="min-w-0 flex-1"><span className="block truncate font-semibold text-white">{pathCourse.title}</span><span className="mt-0.5 block text-xs text-gray-500">{pathCourse.category}</span></span>{course && <span className="text-xs font-semibold text-orange-400">Abrir curso</span>}</button></li>; })}</ol><div className="border-t border-white/10 p-4 text-center text-xs text-gray-500"><Check className="mr-1 inline h-3.5 w-3.5 text-emerald-400" /> Siga os cursos nesta ordem.</div></div>
</div>}
</>;
};

View File

@@ -68,6 +68,21 @@ h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
user-select: none; user-select: none;
} }
/* Shared native controls used inside focused admin dialogs. */
.input {
width: 100%;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 0.75rem;
background: #18181b;
padding: 0.625rem 0.75rem;
color: #fff;
font-size: 0.875rem;
}
.input::placeholder {
color: #71717a;
}
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
-- A learning path is deliberately only a curated, ordered collection of
-- existing courses. Lessons remain owned by their courses, so a course edit
-- is reflected everywhere that course appears.
create table learning_paths (
id uuid primary key default gen_random_uuid(),
title text not null check (char_length(trim(title)) between 1 and 160),
description text not null default '',
cover_image_url text,
cover_image_zoom real not null default 1 check (cover_image_zoom between 1 and 2.5),
cover_image_position_x real not null default 50 check (cover_image_position_x between 0 and 100),
cover_image_position_y real not null default 50 check (cover_image_position_y between 0 and 100),
status course_status not null default 'draft',
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint learning_paths_published_at_check check (
(status = 'published' and published_at is not null) or status <> 'published'
)
);
create table learning_path_courses (
learning_path_id uuid not null references learning_paths(id) on delete cascade,
course_id uuid not null references courses(id) on delete restrict,
position integer not null check (position > 0),
primary key (learning_path_id, course_id),
unique (learning_path_id, position)
);
create index learning_paths_published_index on learning_paths (published_at desc) where status = 'published';
create index learning_path_courses_position_index on learning_path_courses (learning_path_id, position);
create trigger learning_paths_set_updated_at before update on learning_paths for each row execute function set_updated_at();

View File

@@ -1,4 +1,5 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import type { PoolClient } from 'pg';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { createRawToken, hashToken } from '../auth/account-tokens.js'; import { createRawToken, hashToken } from '../auth/account-tokens.js';
@@ -23,6 +24,64 @@ const homeConfigurationSchema = z.object({
courseOrder: z.array(z.string().uuid()).max(500), courseOrder: z.array(z.string().uuid()).max(500),
defaultCoverImageUrl: z.string().url().nullable(), defaultCoverImageUrl: z.string().url().nullable(),
}); });
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
const learningPathSchema = z.object({
title: z.string().trim().min(1).max(160),
description: z.string().trim().max(2_000).default(''),
coverImageUrl: z.string().url().nullable().default(null),
coverImageZoom: z.number().min(1).max(2.5).default(1),
coverImagePositionX: z.number().min(0).max(100).default(50),
coverImagePositionY: z.number().min(0).max(100).default(50),
status: z.enum(['draft', 'published']).default('draft'),
courseIds: z.array(z.string().uuid()).min(1).max(100),
});
type LearningPathInput = z.infer<typeof learningPathSchema>;
const getLearningPaths = async () => {
const result = await pool.query(
`select
p.id, p.title, p.description, p.cover_image_url as "coverImageUrl",
p.cover_image_zoom as "coverImageZoom",
p.cover_image_position_x as "coverImagePositionX",
p.cover_image_position_y as "coverImagePositionY",
p.status, p.published_at as "publishedAt", p.created_at as "createdAt",
coalesce(jsonb_agg(jsonb_build_object(
'id', c.id, 'title', c.title, 'status', c.status,
'coverImageUrl', c.cover_image_url, 'position', pc.position
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
from learning_paths p
left join learning_path_courses pc on pc.learning_path_id = p.id
left join courses c on c.id = pc.course_id
group by p.id
order by p.created_at desc`,
);
return result.rows;
};
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
const courseIds = [...new Set(input.courseIds)];
if (courseIds.length !== input.courseIds.length) throw new Error('A course can only appear once in a path');
const courses = await client.query<{ id: string; status: string }>(
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
[courseIds],
);
if (courses.rowCount !== courseIds.length) throw new Error('Every path course must exist and cannot be archived');
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
throw new Error('Publish every course in this path before publishing the path');
}
return courseIds;
};
const writePathCourses = async (client: PoolClient, pathId: string, courseIds: string[]) => {
await client.query('delete from learning_path_courses where learning_path_id = $1', [pathId]);
for (const [index, courseId] of courseIds.entries()) {
await client.query(
`insert into learning_path_courses (learning_path_id, course_id, position) values ($1, $2, $3)`,
[pathId, courseId, index + 1],
);
}
};
export const adminRoutes: FastifyPluginAsync = async (app) => { export const adminRoutes: FastifyPluginAsync = async (app) => {
const adminAccess = { preHandler: app.requireRoles(['admin']) }; const adminAccess = { preHandler: app.requireRoles(['admin']) };
@@ -57,6 +116,77 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
return { data: result.rows }; return { data: result.rows };
}); });
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
app.post('/paths', adminAccess, async (request, reply) => {
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>(
`insert into learning_paths (
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at
) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end)
returning id`,
[input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
);
await writePathCourses(client, path.rows[0].id, courseIds);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'path.created', subjectType: 'learning_path', subjectId: path.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
const paths = await getLearningPaths();
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
} catch (error) {
await client.query('rollback');
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
}
});
app.patch('/paths/:pathId', adminAccess, async (request, reply) => {
const { pathId } = learningPathParamsSchema.parse(request.params);
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>(
`update learning_paths set
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5,
cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status,
published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
where id = $1
returning id`,
[pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
);
if (!path.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Learning path not found' });
}
await writePathCourses(client, pathId, courseIds);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'path.updated', subjectType: 'learning_path', subjectId: pathId, metadata: { title: input.title }, ipAddress: request.ip });
const paths = await getLearningPaths();
return { data: paths.find((item) => item.id === pathId) };
} catch (error) {
await client.query('rollback');
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
}
});
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
const { pathId } = learningPathParamsSchema.parse(request.params);
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
if (!result.rows[0]) return reply.code(404).send({ error: 'Learning path not found' });
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
return reply.code(204).send();
});
app.post('/categories', adminAccess, async (request, reply) => { app.post('/categories', adminAccess, async (request, reply) => {
const input = categorySchema.parse(request.body); const input = categorySchema.parse(request.body);
const result = await pool.query( const result = await pool.query(

View File

@@ -148,6 +148,34 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) }; 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('/me/learning', { preHandler: app.authenticate }, async (request) => { app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query( const result = await pool.query(
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity" `select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"

View File

@@ -27,6 +27,36 @@ export interface InstructorAnalytics { courses: number; lessons: number; learner
export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; } export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; }
export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; } export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; }
export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; } export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; }
export interface LearningPathCourse {
id: string;
title: string;
status: 'draft' | 'published' | 'archived';
coverImageUrl: string | null;
position: number;
}
export interface LearningPath {
id: string;
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published' | 'archived';
publishedAt: string | null;
createdAt: string;
courses: LearningPathCourse[];
}
export interface LearningPathInput {
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published';
courseIds: string[];
}
interface Session { interface Session {
token: string; token: string;
@@ -134,6 +164,10 @@ export const adminApi = {
async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); }, async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); },
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); }, async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); }, async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/admin/home-configuration'); }, async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/admin/home-configuration'); },
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); }, async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
}; };

View File

@@ -44,6 +44,23 @@ type ApiCourse = {
assets: ApiAsset[]; assets: ApiAsset[];
}; };
export type PublicLearningPath = {
id: string;
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
courses: Array<{
id: string;
title: string;
category: string;
coverImageUrl: string | null;
position: number;
}>;
};
const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => { const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => {
if (kind === 'document') return 'doc'; if (kind === 'document') return 'doc';
if (kind === 'archive') return 'zip'; if (kind === 'archive') return 'zip';
@@ -173,6 +190,11 @@ export const getCourses = async (): Promise<Course[]> => {
return response.data.map(toCourse); return response.data.map(toCourse);
}; };
export const getLearningPaths = async (): Promise<PublicLearningPath[]> => {
const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths');
return response.data;
};
export const getManagedCourses = async (): Promise<Course[]> => { export const getManagedCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses'); const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
return response.data.map(toCourse); return response.data.map(toCourse);