Compare commits

..

13 Commits

Author SHA1 Message Date
Cauê Faleiros
95a4bd4ac7 fix: download bunny materials through academy
All checks were successful
CI / Validate frontend and API (push) Successful in 52s
CI / Build and publish Docker images (push) Successful in 24s
2026-09-09 16:20:49 -03:00
Cauê Faleiros
a861644f6c feat: add batch material uploads and links
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-09 14:24:54 -03:00
Cauê Faleiros
6981092589 feat: upload course materials to bunny storage
All checks were successful
CI / Validate frontend and API (push) Successful in 2m1s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-09 14:16:00 -03:00
Cauê Faleiros
61b7bba5af fix: make trail course selection explicit
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-08 16:37:18 -03:00
Cauê Faleiros
81a67bf123 fix: load trail courses from course manager
All checks were successful
CI / Validate frontend and API (push) Successful in 30s
CI / Build and publish Docker images (push) Successful in 18s
2026-09-08 16:27:00 -03:00
Cauê Faleiros
d9184372ca fix: show specific validation errors 2026-09-08 16:24:15 -03:00
Cauê Faleiros
61359a22a5 fix: allow editing empty draft trails
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-08 16:10:53 -03:00
Cauê Faleiros
bdaff3f5df fix: rename draft status to not published 2026-09-08 16:03:47 -03:00
Cauê Faleiros
f5aa826b32 feat: add editable ordered trail pages
All checks were successful
CI / Validate frontend and API (push) Successful in 36s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-08 15:53:20 -03:00
Cauê Faleiros
aec9c001d3 fix: simplify materials library header 2026-09-08 15:41:42 -03:00
Cauê Faleiros
10c3d03d9c feat: simplify materials library and use real metadata
All checks were successful
CI / Validate frontend and API (push) Successful in 34s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-08 15:22:45 -03:00
Cauê Faleiros
54f598cd99 feat: let students customize public profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 1m2s
CI / Build and publish Docker images (push) Successful in 23s
2026-09-08 14:46:16 -03:00
Cauê Faleiros
ad736758a7 fix: localize validation and api messages
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-08 13:40:38 -03:00
33 changed files with 821 additions and 1161 deletions

View File

@@ -48,6 +48,7 @@ BUNNY_STORAGE_PASSWORD=
BUNNY_STORAGE_ENDPOINT=
BUNNY_STORAGE_CDN_HOST=
BUNNY_COVER_MAX_UPLOAD_MB=10
BUNNY_ASSET_MAX_UPLOAD_MB=100
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api

View File

