feat: improve learning and platform operations
All checks were successful
CI / Validate frontend and API (push) Successful in 45s
CI / Build and publish Docker images (push) Successful in 24s

This commit is contained in:
Cauê Faleiros
2026-09-01 15:38:14 -03:00
parent 4736fb5208
commit 3d4c72e85c
20 changed files with 292 additions and 45 deletions

View File

@@ -14,6 +14,8 @@ JWT_SECRET=replace-this-with-a-long-random-secret-before-deploying
# Keep them out of Git and use a password with at least 12 characters. # Keep them out of Git and use a password with at least 12 characters.
SUPERADMIN_EMAIL=admin@example.com SUPERADMIN_EMAIL=admin@example.com
SUPERADMIN_PASSWORD=change-this-password SUPERADMIN_PASSWORD=change-this-password
AUTH_RATE_LIMIT_MAX=10
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
SUPERADMIN_NAME=Compor HUB Superadmin SUPERADMIN_NAME=Compor HUB Superadmin
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images. # Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.

View File

@@ -11,6 +11,7 @@ import { ToastProvider } from './context/ToastContext';
import { Course, UserRole } from './types'; import { Course, UserRole } from './types';
import { SuperAdmin } from './pages/SuperAdmin'; import { SuperAdmin } from './pages/SuperAdmin';
import { AccessTokenPage } from './pages/AccessTokenPage'; import { AccessTokenPage } from './pages/AccessTokenPage';
import { MyLearning } from './pages/MyLearning';
// Protected Route Component // Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => { const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
@@ -91,6 +92,7 @@ function AppContent() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
<Route path="/invite" element={<AccessTokenPage mode="invite" />} /> <Route path="/invite" element={<AccessTokenPage mode="invite" />} />
<Route path="/reset-password" element={<AccessTokenPage mode="reset" />} /> <Route path="/reset-password" element={<AccessTokenPage mode="reset" />} />

View File

@@ -16,19 +16,33 @@ The API health endpoint is available at `http://localhost:3001/api/v1/health` an
- `GET /api/v1/courses` returns published courses. - `GET /api/v1/courses` returns published courses.
- `GET /api/v1/courses/:courseId` returns one published course. - `GET /api/v1/courses/:courseId` returns one published course.
- `GET /api/v1/courses/me/learning` returns the authenticated learner's started courses, ordered by their last activity, with completion percentage.
- `GET /api/v1/courses/:courseId/progress` and `PUT /api/v1/lessons/:lessonId/progress` persist completion and watch position.
- `GET /api/v1/admin/audit-log` exposes the last 50 administrative actions to superadmins.
## Accounts and instructor access ## Accounts and instructor access
- `POST /api/v1/auth/register` creates student accounts only. - `POST /api/v1/auth/register` creates student accounts only.
- `POST /api/v1/auth/login` creates a seven-day signed session. - `POST /api/v1/auth/login` creates a seven-day signed session.
- `GET /api/v1/auth/me` restores an existing session. - `GET /api/v1/auth/me` restores an existing session.
- Public authentication endpoints are rate-limited per source IP. Configure `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` if the defaults (10 attempts / 15 minutes) do not fit your environment.
- `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses. - `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses.
Courses can be saved as a draft or published. Drafts are visible only in the managing instructor's dashboard and are never exposed by public course endpoints. Existing lessons keep their IDs when a course is edited, preserving learner progress and comment history; a lesson with progress or comments cannot be removed.
To create the first local administrator, set `SUPERADMIN_EMAIL`, `SUPERADMIN_PASSWORD`, and optionally `SUPERADMIN_NAME`, then run `npm run db:bootstrap-admin`. In Docker/Portainer, the API runs this command automatically after migrations. To create the first local administrator, set `SUPERADMIN_EMAIL`, `SUPERADMIN_PASSWORD`, and optionally `SUPERADMIN_NAME`, then run `npm run db:bootstrap-admin`. In Docker/Portainer, the API runs this command automatically after migrations.
For local demos, `npm run db:seed-demo-content` imports the original frontend catalogue into PostgreSQL. It requires the bootstrap administrator to exist and skips courses already present. For local demos, `npm run db:seed-demo-content` imports the original frontend catalogue into PostgreSQL. It requires the bootstrap administrator to exist and skips courses already present.
Public course endpoints return only public lessons and public assets. Enrolment checks, learner progress, and comments will be added in the next milestone. Anonymous visitors can browse course and lesson information, but media and download links are removed from their response. A signed-in account is required to play lessons, download materials, track progress, or participate in discussion.
## Provider boundaries
Videos remain provider-neutral through `lesson_media.provider` and `lesson_media.external_id`. The platform does not yet create signed playback URLs because that requires the chosen provider's credentials and API. Do not add a provider secret until Panda Video, Vimeo, or another provider has been selected. Invitations and password resets currently generate secure, expiring links for the superadmin to copy; email delivery will be connected once an email service is chosen.
## Administrative operations
The superadmin page supports user activation/role changes, invitations, password reset links, user learning details, CSV export, platform metrics, and an immutable-style activity log. Audit records cover invitations, reset links, user updates, course publishing/drafts/archive, and instructor comment moderation.
## CI/CD status ## CI/CD status

