From 3d4c72e85cfee7c9ee7f977e1b00f5d643c10d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 1 Sep 2026 15:38:14 -0300 Subject: [PATCH] feat: improve learning and platform operations --- .env.example | 2 ++ App.tsx | 2 ++ BACKEND.md | 16 ++++++++++- PORTAINER.md | 1 + components/CoursePlayerModal.tsx | 43 ++++++++++++++++++++++------ components/Navbar.tsx | 4 +-- pages/ManageCourses.tsx | 44 ++++++++++++++++++----------- pages/MyLearning.tsx | 39 +++++++++++++++++++++++++ pages/SuperAdmin.tsx | 21 +++++++++++--- server/migrations/005_audit_log.sql | 13 +++++++++ server/src/audit.ts | 18 ++++++++++++ server/src/config.ts | 2 ++ server/src/routes/admin.ts | 16 +++++++++++ server/src/routes/auth.ts | 24 ++++++++++++++++ server/src/routes/courses.ts | 23 +++++++++++++++ server/src/routes/learning.ts | 23 +++++++++++---- server/src/routes/manage-courses.ts | 10 ++++++- services/api.ts | 2 ++ services/db.ts | 32 +++++++++++++++++---- types.ts | 2 ++ 20 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 pages/MyLearning.tsx create mode 100644 server/migrations/005_audit_log.sql create mode 100644 server/src/audit.ts diff --git a/.env.example b/.env.example index 7c48f9a..dcc344b 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/App.tsx b/App.tsx index 9ddb440..ca1df9b 100644 --- a/App.tsx +++ b/App.tsx @@ -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() { } /> + } /> } /> } /> diff --git a/BACKEND.md b/BACKEND.md index dd85bd5..4c75144 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -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 diff --git a/PORTAINER.md b/PORTAINER.md index 6736a2f..47c378b 100644 --- a/PORTAINER.md +++ b/PORTAINER.md @@ -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: diff --git a/components/CoursePlayerModal.tsx b/components/CoursePlayerModal.tsx index 6b53e48..f40eb89 100644 --- a/components/CoursePlayerModal.tsx +++ b/components/CoursePlayerModal.tsx @@ -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 = ({ course, on const [newComment, setNewComment] = useState(''); const [currentLesson, setCurrentLesson] = useState(null); const [completedLessonIds, setCompletedLessonIds] = useState([]); + const [watchedSecondsByLesson, setWatchedSecondsByLesson] = useState>({}); const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist'); const [copiedLink, setCopiedLink] = useState(false); const videoRef = useRef(null); + const lastProgressSave = useRef>({}); // 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 = ({ 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 = ({ 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 = ({ course, on autoPlay onTimeUpdate={handleTimeUpdate} onEnded={handleVideoEnded} + onLoadedMetadata={restoreWatchPosition} > Seu navegador não suporta a tag de vídeo. diff --git a/components/Navbar.tsx b/components/Navbar.tsx index bfaf398..11de325 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -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 = ({ onPlay }) => { ) : (
+ {user.role === 'student' && } {user.role === 'superadmin' ? (
+
+ + +
+
- {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')}
@@ -532,21 +544,18 @@ export const ManageCourses: React.FC = () => { const loadData = async () => { setLoading(true); - 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 }); - }); + try { + const data = await getManagedCourses(); + setCourses(data); + const analyticsResponse = await instructorApi.analytics(); + setAnalytics(analyticsResponse.data); + 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); } - setAllComments(commentsAccumulator); - setLoading(false); }; useEffect(() => { @@ -713,6 +722,7 @@ export const ManageCourses: React.FC = () => {
{course.category}
+ {course.status === 'draft' &&
Rascunho
} {/* Body */} diff --git a/pages/MyLearning.tsx b/pages/MyLearning.tsx new file mode 100644 index 0000000..f3a8df6 --- /dev/null +++ b/pages/MyLearning.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getMyLearningCourses().then(setCourses).catch(() => setCourses([])).finally(() => setLoading(false)); + }, []); + + return ( +
+
+
Minha área
+

Continue aprendendo

+

Retome suas aulas exatamente de onde parou.

+
+ {loading ?
: courses.length === 0 ? ( +
+ +

Você ainda não iniciou nenhum curso

+

Escolha uma aula na página de cursos para começar.

+
+ ) : ( +
+ {courses.map((course) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index c73cffa..f2c8190 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -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(null); const [loadingDetailId, setLoadingDetailId] = useState(null); + const [auditEntries, setAuditEntries] = useState([]); 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 = { '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 (
@@ -113,6 +120,7 @@ export const SuperAdmin: React.FC = () => { > Atualizar + @@ -226,6 +234,11 @@ export const SuperAdmin: React.FC = () => { )} + +
+

Registro de atividades

+ {auditEntries.length === 0 ?

Nenhuma atividade administrativa registrada ainda.

:
{auditEntries.map((entry) =>
{auditLabel[entry.action] || entry.action} · {entry.actorName}
)}
} +
); }; diff --git a/server/migrations/005_audit_log.sql b/server/migrations/005_audit_log.sql new file mode 100644 index 0000000..bd8d49c --- /dev/null +++ b/server/migrations/005_audit_log.sql @@ -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); diff --git a/server/src/audit.ts b/server/src/audit.ts new file mode 100644 index 0000000..0686bdc --- /dev/null +++ b/server/src/audit.ts @@ -0,0 +1,18 @@ +import { pool } from './db/pool.js'; + +type AuditInput = { + actorId: string; + action: string; + subjectType: string; + subjectId?: string; + metadata?: Record; + 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], + ); +}; diff --git a/server/src/config.ts b/server/src/config.ts index b1f7871..ac0bfa5 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 8acc883..49bc43a 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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 }; }); }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e8708ba..084dda7 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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(); + 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( `select id, email, display_name, role, password_hash, is_active from users where email = $1`, diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index ffee8fc..f9ffee4 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -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); diff --git a/server/src/routes/learning.ts b/server/src/routes/learning.ts index 47a9d7a..318d7ca 100644 --- a/server/src/routes/learning.ts +++ b/server/src/routes/learning.ts @@ -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(); }); }; diff --git a/server/src/routes/manage-courses.ts b/server/src/routes/manage-courses.ts index a138b07..349302e 100644 --- a/server/src/routes/manage-courses.ts +++ b/server/src/routes/manage-courses.ts @@ -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; +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(); }); }; diff --git a/services/api.ts b/services/api.ts index edbb5dc..5fbc2a1 100644 --- a/services/api.ts +++ b/services/api.ts @@ -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; 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 = { diff --git a/services/db.ts b/services/db.ts index 0473426..3dfd261 100644 --- a/services/db.ts +++ b/services/db.ts @@ -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 => { return response.data.map(toCourse); }; +export const getMyLearningCourses = async (): Promise => { + const response = await apiRequest<{ data: ApiCourse[] }>('/courses/me/learning'); + return response.data.map(toCourse); +}; + export const getCourseById = async (id: string): Promise => { try { const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`); @@ -192,15 +203,26 @@ export const moderateComment = async (commentId: string): Promise => { }; export const getCompletedLessonIds = async (courseId: string): Promise => { - 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 => { +export type LessonProgress = { lessonId: string; watchedSeconds: number; completedAt: string | null }; + +export const getLessonProgress = async (courseId: string): Promise => { + const response = await apiRequest<{ data: LessonProgress[] }>(`/courses/${courseId}/progress`); + return response.data; +}; + +export const saveLessonCompletion = async (lessonId: string, completed: boolean, watchedSeconds?: number): Promise => { 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 => { + await apiRequest(`/lessons/${lessonId}/progress`, { method: 'PUT', body: JSON.stringify({ watchedSeconds }) }); +}; + export const incrementViews = async (_courseId: string): Promise => undefined; diff --git a/types.ts b/types.ts index 7c8c8b8..5cabe87 100644 --- a/types.ts +++ b/types.ts @@ -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 {