@@ -14,6 +14,8 @@ import { AccessTokenPage } from './pages/AccessTokenPage';
import { MyLearning } from './pages/MyLearning';
import { ProfilePage } from './pages/ProfilePage';
import { InstructorProfile } from './pages/InstructorProfile';
import { TrailDetail } from './pages/TrailDetail';
import { TrailPage } from './pages/TrailPage';
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
@@ -94,9 +96,12 @@ function AppContent() {
</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="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/perfis/:profileId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/invite" element={<AccessTokenPage mode="invite" />} />
<Route path="/reset-password" element={<AccessTokenPage mode="reset" />} />

View File

@@ -38,9 +38,10 @@ BUNNY_STORAGE_PASSWORD=the-storage-zone-password
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
BUNNY_COVER_MAX_UPLOAD_MB=10
BUNNY_ASSET_MAX_UPLOAD_MB=100
```
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload only JPG, PNG, or WebP cover images; the API verifies the image bytes and uploads them under a generated `covers/` filename. The CDN URL is then saved on the course.
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload JPG, PNG, or WebP cover images and PDF, DOCX, XLSX, CSV, or ZIP support materials; the API validates the file bytes and determines the material type and size automatically. Covers are stored under `covers/` and course materials under `materials/`.
### Bunny processing webhooks

View File

@@ -25,6 +25,18 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
if (!email.trim() || !/^\S+@\S+\.\S+$/.test(email)) {
setError('Informe um e-mail válido.');
return;
}
if (isRegistering && name.trim().length < 2) {
setError('Informe seu nome.');
return;
}
if (password.length < 8) {
setError('A senha deve ter pelo menos 8 caracteres.');
return;
}
setIsSubmitting(true);
if (isRegistering && role === 'professor') {
@@ -83,11 +95,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<form noValidate onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3">
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />}
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} />
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" autoFocus />}
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" autoFocus={!isRegistering} />
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" />
</div>
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}

View File

@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import { Attachment, AttachmentType } from '../types';
import { useToast } from '../context/ToastContext';
import { downloadCourseAsset } from '../services/api';
interface MaterialCardProps {
attachment: Attachment;
@@ -67,32 +68,29 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
const { showDownloadToast, showToast } = useToast();
const badge = getAttachmentBadge(attachment.type);
const handleAction = (e: React.MouseEvent) => {
setDownloaded(true);
const handleAction = async () => {
if (attachment.type === 'link') {
showToast(`Acessando link "${attachment.name}"`, 'info');
} else {
showDownloadToast(attachment.name, attachment.size);
window.open(attachment.url, '_blank', 'noopener,noreferrer');
return;
}
if (onDownload) {
onDownload(attachment);
}
// If it has a real URL that is not '#', let the default anchor do its work
if (!attachment.url || attachment.url === '#') {
e.preventDefault();
// Trigger a synthetic download notification reset
setTimeout(() => setDownloaded(false), 3000);
try {
await downloadCourseAsset(attachment.id, attachment.name);
setDownloaded(true);
showDownloadToast(attachment.name, attachment.size);
onDownload?.(attachment);
window.setTimeout(() => setDownloaded(false), 3000);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Não foi possível baixar este material.', 'error');
}
};
if (compact) {
return (
<a
href={attachment.url || '#'}
target={attachment.type === 'link' ? '_blank' : '_self'}
rel="noreferrer"
onClick={handleAction}
<button
type="button"
onClick={() => void handleAction()}
className="flex items-center justify-between p-3.5 rounded-xl bg-zinc-900/90 hover:bg-zinc-800/90 transition-all duration-200 group shadow-sm"
>
<div className="flex items-center gap-3.5 min-w-0 pr-3">
@@ -119,8 +117,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
Baixado
</span>
) : (
<button
type="button"
<span
className="flex items-center gap-1.5 text-xs font-semibold bg-white/10 hover:bg-orange-500 hover:text-white text-gray-200 px-3.5 py-1.5 rounded-lg transition-all active:scale-95"
>
{attachment.type === 'link' ? (
@@ -134,10 +131,10 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
Baixar
</>
)}
</button>
</span>
)}
</div>
</a>
</button>
);
}
@@ -169,11 +166,9 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
{attachment.size || 'Disponível'}
</span>
<a
href={attachment.url || '#'}
target={attachment.type === 'link' ? '_blank' : '_self'}
rel="noreferrer"
onClick={handleAction}
<button
type="button"
onClick={() => void handleAction()}
className="inline-flex items-center gap-2 text-xs font-bold text-black bg-white hover:bg-orange-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 active:scale-95 shadow-md"
>
{attachment.type === 'link' ? (
@@ -187,7 +182,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
Baixar Arquivo
</>
)}
</a>
</button>
</div>
</div>
);

View File

@@ -50,6 +50,7 @@ services:
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
interval: 15s

View File

@@ -11,7 +11,10 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault(); setSaving(true); setError('');
event.preventDefault(); setError('');
if (mode === 'invite' && name.trim().length < 2) { setError('Informe seu nome.'); return; }
if (password.length < 8) { setError('A senha deve ter pelo menos 8 caracteres.'); return; }
setSaving(true);
try {
if (mode === 'invite') {
const session = await authApi.acceptInvitation(token, name, password);
@@ -20,5 +23,5 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
finally { setSaving(false); }
};
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input required minLength={8} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form noValidate onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
};

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Course } from '../types';
import { getInstructorProfile, InstructorProfileData } from '../services/db';
import { getPublicProfile, InstructorProfileData } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { CourseCard } from '../components/CourseCard';
@@ -14,18 +14,21 @@ const networks = [
] as const;
export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const { instructorId } = useParams<{ instructorId: string }>();
const { instructorId, profileId } = useParams<{ instructorId?: string; profileId?: string }>();
const publicProfileId = instructorId || profileId;
const [profile, setProfile] = useState<InstructorProfileData | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading');
useEffect(() => {
if (!instructorId) return;
if (!publicProfileId) return;
setState('loading');
getInstructorProfile(instructorId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing'));
}, [instructorId]);
getPublicProfile(publicProfileId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing'));
}, [publicProfileId]);
if (state === 'loading') return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
if (state === 'missing' || !profile) return <main className="mx-auto flex min-h-[65vh] max-w-5xl items-center px-6 pt-24"><div><p className="text-sm font-semibold uppercase tracking-widest text-orange-400">Perfil indisponível</p><h1 className="mt-3 text-3xl font-bold">Este perfil não está público.</h1></div></main>;
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Instrutor</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className="mx-auto grid max-w-6xl gap-12 px-6 py-12 sm:px-8 lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]"><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || 'Este instrutor ainda não adicionou uma apresentação.'}</p></aside><section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section></div></main>;
const isInstructor = profile.role === 'instructor' || profile.role === 'admin';
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">{isInstructor ? 'Instrutor' : 'Aluno'}</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className={`mx-auto max-w-6xl gap-12 px-6 py-12 sm:px-8 ${isInstructor ? 'grid lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]' : 'max-w-3xl'}`}><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || `${isInstructor ? 'Este instrutor' : 'Este aluno'} ainda não adicionou uma apresentação.`}</p></aside>{isInstructor && <section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section>}</div></main>;
};

View File

@@ -40,6 +40,7 @@ import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getCommen
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
import { useSearchParams } from 'react-router-dom';
const formatVideoDuration = (seconds: number | null | undefined) => {
if (!seconds || seconds < 1) return 'Processando';
@@ -82,6 +83,11 @@ const CourseModal: React.FC<{
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
const [coverUploadMessage, setCoverUploadMessage] = useState('');
const [isCoverUploading, setIsCoverUploading] = useState(false);
const attachmentFileRef = useRef<HTMLInputElement>(null);
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
const [isAssetUploading, setIsAssetUploading] = useState(false);
const [assetUploadMessage, setAssetUploadMessage] = useState('');
const [isDraggingAsset, setIsDraggingAsset] = useState(false);
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
// Lesson Management
@@ -101,12 +107,8 @@ const CourseModal: React.FC<{
const [newTip, setNewTip] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
// New Attachment State
const [newAttName, setNewAttName] = useState('');
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
const [newAttUrl, setNewAttUrl] = useState('');
const [newAttSize, setNewAttSize] = useState('');
const [newAttDesc, setNewAttDesc] = useState('');
const [newLinkName, setNewLinkName] = useState('');
const [newLinkUrl, setNewLinkUrl] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -150,12 +152,18 @@ const CourseModal: React.FC<{
setBunnyUploadState('idle');
setBunnyUploadMessage('');
setCoverUploadMessage('');
setAssetUploadMessage('');
setNewLinkName('');
setNewLinkUrl('');
instructorApi.bunnyConfiguration()
.then((response) => setBunnyConfigured(response.data.configured))
.catch(() => setBunnyConfigured(false));
instructorApi.coverConfiguration()
.then((response) => setCoverConfigured(response.data.configured))
.catch(() => setCoverConfigured(false));
instructorApi.assetConfiguration()
.then((response) => setAssetConfigured(response.data.configured))
.catch(() => setAssetConfigured(false));
courseApi.categories()
.then((response) => {
setCategories(response.data);
@@ -390,26 +398,75 @@ const CourseModal: React.FC<{
setTips(tips.filter((_, i) => i !== idx));
};
const handleAddAttachment = () => {
if (!newAttName.trim()) {
alert("Informe o nome do arquivo para download.");
const handleAttachmentUploads = async (files: FileList | File[]) => {
const selectedFiles = Array.from(files);
if (!selectedFiles.length) return;
if (!assetConfigured) {
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
return;
}
const att: Attachment = {
id: `att-${Date.now()}`,
name: newAttName.trim(),
type: newAttType,
url: newAttUrl.trim() || '#',
size: newAttSize.trim() || '1.5 MB',
description: newAttDesc.trim()
};
setIsAssetUploading(true);
setAssetUploadMessage(selectedFiles.length === 1 ? `Enviando ${selectedFiles[0].name}...` : `Enviando ${selectedFiles.length} arquivos...`);
const uploadedAttachments: Attachment[] = [];
const failedFiles: string[] = [];
for (const file of selectedFiles) {
try {
const response = await instructorApi.uploadAsset(file);
const type: AttachmentType = response.data.kind === 'spreadsheet'
? 'spreadsheet'
: response.data.kind === 'archive'
? 'zip'
: file.name.toLowerCase().endsWith('.pdf')
? 'pdf'
: 'doc';
uploadedAttachments.push({
id: `asset-${response.data.key}`,
name: file.name,
type,
url: response.data.assetUrl,
sizeBytes: response.data.sizeBytes,
description: '',
});
} catch {
failedFiles.push(file.name);
}
}
if (uploadedAttachments.length) setAttachments((current) => [...current, ...uploadedAttachments]);
setAssetUploadMessage(failedFiles.length
? `${uploadedAttachments.length} enviado(s). Não foi possível enviar: ${failedFiles.join(', ')}.`
: `${uploadedAttachments.length} material(is) enviado(s) com sucesso.`);
setIsAssetUploading(false);
};
setAttachments([...attachments, att]);
setNewAttName('');
setNewAttSize('');
setNewAttUrl('');
setNewAttDesc('');
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
event.target.value = '';
if (!files) return;
await handleAttachmentUploads(files);
};
const handleAddLink = () => {
const url = newLinkUrl.trim();
if (!url) {
alert('Informe o link do material.');
return;
}
try {
const parsedUrl = new URL(url);
if (!['https:', 'http:'].includes(parsedUrl.protocol)) throw new Error();
setAttachments((current) => [...current, {
id: `link-${Date.now()}`,
name: newLinkName.trim() || parsedUrl.hostname,
type: 'link',
url,
description: '',
}]);
setNewLinkName('');
setNewLinkUrl('');
} catch {
alert('Informe um link HTTP ou HTTPS válido.');
}
};
const removeAttachment = (id: string) => {
@@ -475,7 +532,7 @@ const CourseModal: React.FC<{
<div>
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
<h2 className="mt-1 text-2xl font-extrabold text-white">{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}</h2>
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Inclua os arquivos e links de apoio.'}</p>
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Envie os arquivos de apoio para seus alunos.'}</p>
</div>
<button type="button" onClick={onClose} className="shrink-0 rounded-full bg-white/5 p-2.5 text-gray-300 hover:bg-white/10 hover:text-white" aria-label="Fechar editor"><X className="w-5 h-5" /></button>
</div>
@@ -623,7 +680,7 @@ const CourseModal: React.FC<{
<div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Publicação</label>
<select value={status} onChange={(e) => setStatus(e.target.value as Course['status'])} className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50">
<option value="draft" className="bg-zinc-900">Salvar como rascunho</option>
<option value="draft" className="bg-zinc-900">Salvar como não publicado</option>
<option value="published" className="bg-zinc-900">Publicar agora</option>
</select>
</div>
@@ -656,52 +713,54 @@ const CourseModal: React.FC<{
</h3>
</div>
{/* Add attachment inputs */}
<div className="space-y-2 pt-1">
<div className="grid grid-cols-3 gap-2">
<input
type="text"
value={newAttName}
onChange={(e) => setNewAttName(e.target.value)}
placeholder="Ex.: Planilha de acompanhamento de campanhas"
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
/>
<select
value={newAttType}
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
<input
ref={attachmentFileRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.zip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,application/zip"
className="hidden"
onChange={(event) => void handleAttachmentUpload(event)}
/>
<div
onDragEnter={(event) => { event.preventDefault(); setIsDraggingAsset(true); }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; setIsDraggingAsset(true); }}
onDragLeave={(event) => { event.preventDefault(); setIsDraggingAsset(false); }}
onDrop={(event) => { event.preventDefault(); setIsDraggingAsset(false); void handleAttachmentUploads(event.dataTransfer.files); }}
className={`rounded-xl border border-dashed p-5 transition ${isDraggingAsset ? 'border-orange-400 bg-orange-500/10' : 'border-white/15 bg-white/[0.02]'}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-white">Envie um arquivo de apoio</p>
</div>
<button
type="button"
disabled={!assetConfigured || isAssetUploading}
onClick={() => attachmentFileRef.current?.click()}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
>
<option value="pdf">PDF</option>
<option value="spreadsheet">Planilha</option>
<option value="zip">Pacote ZIP</option>
<option value="doc">Documento</option>
<option value="link">Link</option>
</select>
</div>
<div className="flex gap-2">
<input
type="text"
value={newAttSize}
onChange={(e) => setNewAttSize(e.target.value)}
placeholder="Ex.: 2,4 MB"
className="w-28 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
/>
<input
type="text"
value={newAttDesc}
onChange={(e) => setNewAttDesc(e.target.value)}
placeholder="Explique como este material apoia o aprendizado."
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
/>
<button
type="button"
onClick={handleAddAttachment}
className="bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg text-white text-xs font-bold shrink-0 transition-colors"
>
Adicionar
{isAssetUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
</button>
</div>
{assetUploadMessage && <p className={`mt-2 text-xs ${assetUploadMessage.includes('sucesso') ? 'text-emerald-400' : assetUploadMessage.includes('Enviando') ? 'text-gray-400' : 'text-amber-300'}`}>{assetUploadMessage}</p>}
</div>
<div className="flex flex-col gap-2 border-t border-white/5 pt-4 sm:flex-row">
<input
type="text"
value={newLinkName}
onChange={(event) => setNewLinkName(event.target.value)}
placeholder="Título do link (opcional)"
className="min-w-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-xs text-white outline-none focus:border-orange-500/50 sm:w-52"
/>
<input
type="url"
value={newLinkUrl}
onChange={(event) => setNewLinkUrl(event.target.value)}
placeholder="https://..."
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-xs text-white outline-none focus:border-orange-500/50"
/>
<button type="button" onClick={handleAddLink} className="rounded-lg border border-white/10 px-4 py-2.5 text-xs font-bold text-white transition hover:bg-white/10">Adicionar link</button>
</div>
{/* List of Attachments */}
@@ -711,7 +770,7 @@ const CourseModal: React.FC<{
<div className="flex items-center gap-2 min-w-0 pr-2">
{getAttachmentIcon(att.type)}
<span className="text-gray-200 font-medium truncate">{att.name}</span>
<span className="text-[10px] text-gray-500 font-mono">({att.size || '1 MB'})</span>
{att.size && <span className="text-[10px] text-gray-500 font-mono">({att.size})</span>}
</div>
<button
type="button"
@@ -725,7 +784,7 @@ const CourseModal: React.FC<{
</div>
<div className="mt-5 flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-between">
<button type="button" onClick={() => setEditorStep(2)} className="inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-gray-300 hover:bg-white/5 hover:text-white"><ChevronLeft className="w-4 h-4" /> Voltar para aulas</button>
<button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600 disabled:opacity-60">{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como rascunho' : 'Salvar e publicar'}</button>
<button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600 disabled:opacity-60">{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como não publicado' : 'Salvar e publicar'}</button>
</div>
</div>
</div>
@@ -885,6 +944,7 @@ const CourseModal: React.FC<{
};
export const ManageCourses: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [courses, setCourses] = useState<Course[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
@@ -917,6 +977,17 @@ export const ManageCourses: React.FC = () => {
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 = () => {
setEditingCourse(undefined);
setIsModalOpen(true);
@@ -938,7 +1009,7 @@ export const ManageCourses: React.FC = () => {
const handleDuplicate = async (id: string) => {
try {
await duplicateCourse(id);
showToast('Cópia criada como rascunho.', 'success');
showToast('Cópia criada como não publicada.', 'success');
loadData();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
@@ -1091,7 +1162,7 @@ export const ManageCourses: React.FC = () => {
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
{course.category}
</div>
{course.status === 'draft' && <div className="absolute top-3 right-3 bg-amber-500/20 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-amber-300 border border-amber-500/30">Rascunho</div>}
{course.status === 'draft' && <div className="absolute top-3 right-3 bg-amber-500/20 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-amber-300 border border-amber-500/30">Não publicado</div>}
</div>
{/* Body */}

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,6 @@ export const ProfilePage: React.FC = () => {
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const canPublishProfile = user?.role === 'professor' || user?.role === 'superadmin';
useEffect(() => {
if (!user) return;
profileApi.me()
@@ -102,9 +100,9 @@ export const ProfilePage: React.FC = () => {
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua conta</p>
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Meu perfil</h1>
<p className="mt-2 max-w-xl text-sm text-gray-400">{canPublishProfile ? 'Apresente sua experiência e conecte os alunos aos seus cursos.' : 'Mantenha seus dados pessoais atualizados.'}</p>
<p className="mt-2 max-w-xl text-sm text-gray-400">Apresente quem você é, compartilhe seus links e escolha a visibilidade do seu perfil.</p>
</div>
{canPublishProfile && profile.isPublic && <button onClick={() => navigate(`/instrutores/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>}
{profile.isPublic && <button onClick={() => navigate(`/perfis/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>}
</div>
<form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30">
@@ -112,9 +110,9 @@ export const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-full border-4 border-zinc-950 bg-zinc-900 shadow-xl">
{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-11 w-11 text-orange-300" /></div>}
{canPublishProfile && <button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>}
<button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>
</div>
<div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p>{canPublishProfile && <p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis no seu perfil público.</p>}</div>
<div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p><p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis quando seu perfil estiver público.</p></div>
</div>
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} />
</section>
@@ -122,13 +120,11 @@ export const ProfilePage: React.FC = () => {
<div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]">
<section className="space-y-5">
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Nome</span><input value={profile.name} onChange={(event) => update('name', event.target.value)} className={inputClass} maxLength={120} required /></label>
{canPublishProfile && <>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sua experiência e o que os alunos podem esperar aprender com você." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
</>}
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sobre você, seus interesses ou sua experiência." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
</section>
{canPublishProfile && <section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que alunos vejam sua apresentação e seus cursos publicados.</span></span></label></section>}
<section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que outras pessoas vejam sua apresentação e seus links. Perfis de instrutores também exibem seus cursos publicados.</span></span></label></section>
</div>
<div className="flex justify-end border-t border-white/10 bg-black/20 p-5 sm:px-8"><button disabled={isSaving} className="inline-flex min-w-40 items-center justify-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white shadow-lg shadow-orange-500/20 transition hover:bg-orange-600 disabled:opacity-60">{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} Salvar perfil</button></div>
</form>

File diff suppressed because one or more lines are too long

112
pages/TrailDetail.tsx Normal file

File diff suppressed because one or more lines are too long

34
pages/TrailPage.tsx Normal file
View 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>;
};

View 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;

View File

@@ -35,6 +35,18 @@ export function buildApp() {
app.addContentTypeParser('application/octet-stream', rawUploadParser);
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
const assetContentTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/zip',
'text/csv',
];
for (const contentType of assetContentTypes) {
app.addContentTypeParser(contentType, { parseAs: 'buffer', bodyLimit: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
}
app.register(cors, {
origin: config.FRONTEND_ORIGIN,
@@ -43,11 +55,14 @@ export function buildApp() {
app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid request', details: error.flatten() });
return reply.code(400).send({
error: error.issues[0]?.message || 'Dados inválidos.',
details: error.flatten(),
});
}
app.log.error(error);
return reply.code(500).send({ error: 'Internal server error' });
return reply.code(500).send({ error: 'Erro interno no servidor.' });
});
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {

View File

@@ -34,7 +34,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
try {
await request.jwtVerify();
} catch {
reply.code(401).send({ error: 'Authentication required' });
reply.code(401).send({ error: 'Autenticação necessária.' });
return false;
}
@@ -44,7 +44,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
);
const account = result.rows[0];
if (!account) {
reply.code(401).send({ error: 'This account is no longer active' });
reply.code(401).send({ error: 'Esta conta não está mais ativa.' });
return false;
}
@@ -61,7 +61,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
if (!(await verifyActiveUser(request, reply))) return;
if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' });
return reply.code(403).send({ error: 'Você não tem permissão para esta ação.' });
}
});
};

View File

@@ -35,12 +35,13 @@ const environmentSchema = z.object({
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
BUNNY_STORAGE_CDN_HOST: optionalEnvironmentValue(z.string().url()),
BUNNY_COVER_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(50).default(10),
BUNNY_ASSET_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(512).default(100),
});
export const config = environmentSchema.parse(process.env);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
throw new Error('JWT_SECRET must be set to a unique value in production.');
throw new Error('JWT_SECRET deve ter um valor único em produção.');
}
const bunnyConfigurationValues = [
@@ -50,7 +51,7 @@ const bunnyConfigurationValues = [
];
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY, and BUNNY_EMBED_TOKEN_KEY must be configured together.');
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY e BUNNY_EMBED_TOKEN_KEY devem ser configurados juntos.');
}
const bunnyStorageConfigurationValues = [
@@ -61,10 +62,10 @@ const bunnyStorageConfigurationValues = [
];
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT e BUNNY_STORAGE_CDN_HOST devem ser configurados juntos.');
}
const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM];
if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) {
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, and MAIL_FROM must be configured together.');
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS e MAIL_FROM devem ser configurados juntos.');
}

View File

@@ -5,10 +5,11 @@ export class BunnyStorageConfigurationError extends Error {}
export class BunnyStorageRequestError extends Error {}
type CoverImage = { body: Buffer; contentType: string };
type StoredAsset = { body: Buffer; contentType: string; extension: string };
function bunnyStorageConfiguration() {
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
throw new BunnyStorageConfigurationError('Bunny Storage is not configured. Ask an administrator to add the Storage Zone variables.');
throw new BunnyStorageConfigurationError('O Bunny Storage não está configurado. Peça a um administrador para adicionar as variáveis da Storage Zone.');
}
return {
zone: config.BUNNY_STORAGE_ZONE,
@@ -30,7 +31,7 @@ function imageExtension(image: CoverImage) {
if (contentType === 'image/png' && isPng) return 'png';
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
if (contentType === 'image/webp' && isWebp) return 'webp';
throw new BunnyStorageRequestError('Only valid JPG, PNG, and WebP images are accepted.');
throw new BunnyStorageRequestError('Envie apenas imagens JPG, PNG ou WebP válidas.');
}
export async function uploadBunnyCover(image: CoverImage) {
@@ -53,6 +54,48 @@ export async function uploadBunnyCover(image: CoverImage) {
return { key, coverImageUrl: `${cdnHost}/${key}` };
}
export async function uploadBunnyAsset(asset: StoredAsset) {
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
const key = `materials/${randomUUID()}.${asset.extension}`;
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
method: 'PUT',
headers: {
AccessKey: password,
'Content-Type': asset.contentType,
'Cache-Control': 'private, max-age=31536000, immutable',
},
body: asset.body,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage upload failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return { key, assetUrl: `${cdnHost}/${key}` };
}
export function bunnyAssetKeyFromUrl(url: string) {
if (!config.BUNNY_STORAGE_CDN_HOST) return null;
const cdnHost = config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, '');
if (!url.startsWith(`${cdnHost}/materials/`)) return null;
const key = url.slice(cdnHost.length + 1);
return /^materials\/[0-9a-f-]{36}\.(?:pdf|docx|doc|xlsx|xls|csv|zip)$/i.test(key) ? key : null;
}
export async function downloadBunnyAsset(key: string) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
headers: { AccessKey: password },
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage download failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return {
body: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || 'application/octet-stream',
};
}
export async function downloadBunnyCover(filename: string) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {

View File

@@ -27,7 +27,7 @@ export class BunnyRequestError extends Error {}
function getBunnyConfiguration() {
if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) {
throw new BunnyConfigurationError('Bunny Stream is not configured. Ask an administrator to configure the Bunny environment variables.');
throw new BunnyConfigurationError('O Bunny Stream não está configurado. Peça a um administrador para configurar as variáveis de ambiente do Bunny.');
}
return {

View File

@@ -16,7 +16,7 @@ const updateUserSchema = z.object({
role: z.enum(['student', 'instructor', 'admin']).optional(),
isActive: z.boolean().optional(),
}).refine((input) => input.name !== undefined || input.email !== undefined || input.role !== undefined || input.isActive !== undefined, {
message: 'Provide at least one field to update',
message: 'Informe pelo menos um campo para atualizar.',
});
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.literal('instructor').default('instructor') });
const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() });
@@ -34,8 +34,8 @@ const homeBannersSchema = z.object({
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' });
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners de curso precisam de um curso.' });
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners personalizados precisam de uma imagem.' });
})).max(10),
});
@@ -58,7 +58,15 @@ const learningPathSchema = z.object({
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),
courseIds: z.array(z.string().uuid()).max(100),
}).superRefine((input, context) => {
if (input.status === 'published' && input.courseIds.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['courseIds'],
message: 'Adicione pelo menos um curso antes de publicar a trilha.',
});
}
});
type LearningPathInput = z.infer<typeof learningPathSchema>;
@@ -86,14 +94,14 @@ const getLearningPaths = async () => {
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');
if (courseIds.length !== input.courseIds.length) throw new Error('Um curso só pode aparecer uma vez em uma trilha.');
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 (courses.rowCount !== courseIds.length) throw new Error('Todos os cursos da trilha devem existir e não podem estar arquivados.');
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
throw new Error('Publish every course in this path before publishing the path');
throw new Error('Publique todos os cursos desta trilha antes de publicá-la.');
}
return courseIds;
};
@@ -143,6 +151,17 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
app.get('/path-courses', adminAccess, async () => {
const result = await pool.query<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }>(
`select id, title, status, category,
coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl"
from courses
where status <> 'archived'
order by category, title`,
);
return { data: result.rows };
});
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
app.put('/home-banners', adminAccess, async (request) => {
@@ -150,7 +169,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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' };
if (courses.rowCount !== courseIds.length) return { error: 'Banners de curso devem referenciar cursos publicados.' };
}
const client = await pool.connect();
try {
@@ -189,7 +208,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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 });
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
@@ -214,7 +233,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
);
if (!path.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Learning path not found' });
return reply.code(404).send({ error: 'Trilha não encontrada.' });
}
await writePathCourses(client, pathId, courseIds);
await client.query('commit');
@@ -223,7 +242,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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 });
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
@@ -233,7 +252,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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' });
if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
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();
});
@@ -260,7 +279,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
returning id, name, position, is_active as "isActive"`,
[categoryId, input.name, input.position ?? null, input.isActive ?? null],
);
if (!result.rows[0]) return reply.code(404).send({ error: 'Category not found' });
if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip });
return { data: result.rows[0] };
});
@@ -288,7 +307,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])];
if (ids.length) {
const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]);
if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Featured courses must exist and be published' });
if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Os cursos em destaque devem existir e estar publicados.' });
}
const client = await pool.connect();
try {
@@ -336,14 +355,14 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
(select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt"
from users u where u.id = $1`, [userId],
);
if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' });
if (!result.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
return { data: result.rows[0] };
});
app.post('/invitations', adminAccess, async (request, reply) => {
const input = invitationSchema.parse(request.body);
const existing = await pool.query('select 1 from users where email = $1', [input.email]);
if (existing.rowCount) return reply.code(409).send({ error: 'This email already has an account' });
if (existing.rowCount) return reply.code(409).send({ error: 'Este e-mail já possui uma conta.' });
const rawToken = createRawToken();
await pool.query(
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
@@ -357,7 +376,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]);
if (!account.rows[0]) return reply.code(404).send({ error: 'User not found' });
if (!account.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
const rawToken = createRawToken();
await pool.query(
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
@@ -373,11 +392,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const input = updateUserSchema.parse(request.body);
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
return reply.code(400).send({ error: 'You cannot remove your own superadmin access' });
return reply.code(400).send({ error: 'Você não pode remover seu próprio acesso de superadmin.' });
}
if (input.email) {
const duplicate = await pool.query('select 1 from users where email = $1 and id <> $2', [input.email, userId]);
if (duplicate.rowCount) return reply.code(409).send({ error: 'This e-mail is already used by another account' });
if (duplicate.rowCount) return reply.code(409).send({ error: 'Este e-mail já é usado por outra conta.' });
}
const result = await pool.query(
@@ -391,14 +410,14 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
[userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
);
const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' });
if (!account) return reply.code(404).send({ error: 'Usuário não encontrado.' });
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
return { data: account };
});
app.delete('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
if (userId === request.user.id) return reply.code(400).send({ error: 'You cannot delete your own superadmin account' });
if (userId === request.user.id) return reply.code(400).send({ error: 'Você não pode excluir sua própria conta de superadmin.' });
const client = await pool.connect();
try {
@@ -406,13 +425,13 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const account = await client.query<{ id: string; email: string; role: 'student' | 'instructor' | 'admin' }>('select id, email, role from users where id = $1 for update', [userId]);
if (!account.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'User not found' });
return reply.code(404).send({ error: 'Usuário não encontrado.' });
}
if (account.rows[0].role === 'admin') {
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
if (Number(admins.rows[0].count) <= 1) {
await client.query('rollback');
return reply.code(400).send({ error: 'The last active superadmin cannot be deleted' });
return reply.code(400).send({ error: 'O último superadmin ativo não pode ser excluído.' });
}
}
// Courses remain available. Their ownership is transferred to the admin

View File

@@ -57,7 +57,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
};
const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
if (await allowPublicAuthAttempt(ip)) return false;
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
reply.code(429).send({ error: 'Muitas tentativas. Tente novamente mais tarde.' });
return true;
};
@@ -74,7 +74,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
);
if (!token.rows[0]) {
await client.query('rollback');
return reply.code(400).send({ error: 'This invitation is invalid or expired' });
return reply.code(400).send({ error: 'Este convite é inválido ou expirou.' });
}
const result = await client.query<UserRow>(
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
@@ -86,7 +86,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
} catch {
await client.query('rollback');
return reply.code(409).send({ error: 'This invitation email already has an account' });
return reply.code(409).send({ error: 'Este e-mail de convite já possui uma conta.' });
} finally {
client.release();
}
@@ -105,7 +105,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
);
if (!token.rows[0]) {
await client.query('rollback');
return reply.code(400).send({ error: 'This reset link is invalid or expired' });
return reply.code(400).send({ error: 'Este link de redefinição é inválido ou expirou.' });
}
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
await client.query('commit');
@@ -140,7 +140,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.code(201).send({ token, user });
} catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
return reply.code(409).send({ error: 'An account with this email already exists' });
return reply.code(409).send({ error: 'Já existe uma conta com este e-mail.' });
}
throw error;
}
@@ -156,7 +156,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const account = result.rows[0];
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' });
return reply.code(401).send({ error: 'E-mail ou senha inválidos.' });
}
const user = serializeUser(account);

