feat: add editable ordered trail pages
This commit is contained in:
4
App.tsx
4
App.tsx
@@ -14,6 +14,8 @@ import { AccessTokenPage } from './pages/AccessTokenPage';
|
|||||||
import { MyLearning } from './pages/MyLearning';
|
import { MyLearning } from './pages/MyLearning';
|
||||||
import { ProfilePage } from './pages/ProfilePage';
|
import { ProfilePage } from './pages/ProfilePage';
|
||||||
import { InstructorProfile } from './pages/InstructorProfile';
|
import { InstructorProfile } from './pages/InstructorProfile';
|
||||||
|
import { TrailDetail } from './pages/TrailDetail';
|
||||||
|
import { TrailPage } from './pages/TrailPage';
|
||||||
|
|
||||||
// Protected Route Component
|
// Protected Route Component
|
||||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
||||||
@@ -94,6 +96,8 @@ function AppContent() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/admin/trilhas/:trailId" element={<ProtectedRoute allowedRoles={['superadmin']}><TrailDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/trilhas/:trailId" element={<TrailPage onPlay={setSelectedCourse} />} />
|
||||||
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
|
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
|
||||||
<Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
<Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||||
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
|
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getCommen
|
|||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
import { CourseCoverImage } from '../components/CourseCoverImage';
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||||
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
const formatVideoDuration = (seconds: number | null | undefined) => {
|
const formatVideoDuration = (seconds: number | null | undefined) => {
|
||||||
if (!seconds || seconds < 1) return 'Processando';
|
if (!seconds || seconds < 1) return 'Processando';
|
||||||
@@ -904,6 +905,7 @@ const CourseModal: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ManageCourses: React.FC = () => {
|
export const ManageCourses: React.FC = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
||||||
@@ -936,6 +938,17 @@ export const ManageCourses: React.FC = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const courseId = searchParams.get('editar');
|
||||||
|
if (!courseId || courses.length === 0) return;
|
||||||
|
const course = courses.find((item) => item.id === courseId);
|
||||||
|
if (course) {
|
||||||
|
setEditingCourse(course);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}, [courses, searchParams, setSearchParams]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingCourse(undefined);
|
setEditingCourse(undefined);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8"><StatCard icon={<Users className="w-5 h-5" />} label="Usuários" value={analytics?.totalUsers ?? users.length} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} /><StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores" value={instructorUsers} /><StatCard icon={<FolderKanban className="w-5 h-5" />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} /></div>
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8"><StatCard icon={<Users className="w-5 h-5" />} label="Usuários" value={analytics?.totalUsers ?? users.length} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} /><StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores" value={instructorUsers} /><StatCard icon={<FolderKanban className="w-5 h-5" />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} /><StatCard icon={<UserCheck className="w-5 h-5" />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} /></div>
|
||||||
<div className="mb-8 flex gap-1 overflow-x-auto border-b border-white/10"><TabButton active={activeTab === 'courses'} onClick={() => setActiveTab('courses')} label="Cursos" /><TabButton active={activeTab === 'trails'} onClick={() => setActiveTab('trails')} label="Trilhas" /><TabButton active={activeTab === 'home'} onClick={() => setActiveTab('home')} label="Página inicial" /><TabButton active={activeTab === 'access'} onClick={() => setActiveTab('access')} label="Acessos" /></div>
|
<div className="mb-8 flex gap-1 overflow-x-auto border-b border-white/10"><TabButton active={activeTab === 'courses'} onClick={() => setActiveTab('courses')} label="Cursos" /><TabButton active={activeTab === 'trails'} onClick={() => setActiveTab('trails')} label="Trilhas" /><TabButton active={activeTab === 'home'} onClick={() => setActiveTab('home')} label="Página inicial" /><TabButton active={activeTab === 'access'} onClick={() => setActiveTab('access')} label="Acessos" /></div>
|
||||||
{activeTab === 'courses' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />}
|
{activeTab === 'courses' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />}
|
||||||
{activeTab === 'trails' && <TrailManager trails={categories} newTrail={newCategory} setNewTrail={setNewCategory} onCreate={createCategory} onToggle={toggleCategory} />}
|
{activeTab === 'trails' && <TrailManager trails={paths} onNew={() => navigate('/admin/trilhas/nova')} onOpen={(trail) => navigate(`/admin/trilhas/${trail.id}`)} />}
|
||||||
{activeTab === 'home' && <><BannerManager courses={homeConfiguration?.courses || []} /><HomepageConfigurationPanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} setHomeConfiguration={setHomeConfiguration} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} /></>}
|
{activeTab === 'home' && <><BannerManager courses={homeConfiguration?.courses || []} /><HomepageConfigurationPanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} setHomeConfiguration={setHomeConfiguration} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} /></>}
|
||||||
{activeTab === 'access' && <UserManagementPanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} updatingUserId={updatingUserId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onDeleteUser={deleteUser} onResetPassword={resetPassword} onExport={exportUsers} />}
|
{activeTab === 'access' && <UserManagementPanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} updatingUserId={updatingUserId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onDeleteUser={deleteUser} onResetPassword={resetPassword} onExport={exportUsers} />}
|
||||||
{isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />}
|
{isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />}
|
||||||
@@ -69,7 +69,7 @@ const TabButton: React.FC<{ active: boolean; onClick: () => void; label: string
|
|||||||
const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => <div className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex items-center gap-2 text-orange-400 mb-3">{icon}<span className="text-xs font-semibold uppercase tracking-wider">{label}</span></div><div className="text-3xl font-bold text-white">{value}</div></div>;
|
const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => <div className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><div className="flex items-center gap-2 text-orange-400 mb-3">{icon}<span className="text-xs font-semibold uppercase tracking-wider">{label}</span></div><div className="text-3xl font-bold text-white">{value}</div></div>;
|
||||||
const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => <div className="rounded-xl bg-white/[0.03] px-4 py-3"><div className="text-xs text-gray-500">{label}</div><div className="text-sm font-semibold text-white mt-1">{value}</div></div>;
|
const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => <div className="rounded-xl bg-white/[0.03] px-4 py-3"><div className="text-xs text-gray-500">{label}</div><div className="text-sm font-semibold text-white mt-1">{value}</div></div>;
|
||||||
|
|
||||||
const TrailManager: React.FC<{ trails: Array<{ id: string; name: string; position: number; isActive: boolean }>; newTrail: string; setNewTrail: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (trail: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ trails, newTrail, setNewTrail, onCreate, onToggle }) => <section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden"><div className="flex items-center justify-between border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">Trilhas</h2><p className="mt-1 text-sm text-gray-500">Organize os temas que agrupam os cursos da plataforma.</p></div></div><form onSubmit={onCreate} className="flex max-w-xl gap-2 p-5 pb-4"><input value={newTrail} onChange={(event) => setNewTrail(event.target.value)} placeholder="Nome da nova trilha" className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white" /><button className="rounded-xl bg-orange-500 px-4 text-sm font-semibold text-white">Nova trilha</button></form><div className="divide-y divide-white/5">{trails.length === 0 ? <p className="p-10 text-center text-sm text-gray-500">Nenhuma trilha criada ainda.</p> : trails.map((trail) => <div key={trail.id} className="flex items-center gap-3 px-5 py-3"><span className="flex-1 font-medium text-white">{trail.name}</span><button onClick={() => onToggle(trail)} className={`rounded-full px-3 py-1.5 text-xs font-semibold ${trail.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-500'}`}>{trail.isActive ? 'Ativa' : 'Inativa'}</button></div>)}</div></section>;
|
const TrailManager: React.FC<{ trails: LearningPath[]; onNew: () => void; onOpen: (trail: LearningPath) => void }> = ({ trails, onNew, onOpen }) => <section className="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80"><div className="flex items-center justify-between gap-4 border-b border-white/10 p-5"><div><h2 className="text-lg font-bold text-white">Trilhas</h2><p className="mt-1 text-sm text-gray-500">Sequências ordenadas de cursos. Abra uma trilha para editar sua capa, descrição e conteúdo.</p></div><button onClick={onNew} className="inline-flex shrink-0 items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white hover:bg-orange-600"><Plus className="h-4 w-4" /> Nova trilha</button></div><div className="divide-y divide-white/5">{trails.length === 0 ? <p className="p-10 text-center text-sm text-gray-500">Nenhuma trilha criada ainda.</p> : trails.map((trail) => <button key={trail.id} onClick={() => onOpen(trail)} className="flex w-full items-center gap-4 px-5 py-4 text-left transition hover:bg-white/[0.03]"><div className="h-14 w-24 shrink-0 overflow-hidden rounded-xl bg-zinc-900">{trail.coverImageUrl ? <CourseCoverImage source={trail.coverImageUrl} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center"><FolderKanban className="h-6 w-6 text-orange-400" /></div>}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="truncate font-semibold text-white">{trail.title}</p><span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase ${trail.status === 'published' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-amber-500/15 text-amber-300'}`}>{trail.status === 'published' ? 'Publicada' : 'Rascunho'}</span></div><p className="mt-1 truncate text-xs text-gray-500">{trail.courses.length} {trail.courses.length === 1 ? 'curso' : 'cursos'} · {trail.description || 'Sem descrição'}</p></div><span className="inline-flex items-center gap-1.5 text-xs font-semibold text-orange-300">Gerenciar <Pencil className="h-3.5 w-3.5" /></span></button>)}</div></section>;
|
||||||
|
|
||||||
const CategoryManager: React.FC<{ categories: Array<{ id: string; name: string; position: number; isActive: boolean }>; newCategory: string; setNewCategory: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (category: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ categories, newCategory, setNewCategory, onCreate, onToggle }) => <section className="mt-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><h2 className="text-lg font-bold text-white">Categorias das trilhas</h2><p className="mt-1 text-sm text-gray-500">Organize os temas disponíveis para cursos e trilhas.</p><form onSubmit={onCreate} className="mt-4 flex max-w-xl gap-2"><input value={newCategory} onChange={(event) => setNewCategory(event.target.value)} placeholder="Nome da categoria" className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white" /><button className="rounded-xl bg-white/10 px-4 text-sm font-semibold text-white hover:bg-white/15">Adicionar</button></form><div className="mt-4 flex flex-wrap gap-2">{categories.map((category) => <button key={category.id} onClick={() => onToggle(category)} className={`rounded-full px-3 py-1.5 text-xs font-semibold ${category.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-500 line-through'}`}>{category.name}</button>)}</div></section>;
|
const CategoryManager: React.FC<{ categories: Array<{ id: string; name: string; position: number; isActive: boolean }>; newCategory: string; setNewCategory: (value: string) => void; onCreate: (event: React.FormEvent) => void; onToggle: (category: { id: string; name: string; position: number; isActive: boolean }) => void }> = ({ categories, newCategory, setNewCategory, onCreate, onToggle }) => <section className="mt-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-5"><h2 className="text-lg font-bold text-white">Categorias das trilhas</h2><p className="mt-1 text-sm text-gray-500">Organize os temas disponíveis para cursos e trilhas.</p><form onSubmit={onCreate} className="mt-4 flex max-w-xl gap-2"><input value={newCategory} onChange={(event) => setNewCategory(event.target.value)} placeholder="Nome da categoria" className="min-w-0 flex-1 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white" /><button className="rounded-xl bg-white/10 px-4 text-sm font-semibold text-white hover:bg-white/15">Adicionar</button></form><div className="mt-4 flex flex-wrap gap-2">{categories.map((category) => <button key={category.id} onClick={() => onToggle(category)} className={`rounded-full px-3 py-1.5 text-xs font-semibold ${category.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-500 line-through'}`}>{category.name}</button>)}</div></section>;
|
||||||
|
|
||||||
|
|||||||
100
pages/TrailDetail.tsx
Normal file
100
pages/TrailDetail.tsx
Normal file
File diff suppressed because one or more lines are too long
34
pages/TrailPage.tsx
Normal file
34
pages/TrailPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, BookOpen, Loader2 } from 'lucide-react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Course } from '../types';
|
||||||
|
import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
|
||||||
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||||
|
import { CourseCard } from '../components/CourseCard';
|
||||||
|
|
||||||
|
export const TrailPage: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
|
||||||
|
const { trailId } = useParams<{ trailId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [trail, setTrail] = useState<PublicLearningPath | null>(null);
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trailId) return;
|
||||||
|
Promise.all([getLearningPaths(), getCourses()])
|
||||||
|
.then(([paths, availableCourses]) => {
|
||||||
|
const found = paths.find((item) => item.id === trailId) || null;
|
||||||
|
setTrail(found);
|
||||||
|
if (found) {
|
||||||
|
const byId = new Map(availableCourses.map((course) => [course.id, course]));
|
||||||
|
setCourses(found.courses.map((course) => byId.get(course.id)).filter((course): course is Course => Boolean(course)));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [trailId]);
|
||||||
|
|
||||||
|
if (isLoading) return <main className="flex min-h-[65vh] items-center justify-center pt-24"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
|
||||||
|
if (!trail) return <main className="mx-auto flex min-h-[65vh] max-w-6xl items-center px-6 pt-24"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha indisponível</p><h1 className="mt-3 text-3xl font-bold text-white">Esta trilha não está publicada.</h1></div></main>;
|
||||||
|
|
||||||
|
return <main className="min-h-screen pb-20 pt-20"><section className="relative overflow-hidden border-b border-white/10 bg-zinc-950"><div className="absolute inset-0 opacity-30">{trail.coverImageUrl && <CourseCoverImage source={trail.coverImageUrl} alt="" className="h-full w-full object-cover" style={{ transform: `scale(${trail.coverImageZoom})`, objectPosition: `${trail.coverImagePositionX}% ${trail.coverImagePositionY}%` }} />}</div><div className="absolute inset-0 bg-gradient-to-r from-black via-black/90 to-black/45" /><div className="relative mx-auto max-w-6xl px-6 py-14 sm:px-8 sm:py-20"><button onClick={() => navigate('/')} className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-gray-300 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Voltar para cursos</button><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha de cursos</p><h1 className="mt-3 max-w-3xl text-4xl font-bold tracking-tight text-white sm:text-5xl">{trail.title}</h1>{trail.description && <p className="mt-5 max-w-2xl text-base leading-7 text-gray-300">{trail.description}</p>}<div className="mt-7 inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-4 py-2 text-sm font-semibold text-white"><BookOpen className="h-4 w-4 text-orange-400" />{courses.length} {courses.length === 1 ? 'curso na trilha' : 'cursos na trilha'}</div></div></section><section className="mx-auto max-w-6xl px-6 py-12 sm:px-8"><div className="mb-7"><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua sequência</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos da trilha</h2></div>{courses.length ? <div className="grid gap-7 sm:grid-cols-2 lg:grid-cols-3">{courses.map((course, index) => <div key={course.id}><p className="mb-3 text-xs font-bold text-orange-300">{String(index + 1).padStart(2, '0')} · Curso</p><CourseCard course={course} variant="landscape" onClick={onPlay} /></div>)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-10 text-center text-sm text-gray-500">Nenhum curso publicado nesta trilha.</div>}</section></main>;
|
||||||
|
};
|
||||||
20
server/migrations/015_seed_trails_from_legacy_categories.sql
Normal file
20
server/migrations/015_seed_trails_from_legacy_categories.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Course categories were previously presented as trilhas but only stored a
|
||||||
|
-- label. Turn every existing label into a real ordered trilha without moving
|
||||||
|
-- or deleting any course.
|
||||||
|
insert into learning_paths (title, description, status, published_at)
|
||||||
|
select category.name, '', 'draft', null
|
||||||
|
from course_categories category
|
||||||
|
where not exists (
|
||||||
|
select 1 from learning_paths path where lower(path.title) = lower(category.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into learning_path_courses (learning_path_id, course_id, position)
|
||||||
|
select path.id, course.id, ranked.position
|
||||||
|
from (
|
||||||
|
select id, category, row_number() over (partition by category order by published_at nulls last, title, id)::integer as position
|
||||||
|
from courses
|
||||||
|
where status <> 'archived'
|
||||||
|
) ranked
|
||||||
|
join courses course on course.id = ranked.id
|
||||||
|
join learning_paths path on lower(path.title) = lower(ranked.category)
|
||||||
|
on conflict (learning_path_id, course_id) do nothing;
|
||||||
@@ -271,7 +271,18 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { courseId } = paramsSchema.parse(request.params);
|
const { courseId } = paramsSchema.parse(request.params);
|
||||||
const accessError = await assertCanManageCourse(courseId, request.user);
|
const accessError = await assertCanManageCourse(courseId, request.user);
|
||||||
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
|
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
|
||||||
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
await client.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
|
||||||
|
await client.query('delete from learning_path_courses where course_id = $1', [courseId]);
|
||||||
|
await client.query('commit');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user