View File

@@ -20,6 +20,7 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`. - `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator. - `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
- `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters). - `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters).
- `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` are optional login and public-auth throttling controls (defaults: 10 attempts per 900 seconds per source IP).
Optional variables: Optional variables:

View File

@@ -19,7 +19,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Course, Comment, Lesson, Attachment } from '../types'; import { Course, Comment, Lesson, Attachment } from '../types';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { getComments, getCompletedLessonIds, saveComment, saveLessonCompletion, incrementViews } from '../services/db'; import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard'; import { MaterialCard } from './MaterialCard';
@@ -34,21 +34,30 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null); const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]); const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]);
const [watchedSecondsByLesson, setWatchedSecondsByLesson] = useState<Record<string, number>>({});
const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist'); const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
const [copiedLink, setCopiedLink] = useState(false); const [copiedLink, setCopiedLink] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const lastProgressSave = useRef<Record<string, number>>({});
// Load course data and this learner's saved state from PostgreSQL. // Load course data and this learner's saved state from PostgreSQL.
useEffect(() => { useEffect(() => {
if (course) { if (course) {
incrementViews(course.id); incrementViews(course.id);
setCompletedLessonIds([]); setCompletedLessonIds([]);
setWatchedSecondsByLesson({});
lastProgressSave.current = {};
if (user) { if (user) {
getComments(course.id).then(setComments).catch(() => setComments([])); getComments(course.id).then(setComments).catch(() => setComments([]));
getCompletedLessonIds(course.id).then((completed) => { getLessonProgress(course.id).then((progress) => {
const completed = progress.filter((item) => item.completedAt).map((item) => item.lessonId);
setCompletedLessonIds(completed); setCompletedLessonIds(completed);
setWatchedSecondsByLesson(Object.fromEntries(progress.map((item) => [item.lessonId, item.watchedSeconds])));
lastProgressSave.current = Object.fromEntries(progress.map((item) => [item.lessonId, item.watchedSeconds]));
const nextIncomplete = course.lessons.find((lesson) => !completed.includes(lesson.id));
if (nextIncomplete) setCurrentLesson(nextIncomplete);
}).catch(() => setCompletedLessonIds([])); }).catch(() => setCompletedLessonIds([]));
} else { } else {
setComments([]); setComments([]);
@@ -78,17 +87,21 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
updateCompletedLessons(updated); updateCompletedLessons(updated);
saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds)); saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds));
} else { } else {
const updated = [...completedLessonIds, lessonId]; markLessonCompleted(lessonId);
updateCompletedLessons(updated);
saveLessonCompletion(lessonId, true).catch(() => updateCompletedLessons(completedLessonIds));
} }
}; };
const markLessonCompleted = (lessonId: string, watchedSeconds?: number) => {
if (!user || completedLessonIds.includes(lessonId)) return;
const previous = completedLessonIds;
updateCompletedLessons([...previous, lessonId]);
saveLessonCompletion(lessonId, true, watchedSeconds).catch(() => updateCompletedLessons(previous));
};
// Video finished automatically -> mark lesson as completed // Video finished automatically -> mark lesson as completed
const handleVideoEnded = () => { const handleVideoEnded = () => {
if (currentLesson && !completedLessonIds.includes(currentLesson.id)) { if (currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id]; markLessonCompleted(currentLesson.id, Math.round(videoRef.current?.duration || 0));
updateCompletedLessons(updated);
} }
// Auto-advance to next lesson if available // Auto-advance to next lesson if available
@@ -110,16 +123,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
if (videoRef.current) { if (videoRef.current) {
const { currentTime, duration } = videoRef.current; const { currentTime, duration } = videoRef.current;
if (duration > 0) { if (duration > 0) {
if (user && currentLesson && currentTime - (lastProgressSave.current[currentLesson.id] || 0) >= 30) {
const watchedSeconds = Math.floor(currentTime);
lastProgressSave.current[currentLesson.id] = watchedSeconds;
setWatchedSecondsByLesson((current) => ({ ...current, [currentLesson.id]: watchedSeconds }));
saveLessonProgress(currentLesson.id, watchedSeconds).catch(() => undefined);
}
const percent = Math.round((currentTime / duration) * 100); const percent = Math.round((currentTime / duration) * 100);
// Automatically mark as complete when reaching 95%+ // Automatically mark as complete when reaching 95%+
if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) { if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id]; markLessonCompleted(currentLesson.id, Math.round(currentTime));
updateCompletedLessons(updated);
} }
} }
} }
}; };
const restoreWatchPosition = () => {
if (!currentLesson || !videoRef.current) return;
const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0;
if (watchedSeconds > 0 && watchedSeconds < videoRef.current.duration - 5) videoRef.current.currentTime = watchedSeconds;
};
const handleAddComment = async (e: React.FormEvent) => { const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newComment.trim() || !course) return; if (!newComment.trim() || !course) return;
@@ -224,6 +248,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
autoPlay autoPlay
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded} onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
> >
Seu navegador não suporta a tag de vídeo. Seu navegador não suporta a tag de vídeo.
</video> </video>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { User as UserIcon, LogOut, Search, Folder } from 'lucide-react'; import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { SearchModal } from './SearchModal'; import { SearchModal } from './SearchModal';
@@ -100,6 +100,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</button> </button>
) : ( ) : (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.role === 'student' && <button onClick={() => navigate('/meu-aprendizado')} className={`hidden sm:inline-flex items-center gap-2 text-sm font-semibold px-4 py-2.5 rounded-[980px] transition-colors ${location.pathname === '/meu-aprendizado' ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' : 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10'}`}><BookOpenCheck className="w-4 h-4" /> Meu aprendizado</button>}
{user.role === 'superadmin' ? ( {user.role === 'superadmin' ? (
<button <button
onClick={() => navigate('/admin')} onClick={() => navigate('/admin')}
@@ -150,4 +151,3 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</> </>
); );
}; };

View File

@@ -42,6 +42,7 @@ const CourseModal: React.FC<{
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [status, setStatus] = useState<Course['status']>('draft');
const [thumbnailPreview, setThumbnailPreview] = useState<string>(''); const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const [instructor, setInstructor] = useState('Mário Morgado'); const [instructor, setInstructor] = useState('Mário Morgado');
const [instructorRole, setInstructorRole] = useState('Especialista em Performance'); const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
@@ -76,6 +77,7 @@ const CourseModal: React.FC<{
setTitle(initialData.title); setTitle(initialData.title);
setDescription(initialData.description); setDescription(initialData.description);
setCategory(initialData.category); setCategory(initialData.category);
setStatus(initialData.status || 'published');
setThumbnailPreview(initialData.thumbnail); setThumbnailPreview(initialData.thumbnail);
setInstructor(initialData.instructor || 'Mário Morgado'); setInstructor(initialData.instructor || 'Mário Morgado');
setInstructorRole(initialData.instructorRole || 'Especialista em Performance'); setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
@@ -86,6 +88,7 @@ const CourseModal: React.FC<{
setTitle(''); setTitle('');
setDescription(''); setDescription('');
setCategory(CATEGORIES[0]); setCategory(CATEGORIES[0]);
setStatus('draft');
setThumbnailPreview(''); setThumbnailPreview('');
setInstructor('Mário Morgado'); setInstructor('Mário Morgado');
setInstructorRole('Especialista em Performance'); setInstructorRole('Especialista em Performance');
@@ -187,6 +190,7 @@ const CourseModal: React.FC<{
title, title,
description, description,
category: category || CATEGORIES[0], category: category || CATEGORIES[0],
status,
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80', thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
duration: durationStr, duration: durationStr,
instructor, instructor,
@@ -198,8 +202,8 @@ const CourseModal: React.FC<{
attachments: attachments attachments: attachments
}); });
onClose(); onClose();
} catch { } catch (error) {
alert('We could not save this course. Confirm that you are still signed in and that all asset URLs are valid.'); alert(error instanceof Error ? error.message : 'Não foi possível salvar este curso.');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -275,6 +279,14 @@ const CourseModal: React.FC<{
</select> </select>
</div> </div>
<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="published" className="bg-zinc-900">Publicar agora</option>
</select>
</div>
<div> <div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label> <label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
<input <input
@@ -507,7 +519,7 @@ const CourseModal: React.FC<{
disabled={isSubmitting} disabled={isSubmitting}
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25" className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25"
> >
{isSubmitting ? 'Salvando...' : (initialData ? 'Salvar Alterações no Curso' : 'Publicar Curso & Materiais')} {isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como Rascunho' : (initialData ? 'Salvar e Publicar Curso' : 'Publicar Curso & Materiais')}
</button> </button>
</div> </div>
</div> </div>
@@ -532,21 +544,18 @@ export const ManageCourses: React.FC = () => {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
const data = await getManagedCourses(); try {
setCourses(data); const data = await getManagedCourses();
const analyticsResponse = await instructorApi.analytics(); setCourses(data);
setAnalytics(analyticsResponse.data); const analyticsResponse = await instructorApi.analytics();
setAnalytics(analyticsResponse.data);
// Load all comments const commentLists = await Promise.all(data.map(async (course) => ({ course, comments: await getComments(course.id) })));
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = []; setAllComments(commentLists.flatMap(({ course, comments }) => comments.map((comment) => ({ comment, courseTitle: course.title }))));
for (const c of data) { } catch {
const courseComments = await getComments(c.id); showToast('Não foi possível carregar o painel do instrutor.', 'error');
courseComments.forEach(comm => { } finally {
commentsAccumulator.push({ comment: comm, courseTitle: c.title }); setLoading(false);
});
} }
setAllComments(commentsAccumulator);
setLoading(false);
}; };
useEffect(() => { useEffect(() => {
@@ -713,6 +722,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"> <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} {course.category}
</div> </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>}
</div> </div>
{/* Body */} {/* Body */}

39
pages/MyLearning.tsx Normal file
View File

@@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react';
import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react';
import { Course } from '../types';
import { getMyLearningCourses } from '../services/db';
export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getMyLearningCourses().then(setCourses).catch(() => setCourses([])).finally(() => setLoading(false));
}, []);
return (
<main className="min-h-screen max-w-[1440px] mx-auto pt-28 pb-20 px-6 md:px-12">
<div className="mb-10">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400 mb-3"><BookOpenCheck className="w-4 h-4" /> Minha área</div>
<h1 className="text-3xl md:text-4xl font-bold text-white">Continue aprendendo</h1>
<p className="mt-2 text-gray-400">Retome suas aulas exatamente de onde parou.</p>
</div>
{loading ? <div className="h-64 flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-orange-400" /></div> : courses.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-zinc-950/80 py-16 px-6 text-center">
<BookOpenCheck className="w-10 h-10 text-orange-400 mx-auto mb-4" />
<h2 className="font-bold text-white text-lg">Você ainda não iniciou nenhum curso</h2>
<p className="mt-2 text-sm text-gray-400">Escolha uma aula na página de cursos para começar.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{courses.map((course) => (
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
<div className="aspect-video relative bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 group-hover:opacity-100 transition-opacity" /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
</button>
))}
</div>
)}
</main>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react'; import { Download, Loader2, RefreshCw, ScrollText, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
import { adminApi, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api'; import { adminApi, AuditEntry, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -25,14 +25,15 @@ export const SuperAdmin: React.FC = () => {
const [accessLink, setAccessLink] = useState(''); const [accessLink, setAccessLink] = useState('');
const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null); const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null);
const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null); const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null);
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const loadUsers = async () => { const loadUsers = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await adminApi.listUsers(); const [response, dashboard, auditLog] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog()]);
setUsers(response.data); setUsers(response.data);
const dashboard = await adminApi.dashboard();
setAnalytics(dashboard.data); setAnalytics(dashboard.data);
setAuditEntries(auditLog.data);
} catch { } catch {
showToast('Não foi possível carregar os usuários.', 'error'); showToast('Não foi possível carregar os usuários.', 'error');
} finally { } finally {
@@ -89,6 +90,12 @@ export const SuperAdmin: React.FC = () => {
setLoadingDetailId(null); setLoadingDetailId(null);
} }
}; };
const exportUsers = () => {
const csv = ['Nome,E-mail,Perfil,Status,Cadastro', ...filteredUsers.map((account) => [account.name, account.email, roleLabel[account.role], account.isActive ? 'Ativo' : 'Desativado', new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))].map((value) => `"${value.replaceAll('"', '""')}"`).join(','))].join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a'); link.href = url; link.download = 'usuarios-compor-hub.csv'; link.click(); URL.revokeObjectURL(url);
};
const auditLabel: Record<string, string> = { 'invitation.created': 'Convite criado', 'password_reset.created': 'Redefinição de senha criada', 'user.updated': 'Usuário atualizado', 'course.published': 'Curso publicado', 'course.drafted': 'Rascunho salvo', 'course.archived': 'Curso arquivado', 'comment.replied': 'Comentário respondido', 'comment.deleted': 'Comentário removido' };
return ( return (
<main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto"> <main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
@@ -113,6 +120,7 @@ export const SuperAdmin: React.FC = () => {
> >
<RefreshCw className="w-4 h-4" /> Atualizar <RefreshCw className="w-4 h-4" /> Atualizar
</button> </button>
<button onClick={exportUsers} className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-white/10 hover:bg-white/15 text-white transition-colors"><Download className="w-4 h-4" /> Exportar CSV</button>
</div> </div>
</div> </div>
@@ -226,6 +234,11 @@ export const SuperAdmin: React.FC = () => {
</div> </div>
)} )}
</section> </section>
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden mt-8">
<div className="p-5 border-b border-white/10 flex items-center gap-2"><ScrollText className="w-5 h-5 text-orange-400" /><h2 className="text-lg font-bold text-white">Registro de atividades</h2></div>
{auditEntries.length === 0 ? <p className="px-5 py-10 text-sm text-gray-500">Nenhuma atividade administrativa registrada ainda.</p> : <div className="divide-y divide-white/5">{auditEntries.map((entry) => <div key={entry.id} className="p-4 flex flex-col sm:flex-row sm:items-center gap-1 sm:justify-between text-sm"><div><span className="font-semibold text-white">{auditLabel[entry.action] || entry.action}</span><span className="text-gray-500"> · {entry.actorName}</span></div><time className="text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(entry.createdAt))}</time></div>)}</div>}
</section>
</main> </main>
); );
}; };