View File

@@ -14,16 +14,16 @@ const webhookSchema = z.object({
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
app.post('/bunny', async (request, reply) => {
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Bunny webhooks are not configured' });
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Os webhooks do Bunny não estão configurados.' });
const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody;
const signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) {
return reply.code(401).send({ error: 'Invalid Bunny webhook signature' });
return reply.code(401).send({ error: 'Assinatura do webhook do Bunny inválida.' });
}
const input = webhookSchema.parse(request.body);
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
return reply.code(400).send({ error: 'Unexpected Bunny video library' });
return reply.code(400).send({ error: 'Biblioteca de vídeos Bunny inesperada.' });
}
const status = bunnyMediaStatus(input.Status);
await pool.query(

View File

@@ -2,7 +2,7 @@ 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';
import { BunnyStorageConfigurationError, BunnyStorageRequestError, bunnyAssetKeyFromUrl, downloadBunnyAsset, downloadBunnyCover } from '../providers/bunny-storage.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
@@ -14,6 +14,7 @@ const playbackParamsSchema = z.object({
const coverParamsSchema = z.object({
filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i),
});
const assetParamsSchema = z.object({ assetId: z.string().uuid() });
export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select
@@ -49,6 +50,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
'name', a.name,
'kind', a.kind,
'url', a.url,
'sizeBytes', a.size_bytes,
'createdAt', a.created_at,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
@@ -81,6 +84,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
'name', a.name,
'kind', a.kind,
'url', a.url,
'sizeBytes', a.size_bytes,
'createdAt', a.created_at,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
@@ -123,7 +128,37 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return reply.send(cover.body);
} catch (error) {
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
return reply.code(404).send({ error: 'Cover image was not found' });
return reply.code(404).send({ error: 'Imagem de capa não encontrada.' });
}
throw error;
}
});
app.get('/assets/:assetId/download', { preHandler: app.authenticate }, async (request, reply) => {
const { assetId } = assetParamsSchema.parse(request.params);
const result = await pool.query<{ name: string; url: string; status: string; instructor_id: string }>(
`select a.name, a.url, c.status, c.instructor_id
from assets a
join courses c on c.id = coalesce(a.course_id, (select lesson.course_id from lessons lesson where lesson.id = a.lesson_id))
where a.id = $1`,
[assetId],
);
const asset = result.rows[0];
if (!asset || (asset.status !== 'published' && request.user.role !== 'admin' && asset.instructor_id !== request.user.id)) {
return reply.code(404).send({ error: 'Material não encontrado.' });
}
const key = bunnyAssetKeyFromUrl(asset.url);
if (!key) return reply.redirect(asset.url);
try {
const file = await downloadBunnyAsset(key);
const filename = asset.name.replace(/[\\/\r\n"]/g, '_');
reply.header('Content-Type', file.contentType);
reply.header('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
reply.header('Cache-Control', 'private, no-store');
return reply.send(file.body);
} catch (error) {
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
return reply.code(404).send({ error: 'Material não encontrado.' });
}
throw error;
}
@@ -219,7 +254,7 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
const course = result.rows[0];
if (!course) {
return reply.code(404).send({ error: 'Course not found' });
return reply.code(404).send({ error: 'Curso não encontrado.' });
}
return { data: authenticated ? course : withoutProtectedMedia(course) };
@@ -244,22 +279,22 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
[courseId, lessonId],
);
const media = result.rows[0];
if (!media) return reply.code(404).send({ error: 'Lesson media was not found' });
if (media.status === 'processing') return reply.code(409).send({ error: 'This video is still being processed by Bunny Stream.' });
if (media.status === 'failed') return reply.code(409).send({ error: 'This video could not be processed. Please contact the instructor.' });
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: 'Bunny playback is not configured yet.' });
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: 'This lesson does not have a playable video 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 } };
});
};

