feat: improve learning and platform operations
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
2
App.tsx
2
App.tsx
@@ -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" />} />
|
||||||
|
|
||||||
|
|||||||
16
BACKEND.md
16
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` 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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }) => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
39
pages/MyLearning.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
13
server/migrations/005_audit_log.sql
Normal file
13
server/migrations/005_audit_log.sql
Normal 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
18
server/src/audit.ts
Normal 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],
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
2
types.ts
2
types.ts
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user