View File

@@ -0,0 +1,13 @@
create table audit_logs (
id uuid primary key default gen_random_uuid(),
actor_id uuid references users(id) on delete set null,
action text not null check (char_length(action) between 1 and 120),
subject_type text not null check (char_length(subject_type) between 1 and 80),
subject_id uuid,
metadata jsonb not null default '{}'::jsonb,
ip_address inet,
created_at timestamptz not null default now()
);
create index audit_logs_created_index on audit_logs (created_at desc);
create index audit_logs_actor_index on audit_logs (actor_id, created_at desc);

18
server/src/audit.ts Normal file
View File

@@ -0,0 +1,18 @@
import { pool } from './db/pool.js';
type AuditInput = {
actorId: string;
action: string;
subjectType: string;
subjectId?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
};
export const recordAudit = async (input: AuditInput) => {
await pool.query(
`insert into audit_logs (actor_id, action, subject_type, subject_id, metadata, ip_address)
values ($1, $2, $3, $4::uuid, $5::jsonb, $6::inet)`,
[input.actorId, input.action, input.subjectType, input.subjectId ?? null, JSON.stringify(input.metadata ?? {}), input.ipAddress ?? null],
);
};

View File

@@ -13,6 +13,8 @@ const environmentSchema = z.object({
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()), SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)), SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)),
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'), SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
}); });
export const config = environmentSchema.parse(process.env); export const config = environmentSchema.parse(process.env);

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { createRawToken, hashToken } from '../auth/account-tokens.js'; import { createRawToken, hashToken } from '../auth/account-tokens.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { recordAudit } from '../audit.js';
const userParamsSchema = z.object({ const userParamsSchema = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
@@ -41,6 +42,18 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
return { data: result.rows[0] }; return { data: result.rows[0] };
}); });
app.get('/audit-log', adminAccess, async () => {
const result = await pool.query(
`select a.id, a.action, a.subject_type as "subjectType", a.subject_id as "subjectId",
a.metadata, a.created_at as "createdAt", coalesce(u.display_name, 'Sistema') as "actorName"
from audit_logs a
left join users u on u.id = a.actor_id
order by a.created_at desc
limit 50`,
);
return { data: result.rows };
});
app.get('/users/:userId', adminAccess, async (request, reply) => { app.get('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params); const { userId } = userParamsSchema.parse(request.params);
const result = await pool.query( const result = await pool.query(
@@ -63,6 +76,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`, values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`,
[input.email, input.role, hashToken(rawToken), request.user.id], [input.email, input.role, hashToken(rawToken), request.user.id],
); );
await recordAudit({ actorId: request.user.id, action: 'invitation.created', subjectType: 'invitation', metadata: { email: input.email, role: input.role }, ipAddress: request.ip });
return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } }); return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } });
}); });
@@ -76,6 +90,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
values ($1, $2::user_role, 'password_reset', $3, now() + interval '1 day', $4)`, values ($1, $2::user_role, 'password_reset', $3, now() + interval '1 day', $4)`,
[account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id], [account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id],
); );
await recordAudit({ actorId: request.user.id, action: 'password_reset.created', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email }, ipAddress: request.ip });
return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } }; return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } };
}); });
@@ -97,6 +112,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
); );
const account = result.rows[0]; const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' }); if (!account) return reply.code(404).send({ error: 'User not found' });
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
return { data: account }; return { data: account };
}); });
}; };