View File

@@ -14,7 +14,7 @@ const lessonParamsSchema = z.object({
const completionSchema = z.object({
completed: z.boolean().optional(),
watchedSeconds: z.coerce.number().int().min(0).optional(),
}).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Provide progress or completion state' });
}).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Informe o progresso ou o status de conclusão.' });
const commentSchema = z.object({
lessonId: z.string().uuid().nullable().optional(),
@@ -51,7 +51,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params);
if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' });
return reply.code(404).send({ error: 'Curso não encontrado.' });
}
const result = await pool.query(
@@ -78,7 +78,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
);
if (lesson.rowCount !== 1) {
return reply.code(404).send({ error: 'Lesson not found' });
return reply.code(404).send({ error: 'Aula não encontrada.' });
}
const result = await pool.query(
@@ -99,7 +99,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params);
if (!(await canViewCourseComments(courseId, request.user))) {
return reply.code(404).send({ error: 'Course not found' });
return reply.code(404).send({ error: 'Curso não encontrado.' });
}
const result = await pool.query(
@@ -120,13 +120,13 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const { courseId } = courseParamsSchema.parse(request.params);
const input = commentSchema.parse(request.body);
if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' });
return reply.code(404).send({ error: 'Curso não encontrado.' });
}
if (input.lessonId) {
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
if (lesson.rowCount !== 1) {
return reply.code(400).send({ error: 'Lesson does not belong to this course' });
return reply.code(400).send({ error: 'Esta aula não pertence ao curso.' });
}
}
@@ -148,7 +148,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
const { commentId } = commentParamsSchema.parse(request.params);
const input = replySchema.parse(request.body);
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot reply to this comment' });
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode responder a este comentário.' });
await pool.query(
`insert into comment_replies (comment_id, author_id, body)
values ($1, $2, $3)
@@ -161,7 +161,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
const { commentId } = commentParamsSchema.parse(request.params);
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot moderate this comment' });
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode moderar este comentário.' });
await pool.query('delete from comments where id = $1', [commentId]);
await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
return reply.code(204).send();

View File

@@ -20,6 +20,7 @@ const assetSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(),
sizeBytes: z.number().int().nonnegative().nullable().optional(),
description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
});
@@ -37,7 +38,7 @@ const lessonSchema = z.object({
const coverImageUrlSchema = z.string().url().refine((value) => {
const protocol = new URL(value).protocol;
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
}, { message: 'Cover image URL must use HTTPS.' });
}, { message: 'A URL da imagem de capa deve usar HTTPS.' });
const courseSchema = z.object({
title: z.string().trim().min(1).max(255),
@@ -85,16 +86,16 @@ async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined,
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) {
await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel],
`insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7, $8)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.sizeBytes ?? null, asset.description, asset.accessLevel],
);
}
}
async function assertActiveCategory(category: string) {
const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]);
if (!result.rowCount) throw new CourseContentConflict('Select an active category created by the superadmin.');
if (!result.rowCount) throw new CourseContentConflict('Selecione uma trilha ativa criada pelo superadmin.');
}
function assertPublishable(input: CourseInput) {
@@ -103,7 +104,7 @@ function assertPublishable(input: CourseInput) {
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
);
if (unplayableLesson) {
throw new CoursePublicationBlocked(`The course cannot be published while "${unplayableLesson.title}" has no ready video.`);
throw new CoursePublicationBlocked(`O curso não pode ser publicado enquanto “${unplayableLesson.title}” não tiver um vídeo pronto.`);
}
}
@@ -112,7 +113,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
if (unknownLessonId) throw new Error('A lesson being edited does not belong to this course.');
if (unknownLessonId) throw new Error('Uma aula em edição não pertence a este curso.');
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
if (removedLessonIds.length > 0) {
@@ -125,7 +126,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
[removedLessonIds],
);
if (usage.rowCount) {
throw new CourseContentConflict('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.');
throw new CourseContentConflict('Uma aula com progresso ou comentários de alunos não pode ser removida. Mantenha-a ou arquive o curso.');
}
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
}
@@ -170,9 +171,9 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0];
if (!course) return { error: 'Course not found', statusCode: 404 as const };
if (!course) return { error: 'Curso não encontrado.', statusCode: 404 as const };
if (user.role !== 'admin' && course.instructor_id !== user.id) {
return { error: 'You can only manage your own courses', statusCode: 403 as const };
return { error: 'Você só pode gerenciar seus próprios cursos.', statusCode: 403 as const };
}
return null;
}
@@ -270,7 +271,18 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const { courseId } = paramsSchema.parse(request.params);
const accessError = await assertCanManageCourse(courseId, request.user);
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 });
return reply.code(204).send();
});
@@ -288,7 +300,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
);
if (!original.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Course not found' });
return reply.code(404).send({ error: 'Curso não encontrado.' });
}
const source = original.rows[0];
const title = `${source.title} (cópia)`;

