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.
SUPERADMIN_EMAIL=admin@example.com
SUPERADMIN_PASSWORD=change-this-password
AUTH_RATE_LIMIT_MAX=10
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
SUPERADMIN_NAME=Compor HUB Superadmin
# 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 { SuperAdmin } from './pages/SuperAdmin';
import { AccessTokenPage } from './pages/AccessTokenPage';
import { MyLearning } from './pages/MyLearning';
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
@@ -91,6 +92,7 @@ function AppContent() {
</ProtectedRoute>
}
/>
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
<Route path="/invite" element={<AccessTokenPage mode="invite" />} />
<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/: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
- `POST /api/v1/auth/register` creates student accounts only.
- `POST /api/v1/auth/login` creates a seven-day signed 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.
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.
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

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`.
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
- `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:

View File

@@ -19,7 +19,7 @@ import {
} from 'lucide-react';
import { Course, Comment, Lesson, Attachment } from '../types';
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 { MaterialCard } from './MaterialCard';
@@ -34,21 +34,30 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const [newComment, setNewComment] = useState('');
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]);
const [watchedSecondsByLesson, setWatchedSecondsByLesson] = useState<Record<string, number>>({});
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
const [copiedLink, setCopiedLink] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const lastProgressSave = useRef<Record<string, number>>({});
// Load course data and this learner's saved state from PostgreSQL.
useEffect(() => {
if (course) {
incrementViews(course.id);
setCompletedLessonIds([]);
setWatchedSecondsByLesson({});
lastProgressSave.current = {};
if (user) {
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);
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([]));
} else {
setComments([]);
@@ -78,17 +87,21 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
updateCompletedLessons(updated);
saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds));
} else {
const updated = [...completedLessonIds, lessonId];
updateCompletedLessons(updated);
saveLessonCompletion(lessonId, true).catch(() => updateCompletedLessons(completedLessonIds));
markLessonCompleted(lessonId);
}
};
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
const handleVideoEnded = () => {
if (currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id];
updateCompletedLessons(updated);
markLessonCompleted(currentLesson.id, Math.round(videoRef.current?.duration || 0));
}
// Auto-advance to next lesson if available
@@ -110,16 +123,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
if (videoRef.current) {
const { currentTime, duration } = videoRef.current;
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);
// Automatically mark as complete when reaching 95%+
if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id];
updateCompletedLessons(updated);
markLessonCompleted(currentLesson.id, Math.round(currentTime));
}
}
}
};
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) => {
e.preventDefault();
if (!newComment.trim() || !course) return;
@@ -224,6 +248,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
>
Seu navegador não suporta a tag de vídeo.
</video>

View File

@@ -1,5 +1,5 @@
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 { LoginModal } from './LoginModal';
import { SearchModal } from './SearchModal';
@@ -100,6 +100,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</button>
) : (
<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' ? (
<button
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 [description, setDescription] = useState('');
const [category, setCategory] = useState('');
const [status, setStatus] = useState<Course['status']>('draft');
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const [instructor, setInstructor] = useState('Mário Morgado');
const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
@@ -76,6 +77,7 @@ const CourseModal: React.FC<{
setTitle(initialData.title);
setDescription(initialData.description);
setCategory(initialData.category);
setStatus(initialData.status || 'published');
setThumbnailPreview(initialData.thumbnail);
setInstructor(initialData.instructor || 'Mário Morgado');
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
@@ -86,6 +88,7 @@ const CourseModal: React.FC<{
setTitle('');
setDescription('');
setCategory(CATEGORIES[0]);
setStatus('draft');
setThumbnailPreview('');
setInstructor('Mário Morgado');
setInstructorRole('Especialista em Performance');
@@ -187,6 +190,7 @@ const CourseModal: React.FC<{
title,
description,
category: category || CATEGORIES[0],
status,
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
duration: durationStr,
instructor,
@@ -198,8 +202,8 @@ const CourseModal: React.FC<{
attachments: attachments
});
onClose();
} catch {
alert('We could not save this course. Confirm that you are still signed in and that all asset URLs are valid.');
} catch (error) {
alert(error instanceof Error ? error.message : 'Não foi possível salvar este curso.');
} finally {
setIsSubmitting(false);
}
@@ -275,6 +279,14 @@ const CourseModal: React.FC<{
</select>
</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>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
<input
@@ -507,7 +519,7 @@ const CourseModal: React.FC<{
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"
>
{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>
</div>
</div>
@@ -532,21 +544,18 @@ export const ManageCourses: React.FC = () => {
const loadData = async () => {
setLoading(true);
try {
const data = await getManagedCourses();
setCourses(data);
const analyticsResponse = await instructorApi.analytics();
setAnalytics(analyticsResponse.data);
// Load all comments
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
for (const c of data) {
const courseComments = await getComments(c.id);
courseComments.forEach(comm => {
commentsAccumulator.push({ comment: comm, courseTitle: c.title });
});
}
setAllComments(commentsAccumulator);
const commentLists = await Promise.all(data.map(async (course) => ({ course, comments: await getComments(course.id) })));
setAllComments(commentLists.flatMap(({ course, comments }) => comments.map((comment) => ({ comment, courseTitle: course.title }))));
} catch {
showToast('Não foi possível carregar o painel do instrutor.', 'error');
} finally {
setLoading(false);
}
};
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">
{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>}
</div>
{/* 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 { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
import { adminApi, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api';
import { Download, Loader2, RefreshCw, ScrollText, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
import { adminApi, AuditEntry, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../context/ToastContext';
import { useNavigate } from 'react-router-dom';
@@ -25,14 +25,15 @@ export const SuperAdmin: React.FC = () => {
const [accessLink, setAccessLink] = useState('');
const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null);
const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null);
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
const loadUsers = async () => {
setIsLoading(true);
try {
const response = await adminApi.listUsers();
const [response, dashboard, auditLog] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog()]);
setUsers(response.data);
const dashboard = await adminApi.dashboard();
setAnalytics(dashboard.data);
setAuditEntries(auditLog.data);
} catch {
showToast('Não foi possível carregar os usuários.', 'error');
} finally {
@@ -89,6 +90,12 @@ export const SuperAdmin: React.FC = () => {
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 (
<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
</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>
@@ -226,6 +234,11 @@ export const SuperAdmin: React.FC = () => {
</div>
)}
</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>
);
};

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_PASSWORD: optionalEnvironmentValue(z.string().min(12)),
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);

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { pool } from '../db/pool.js';
import { createRawToken, hashToken } from '../auth/account-tokens.js';
import { config } from '../config.js';
import { recordAudit } from '../audit.js';
const userParamsSchema = z.object({
userId: z.string().uuid(),
@@ -41,6 +42,18 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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) => {
const { userId } = userParamsSchema.parse(request.params);
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)`,
[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}` } });
});
@@ -76,6 +90,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
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],
);
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}` } };
});
@@ -97,6 +112,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
);
const account = result.rows[0];
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 };
});
};

View File

@@ -4,6 +4,7 @@ import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { hashToken } from '../auth/account-tokens.js';
import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js';
import { config } from '../config.js';
const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()),
@@ -32,7 +33,27 @@ const serializeUser = (user: UserRow): AuthUser => ({
});
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) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
const client = await pool.connect();
try {
@@ -63,6 +84,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
});
app.post('/reset-password', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.parse(request.body);
const client = await pool.connect();
try {
@@ -87,6 +109,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
});
app.post('/register', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password);
@@ -109,6 +132,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
});
app.post('/login', async (request, reply) => {
if (rejectIfRateLimited(request.ip, reply)) return;
const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>(
`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) };
});
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) => {
const authenticated = await hasSession(request);
const { courseId } = paramsSchema.parse(request.params);

View File

@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js';
const courseParamsSchema = z.object({
courseId: z.string().uuid(),
@@ -11,9 +12,9 @@ const lessonParamsSchema = z.object({
});
const completionSchema = z.object({
completed: z.boolean(),
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' });
const commentSchema = z.object({
lessonId: z.string().uuid().nullable().optional(),
@@ -30,6 +31,12 @@ const ensurePublishedCourse = async (courseId: string) => {
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) => {
const canModerateComment = async (commentId: string, user: { id: string; role: string }) => {
const result = await pool.query<{ instructor_id: string }>(
@@ -76,20 +83,22 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const result = await pool.query(
`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
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",
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] };
});
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
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' });
}
@@ -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`,
[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 } };
});
@@ -153,6 +163,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const { commentId } = commentParamsSchema.parse(request.params);
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 recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
return reply.code(204).send();
});
};

View File

@@ -4,6 +4,7 @@ import type { PoolClient } from 'pg';
import { z } from 'zod';
import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js';
const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80),
@@ -46,6 +47,8 @@ const courseSchema = z.object({
const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>;
class CourseContentConflict extends Error {}
function slugify(value: string) {
return value
.normalize('NFD')
@@ -97,7 +100,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
[removedLessonIds],
);
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]);
}
@@ -193,9 +196,11 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
);
await replaceCourseContents(client, course.rows[0].id, input);
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 } });
} catch (error) {
await client.query('rollback');
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
throw error;
} finally {
client.release();
@@ -221,9 +226,11 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
);
await replaceCourseContents(client, courseId, input);
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 } };
} catch (error) {
await client.query('rollback');
if (error instanceof CourseContentConflict) return reply.code(409).send({ error: error.message });
throw error;
} finally {
client.release();
@@ -235,6 +242,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
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]);
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
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 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 {
token: string;
@@ -107,6 +108,7 @@ export const adminApi = {
return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' });
},
async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); },
async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); },
};
export const instructorApi = {

View File

@@ -17,6 +17,9 @@ type ApiCourse = {
description: string;
category: string;
coverImageUrl: string | null;
status: 'draft' | 'published' | 'archived';
progress?: number;
lastActivity?: string | null;
instructor: { name: string };
lessons: Array<{
id: string;
@@ -57,6 +60,9 @@ const toCourse = (course: ApiCourse): Course => ({
category: course.category,
thumbnail: course.coverImageUrl || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
instructor: course.instructor.name,
status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress,
lastActivity: course.lastActivity || undefined,
duration: `${course.lessons.length} Aulas`,
lessons: course.lessons.map((lesson): Lesson => ({
id: lesson.id,
@@ -98,7 +104,7 @@ const toCoursePayload = (course: Course) => ({
description: course.description,
category: course.category,
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),
lessons: course.lessons.map((lesson) => ({
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);
};
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> => {
try {
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[]> => {
const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`);
return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId);
const progress = await getLessonProgress(courseId);
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`, {
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;

View File

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