View File

@@ -4,6 +4,7 @@ import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { hashToken } from '../auth/account-tokens.js'; import { hashToken } from '../auth/account-tokens.js';
import type { AuthUser } from '../auth/plugin.js'; import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { config } from '../config.js';
const credentialsSchema = z.object({ const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()), email: z.string().email().transform((email) => email.toLowerCase()),
@@ -32,7 +33,27 @@ const serializeUser = (user: UserRow): AuthUser => ({
}); });
export const authRoutes: FastifyPluginAsync = async (app) => { export const authRoutes: FastifyPluginAsync = async (app) => {
const publicAttempts = new Map<string, number[]>();
const allowPublicAuthAttempt = (ip: string) => {
const now = Date.now();
const earliestAllowed = now - config.AUTH_RATE_LIMIT_WINDOW_SECONDS * 1000;
const attempts = (publicAttempts.get(ip) || []).filter((timestamp) => timestamp > earliestAllowed);
if (attempts.length >= config.AUTH_RATE_LIMIT_MAX) {
publicAttempts.set(ip, attempts);
return false;
}
attempts.push(now);
publicAttempts.set(ip, attempts);
return true;
};
const rejectIfRateLimited = (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
if (allowPublicAuthAttempt(ip)) return false;
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
return true;
};
app.post('/accept-invitation', async (request, reply) => { app.post('/accept-invitation', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body); const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -63,6 +84,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}); });
app.post('/reset-password', async (request, reply) => { app.post('/reset-password', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.parse(request.body); const input = tokenPasswordSchema.parse(request.body);
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -87,6 +109,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
} }
}); });
app.post('/register', async (request, reply) => { app.post('/register', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = registerSchema.parse(request.body); const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password); const passwordHash = await hashPassword(input.password);
@@ -109,6 +132,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}); });
app.post('/login', async (request, reply) => { app.post('/login', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = credentialsSchema.parse(request.body); const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>( const result = await pool.query<UserRow>(
`select id, email, display_name, role, password_hash, is_active from users where email = $1`, `select id, email, display_name, role, password_hash, is_active from users where email = $1`,

View File

@@ -100,6 +100,29 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) }; return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
}); });
app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query(
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"
from (
${courseSelect()}
where c.status = 'published'
) base
left join lateral (
select
case when count(l.id) = 0 then 0
else floor(100.0 * count(l.id) filter (where lp.completed_at is not null) / count(l.id))::int end as progress,
max(lp.updated_at) as "lastActivity"
from lessons l
left join lesson_progress lp on lp.lesson_id = l.id and lp.user_id = $1
where l.course_id = base.id
) progress on true
where progress."lastActivity" is not null
order by progress."lastActivity" desc`,
[request.user.id],
);
return { data: result.rows };
});
app.get('/:courseId', async (request, reply) => { app.get('/:courseId', async (request, reply) => {
const authenticated = await hasSession(request); const authenticated = await hasSession(request);
const { courseId } = paramsSchema.parse(request.params); const { courseId } = paramsSchema.parse(request.params);

View File

@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js';
const courseParamsSchema = z.object({ const courseParamsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
@@ -11,9 +12,9 @@ const lessonParamsSchema = z.object({
}); });
const completionSchema = z.object({ const completionSchema = z.object({
completed: z.boolean(), completed: z.boolean().optional(),
watchedSeconds: z.coerce.number().int().min(0).optional(), watchedSeconds: z.coerce.number().int().min(0).optional(),
}); }).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Provide progress or completion state' });
const commentSchema = z.object({ const commentSchema = z.object({
lessonId: z.string().uuid().nullable().optional(), lessonId: z.string().uuid().nullable().optional(),
@@ -30,6 +31,12 @@ const ensurePublishedCourse = async (courseId: string) => {
return result.rowCount === 1; return result.rowCount === 1;
}; };
const canViewCourseComments = async (courseId: string, user: { id: string; role: string }) => {
const result = await pool.query<{ status: string; instructor_id: string }>('select status, instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0];
return Boolean(course && (course.status === 'published' || user.role === 'admin' || course.instructor_id === user.id));
};
export const learningRoutes: FastifyPluginAsync = async (app) => { export const learningRoutes: FastifyPluginAsync = async (app) => {
const canModerateComment = async (commentId: string, user: { id: string; role: string }) => { const canModerateComment = async (commentId: string, user: { id: string; role: string }) => {
const result = await pool.query<{ instructor_id: string }>( const result = await pool.query<{ instructor_id: string }>(
@@ -76,20 +83,22 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const result = await pool.query( const result = await pool.query(
`insert into lesson_progress (lesson_id, user_id, watched_seconds, completed_at) `insert into lesson_progress (lesson_id, user_id, watched_seconds, completed_at)
values ($1, $2, $3, case when $4 then now() else null end) values ($1, $2, $3, case when $4 is true then now() else null end)
on conflict (lesson_id, user_id) do update set on conflict (lesson_id, user_id) do update set
watched_seconds = greatest(lesson_progress.watched_seconds, excluded.watched_seconds), watched_seconds = greatest(lesson_progress.watched_seconds, excluded.watched_seconds),
completed_at = case when $4 then coalesce(lesson_progress.completed_at, now()) else null end completed_at = case when $4 is true then coalesce(lesson_progress.completed_at, now())
when $4 is false then null
else lesson_progress.completed_at end
returning lesson_id as "lessonId", watched_seconds as "watchedSeconds", returning lesson_id as "lessonId", watched_seconds as "watchedSeconds",
completed_at as "completedAt", updated_at as "updatedAt"`, completed_at as "completedAt", updated_at as "updatedAt"`,
[lessonId, request.user.id, input.watchedSeconds || 0, input.completed], [lessonId, request.user.id, input.watchedSeconds || 0, input.completed ?? null],
); );
return { data: result.rows[0] }; return { data: result.rows[0] };
}); });
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => { app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
if (!(await ensurePublishedCourse(courseId))) { if (!(await canViewCourseComments(courseId, request.user))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Course not found' });
} }
@@ -146,6 +155,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`, on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`,
[commentId, request.user.id, input.text], [commentId, request.user.id, input.text],
); );
await recordAudit({ actorId: request.user.id, action: 'comment.replied', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
return { data: { id: commentId } }; return { data: { id: commentId } };
}); });
@@ -153,6 +163,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const { commentId } = commentParamsSchema.parse(request.params); 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: 'You cannot moderate this comment' });
await pool.query('delete from comments where id = $1', [commentId]); 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(); return reply.code(204).send();
}); });
}; };

View File

@@ -4,6 +4,7 @@ import type { PoolClient } from 'pg';
import { z } from 'zod'; import { z } from 'zod';
import { courseSelect } from './courses.js'; import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js';
const mediaSchema = z.object({ const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80), provider: z.string().trim().min(1).max(80),
@@ -46,6 +47,8 @@ const courseSchema = z.object({
const paramsSchema = z.object({ courseId: z.string().uuid() }); const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>; type CourseInput = z.infer<typeof courseSchema>;
class CourseContentConflict extends Error {}
function slugify(value: string) { function slugify(value: string) {
return value return value
.normalize('NFD') .normalize('NFD')
@@ -97,7 +100,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
[removedLessonIds], [removedLessonIds],
); );
if (usage.rowCount) { if (usage.rowCount) {
throw new Error('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.'); throw new CourseContentConflict('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.');
} }
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]); await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
} }
@@ -193,9 +196,11 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
); );
await replaceCourseContents(client, course.rows[0].id, input); await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit'); await client.query('commit');
await recordAudit({ actorId: request.user.id, action: input.status === 'published' ? 'course.published' : 'course.drafted', subjectType: 'course', subjectId: course.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
return reply.code(201).send({ data: { id: course.rows[0].id } }); return reply.code(201).send({ data: { id: course.rows[0].id } });
} catch (error) { } catch (error) {
await client.query('rollback'); await client.query('rollback');
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
throw error; throw error;
} finally { } finally {
client.release(); client.release();
@@ -221,9 +226,11 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
); );
await replaceCourseContents(client, courseId, input); await replaceCourseContents(client, courseId, input);
await client.query('commit'); await client.query('commit');
await recordAudit({ actorId: request.user.id, action: input.status === 'published' ? 'course.published' : 'course.drafted', subjectType: 'course', subjectId: courseId, metadata: { title: input.title }, ipAddress: request.ip });
return { data: { id: courseId } }; return { data: { id: courseId } };
} catch (error) { } catch (error) {
await client.query('rollback'); await client.query('rollback');
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
throw error; throw error;
} finally { } finally {
client.release(); client.release();
@@ -235,6 +242,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const accessError = await assertCanManageCourse(courseId, request.user); const accessError = await assertCanManageCourse(courseId, request.user);
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error }); if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]); await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
return reply.code(204).send(); return reply.code(204).send();
}); });
}; };

View File

@@ -24,6 +24,7 @@ export interface ManagedUserDetail extends ManagedUser {
} }
export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; } export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; }
export interface InstructorAnalytics { courses: number; lessons: number; learners: number; completedLessons: number; comments: number; } export interface InstructorAnalytics { courses: number; lessons: number; learners: number; completedLessons: number; comments: number; }
export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; }
interface Session { interface Session {
token: string; token: string;
@@ -107,6 +108,7 @@ export const adminApi = {
return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' }); return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' });
}, },
async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); }, async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); },
async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); },
}; };
export const instructorApi = { export const instructorApi = {

View File

@@ -17,6 +17,9 @@ type ApiCourse = {
description: string; description: string;
category: string; category: string;
coverImageUrl: string | null; coverImageUrl: string | null;
status: 'draft' | 'published' | 'archived';
progress?: number;
lastActivity?: string | null;
instructor: { name: string }; instructor: { name: string };
lessons: Array<{ lessons: Array<{
id: string; id: string;
@@ -57,6 +60,9 @@ const toCourse = (course: ApiCourse): Course => ({
category: course.category, category: course.category,
thumbnail: course.coverImageUrl || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80', thumbnail: course.coverImageUrl || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
instructor: course.instructor.name, instructor: course.instructor.name,
status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress,
lastActivity: course.lastActivity || undefined,
duration: `${course.lessons.length} Aulas`, duration: `${course.lessons.length} Aulas`,
lessons: course.lessons.map((lesson): Lesson => ({ lessons: course.lessons.map((lesson): Lesson => ({
id: lesson.id, id: lesson.id,
@@ -98,7 +104,7 @@ const toCoursePayload = (course: Course) => ({
description: course.description, description: course.description,
category: course.category, category: course.category,
coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null, coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null,
status: 'published' as const, status: course.status || 'published',
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({ lessons: course.lessons.map((lesson) => ({
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined, id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
@@ -123,6 +129,11 @@ export const getManagedCourses = async (): Promise<Course[]> => {
return response.data.map(toCourse); return response.data.map(toCourse);
}; };
export const getMyLearningCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/courses/me/learning');
return response.data.map(toCourse);
};
export const getCourseById = async (id: string): Promise<Course | null> => { export const getCourseById = async (id: string): Promise<Course | null> => {
try { try {
const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`); const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`);
@@ -192,15 +203,26 @@ export const moderateComment = async (commentId: string): Promise<void> => {
}; };
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => { export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => {
const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`); const progress = await getLessonProgress(courseId);
return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId); return progress.filter((item) => item.completedAt).map((item) => item.lessonId);
}; };
export const saveLessonCompletion = async (lessonId: string, completed: boolean): Promise<void> => { export type LessonProgress = { lessonId: string; watchedSeconds: number; completedAt: string | null };
export const getLessonProgress = async (courseId: string): Promise<LessonProgress[]> => {
const response = await apiRequest<{ data: LessonProgress[] }>(`/courses/${courseId}/progress`);
return response.data;
};
export const saveLessonCompletion = async (lessonId: string, completed: boolean, watchedSeconds?: number): Promise<void> => {
await apiRequest(`/lessons/${lessonId}/progress`, { await apiRequest(`/lessons/${lessonId}/progress`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ completed }), body: JSON.stringify({ completed, watchedSeconds }),
}); });
}; };
export const saveLessonProgress = async (lessonId: string, watchedSeconds: number): Promise<void> => {
await apiRequest(`/lessons/${lessonId}/progress`, { method: 'PUT', body: JSON.stringify({ watchedSeconds }) });
};
export const incrementViews = async (_courseId: string): Promise<void> => undefined; export const incrementViews = async (_courseId: string): Promise<void> => undefined;

View File

@@ -38,6 +38,8 @@ export interface Course {
instructorRole?: string; instructorRole?: string;
tips?: string[]; // Key takeaways / instructor tips tips?: string[]; // Key takeaways / instructor tips
attachments?: Attachment[]; // Downloadable materials across entire course attachments?: Attachment[]; // Downloadable materials across entire course
status?: 'draft' | 'published';
lastActivity?: string;
} }
export interface Section { export interface Section {