View File

@@ -17,8 +17,10 @@ import {
BunnyStorageConfigurationError,
BunnyStorageRequestError,
isBunnyStorageConfigured,
uploadBunnyAsset,
uploadBunnyCover,
} from '../providers/bunny-storage.js';
import { AssetUploadValidationError, validateAssetUpload } from '../uploads/asset.js';
const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
@@ -26,11 +28,12 @@ const videoParamsSchema = z.object({ videoId: z.string().uuid() });
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' });
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'O Bunny Stream não conseguiu concluir esta solicitação. Tente novamente.' });
throw error;
}
function storageError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
if (error instanceof AssetUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
throw error;
@@ -67,11 +70,12 @@ async function assertVideoAccess(videoId: string, user: { id: string; role: stri
[videoId],
);
if (legacy.rows[0]?.instructor_id === user.id) return;
throw new VideoUploadValidationError('You do not have access to this video.', 403);
throw new VideoUploadValidationError('Você não tem acesso a este vídeo.', 403);
}
export const mediaRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
const coverAccess = { preHandler: app.authenticate };
app.get('/bunny/config', manageAccess, async () => ({
data: {
@@ -80,18 +84,25 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
},
}));
app.get('/covers/config', manageAccess, async () => ({
app.get('/covers/config', coverAccess, async () => ({
data: {
configured: isBunnyStorageConfigured(),
maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
app.post('/covers', manageAccess, async (request, reply) => {
app.get('/assets/config', manageAccess, async () => ({
data: {
configured: isBunnyStorageConfigured(),
maxUploadBytes: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
app.post('/covers', coverAccess, async (request, reply) => {
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
const body = request.body;
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Send an image file as the request body.' });
if (body.length === 0) return reply.code(400).send({ error: 'Choose an image to upload.' });
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de imagem.' });
if (body.length === 0) return reply.code(400).send({ error: 'Escolha uma imagem para enviar.' });
if (body.length > config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024) {
return reply.code(413).send({ error: `Cover image is larger than the ${config.BUNNY_COVER_MAX_UPLOAD_MB} MB upload limit.` });
}
@@ -104,6 +115,20 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
}
});
app.post('/assets', manageAccess, async (request, reply) => {
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
const body = request.body;
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de material.' });
try {
const asset = validateAssetUpload(body, contentType, config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024);
const stored = await uploadBunnyAsset({ body, contentType: asset.contentType, extension: asset.extension });
await recordAudit({ actorId: request.user.id, action: 'media.asset.uploaded', subjectType: 'asset', metadata: { key: stored.key, kind: asset.kind, sizeBytes: body.length }, ipAddress: request.ip });
return reply.code(201).send({ data: { ...stored, kind: asset.kind, sizeBytes: body.length } });
} catch (error) {
return storageError(reply, error);
}
});
app.post('/bunny/videos', manageAccess, async (request, reply) => {
const input = createVideoSchema.parse(request.body);
try {

View File

@@ -4,6 +4,7 @@ import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js';
import { courseSelect } from './courses.js';
const profileParamsSchema = z.object({ profileId: z.string().uuid() });
const instructorParamsSchema = z.object({ instructorId: z.string().uuid() });
const optionalUrl = z.union([z.string().url().max(500), z.literal(''), z.null()]).optional()
@@ -68,25 +69,23 @@ export const profileRoutes: FastifyPluginAsync = async (app) => {
app.patch('/me', { preHandler: app.authenticate }, async (request, reply) => {
const input = profileUpdateSchema.parse(request.body);
const isInstructor = request.user.role === 'instructor' || request.user.role === 'admin';
const result = await pool.query<ProfileRow>(
`update users set
display_name = $2,
avatar_image_url = case when $3::boolean then $4 else avatar_image_url end,
profile_headline = case when $3::boolean then $5 else profile_headline end,
profile_bio = case when $3::boolean then $6 else profile_bio end,
website_url = case when $3::boolean then $7 else website_url end,
linkedin_url = case when $3::boolean then $8 else linkedin_url end,
instagram_url = case when $3::boolean then $9 else instagram_url end,
youtube_url = case when $3::boolean then $10 else youtube_url end,
profile_is_public = case when $3::boolean then coalesce($11, profile_is_public) else profile_is_public end,
avatar_image_url = $3,
profile_headline = $4,
profile_bio = $5,
website_url = $6,
linkedin_url = $7,
instagram_url = $8,
youtube_url = $9,
profile_is_public = coalesce($10, profile_is_public),
updated_at = now()
where id = $1
returning ${profileColumns}, email`,
[
request.user.id,
input.name,
isInstructor,
input.avatarImageUrl,
input.headline,
input.bio,
@@ -98,26 +97,35 @@ export const profileRoutes: FastifyPluginAsync = async (app) => {
],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Profile not found' });
if (!profile) return reply.code(404).send({ error: 'Perfil não encontrado.' });
await recordAudit({ actorId: request.user.id, action: 'profile.updated', subjectType: 'user', subjectId: request.user.id, metadata: { public: profile.isPublic }, ipAddress: request.ip });
return { data: profile };
});
app.get('/instructors/:instructorId', async (request, reply) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
const getPublicProfile = async (profileId: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }, instructorOnly = false) => {
const result = await pool.query<ProfileRow>(
`select ${profileColumns}
from users
where id = $1 and profile_is_public and role in ('instructor', 'admin')`,
[instructorId],
where id = $1 and profile_is_public${instructorOnly ? " and role in ('instructor', 'admin')" : ''}`,
[profileId],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Instructor profile not found' });
if (!profile) return reply.code(404).send({ error: instructorOnly ? 'Perfil de instrutor não encontrado.' : 'Perfil não encontrado ou não está público.' });
const courses = await pool.query(
`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`,
[instructorId],
);
const canPublishCourses = profile.role === 'instructor' || profile.role === 'admin';
const courses = canPublishCourses
? await pool.query(`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`, [profileId])
: { rows: [] };
return { data: { ...profile, courses: courses.rows.map(withoutProtectedMedia) } };
};
app.get('/:profileId', async (request, reply) => {
const { profileId } = profileParamsSchema.parse(request.params);
return getPublicProfile(profileId, reply);
});
app.get('/instructors/:instructorId', async (request, reply) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
return getPublicProfile(instructorId, reply, true);
});
};

View File

@@ -0,0 +1,52 @@
type AssetKind = 'document' | 'spreadsheet' | 'archive';
type AssetDefinition = {
kind: AssetKind;
extension: string;
needsZipSignature?: boolean;
needsOleSignature?: boolean;
needsPdfSignature?: boolean;
needsTextContent?: boolean;
};
const allowedAssetTypes = new Map<string, AssetDefinition>([
['application/pdf', { kind: 'document', extension: 'pdf', needsPdfSignature: true }],
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', { kind: 'document', extension: 'docx', needsZipSignature: true }],
['application/msword', { kind: 'document', extension: 'doc', needsOleSignature: true }],
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', { kind: 'spreadsheet', extension: 'xlsx', needsZipSignature: true }],
['application/vnd.ms-excel', { kind: 'spreadsheet', extension: 'xls', needsOleSignature: true }],
['text/csv', { kind: 'spreadsheet', extension: 'csv', needsTextContent: true }],
['application/zip', { kind: 'archive', extension: 'zip', needsZipSignature: true }],
]);
export class AssetUploadValidationError extends Error {
constructor(message: string, public readonly statusCode: 400 | 413 | 415) {
super(message);
}
}
const zipSignature = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
const oleSignature = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
export function validateAssetUpload(body: Buffer, contentType: string | undefined, maxBytes: number) {
const normalizedType = contentType?.split(';')[0]?.trim().toLowerCase();
const definition = normalizedType ? allowedAssetTypes.get(normalizedType) : undefined;
if (!definition) {
throw new AssetUploadValidationError('Envie apenas arquivos PDF, DOCX, XLSX, CSV ou ZIP.', 415);
}
if (body.length === 0) throw new AssetUploadValidationError('Escolha um arquivo para enviar.', 400);
if (body.length > maxBytes) throw new AssetUploadValidationError(`O arquivo excede o limite de ${Math.floor(maxBytes / 1024 / 1024)} MB.`, 413);
if (definition.needsPdfSignature && !body.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
throw new AssetUploadValidationError('O arquivo enviado não é um PDF válido.', 415);
}
if (definition.needsZipSignature && !body.subarray(0, 4).equals(zipSignature)) {
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
}
if (definition.needsOleSignature && !body.subarray(0, 8).equals(oleSignature)) {
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
}
if (definition.needsTextContent && body.subarray(0, Math.min(body.length, 4096)).includes(0)) {
throw new AssetUploadValidationError('O arquivo CSV deve conter texto.', 415);
}
return { kind: definition.kind, extension: definition.extension, contentType: normalizedType as string };
}

View File

@@ -35,7 +35,7 @@ class ValidatedVideoStream extends Transform {
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
if (this.header.length >= 12) {
if (!isRecognizedVideo(this.header)) {
callback(new VideoUploadValidationError('Only valid MP4, WebM, and MOV video files are accepted.', 415));
callback(new VideoUploadValidationError('Envie apenas arquivos de vídeo MP4, WebM ou MOV válidos.', 415));
return;
}
this.validated = true;
@@ -46,7 +46,7 @@ class ValidatedVideoStream extends Transform {
override _flush(callback: (error?: Error | null) => void) {
if (!this.validated) {
callback(new VideoUploadValidationError('The uploaded file is not a valid video.', 415));
callback(new VideoUploadValidationError('O arquivo enviado não é um vídeo válido.', 415));
return;
}
callback();
@@ -56,7 +56,7 @@ class ValidatedVideoStream extends Transform {
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
throw new VideoUploadValidationError('Only MP4, WebM, and MOV video uploads are accepted.', 415);
throw new VideoUploadValidationError('Envie apenas vídeos MP4, WebM ou MOV.', 415);
}
return input.pipe(new ValidatedVideoStream(maxBytes));
}

View File

@@ -74,6 +74,13 @@ export interface LearningPathInput {
status: 'draft' | 'published';
courseIds: string[];
}
export interface TrailCourseOption {
id: string;
title: string;
status: 'draft' | 'published';
category: string;
coverImageUrl: string | null;
}
export interface HomeBannerInput {
kind: 'course' | 'custom';
courseId: string | null;
@@ -119,6 +126,26 @@ export function clearSession() {
localStorage.removeItem(SESSION_KEY);
}
export async function downloadCourseAsset(assetId: string, filename: string) {
const session = getSession();
const headers = new Headers();
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}/courses/assets/${assetId}/download`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error || 'Não foi possível baixar este material.', response.status);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const session = getSession();
const headers = new Headers(options.headers);
@@ -130,7 +157,7 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(body.error || 'The request could not be completed', response.status);
throw new ApiError(body.error || 'Não foi possível concluir a solicitação.', response.status);
}
return body as T;
}
@@ -141,7 +168,7 @@ async function uploadRequest<T>(path: string, file: File): Promise<T> {
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'The video upload could not be completed', response.status);
if (!response.ok) throw new ApiError(body.error || 'Não foi possível concluir o envio do vídeo.', response.status);
return body as T;
}
@@ -151,7 +178,7 @@ async function uploadImageRequest<T>(path: string, file: File): Promise<T> {
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'POST', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'The cover image could not be uploaded', response.status);
if (!response.ok) throw new ApiError(body.error || 'Não foi possível enviar a imagem de capa.', response.status);
return body as T;
}
@@ -197,6 +224,7 @@ export const adminApi = {
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 listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
async listPathCourses() { return apiRequest<{ data: TrailCourseOption[] }>('/admin/path-courses'); },
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' }); },
@@ -209,6 +237,7 @@ export const adminApi = {
export const profileApi = {
async me() { return apiRequest<{ data: Profile }>('/profiles/me'); },
async update(input: ProfileInput) { return apiRequest<{ data: Profile }>('/profiles/me', { method: 'PATCH', body: JSON.stringify(input) }); },
async public(profileId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/${profileId}`); },
async instructor(instructorId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/instructors/${instructorId}`); },
};
@@ -229,6 +258,8 @@ export const instructorApi = {
},
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
async assetConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/assets/config'); },
async uploadAsset(file: File) { return uploadImageRequest<{ data: { key: string; assetUrl: string; kind: 'document' | 'spreadsheet' | 'archive'; sizeBytes: number } }>('/manage/media/assets', file); },
};
export const courseApi = {

View File

@@ -8,6 +8,8 @@ type ApiAsset = {
name: string;
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
url: string;
sizeBytes: number | null;
createdAt: string;
description: string;
};
@@ -88,9 +90,19 @@ const toAttachment = (asset: ApiAsset): Attachment => ({
name: asset.name,
type: toAttachmentType(asset.kind),
url: asset.url,
size: asset.sizeBytes === null ? undefined : formatBytes(asset.sizeBytes),
sizeBytes: asset.sizeBytes ?? undefined,
createdAt: asset.createdAt,
description: asset.description,
});
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)) - 1, units.length - 1);
return `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(bytes / 1024 ** (index + 1))} ${units[index]}`;
};
const toDuration = (seconds: number | null) => {
if (!seconds || seconds < 1) return '—';
const totalSeconds = Math.round(seconds);
@@ -166,10 +178,19 @@ const toAssetPayload = (attachment: Attachment) => ({
name: attachment.name,
kind: toAssetKind(attachment.type),
url: attachment.url,
sizeBytes: attachment.sizeBytes ?? parseSizeBytes(attachment.size),
description: attachment.description || '',
accessLevel: 'enrolled' as const,
});
const parseSizeBytes = (value?: string) => {
if (!value) return null;
const match = value.trim().replace(',', '.').match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i);
if (!match) return null;
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
return Math.round(Number(match[1]) * units[match[2].toUpperCase()]);
};
const toCoursePayload = (course: Course) => ({
title: course.title,
description: course.description,
@@ -210,6 +231,7 @@ export const getCourses = async (): Promise<Course[]> => {
export type InstructorProfileData = {
id: string;
name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null;
headline: string;
bio: string;
@@ -225,6 +247,11 @@ export const getInstructorProfile = async (instructorId: string): Promise<Instru
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getPublicProfile = async (profileId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/${profileId}`);
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getLearningPaths = async (): Promise<PublicLearningPath[]> => {
const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths');
return response.data;

View File

@@ -8,6 +8,8 @@ export interface Attachment {
type: AttachmentType;
url: string;
size?: string; // e.g. "4.2 MB"
sizeBytes?: number;
createdAt?: string;
description?: string;
lessonId?: string; // Associated lesson or global course asset
downloadCount?: number;