feat: complete platform operations roadmap
This commit is contained in:
@@ -16,6 +16,10 @@ SUPERADMIN_EMAIL=admin@example.com
|
|||||||
SUPERADMIN_PASSWORD=change-this-password
|
SUPERADMIN_PASSWORD=change-this-password
|
||||||
AUTH_RATE_LIMIT_MAX=10
|
AUTH_RATE_LIMIT_MAX=10
|
||||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
|
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
|
||||||
|
JWT_SESSION_TTL=7d
|
||||||
|
INVITATION_TTL_HOURS=168
|
||||||
|
PASSWORD_RESET_TTL_HOURS=24
|
||||||
|
AUDIT_LOG_PAGE_SIZE=50
|
||||||
SUPERADMIN_NAME=Compor HUB Superadmin
|
SUPERADMIN_NAME=Compor HUB Superadmin
|
||||||
|
|
||||||
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
|
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
|
||||||
@@ -23,6 +27,9 @@ SUPERADMIN_NAME=Compor HUB Superadmin
|
|||||||
BUNNY_STREAM_LIBRARY_ID=
|
BUNNY_STREAM_LIBRARY_ID=
|
||||||
BUNNY_STREAM_API_KEY=
|
BUNNY_STREAM_API_KEY=
|
||||||
BUNNY_EMBED_TOKEN_KEY=
|
BUNNY_EMBED_TOKEN_KEY=
|
||||||
|
# Bunny Read-Only API key. Required only if Bunny webhooks are enabled.
|
||||||
|
BUNNY_WEBHOOK_SECRET=
|
||||||
|
BUNNY_EMBED_TOKEN_TTL_SECONDS=600
|
||||||
# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf.
|
# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf.
|
||||||
BUNNY_MAX_UPLOAD_MB=5120
|
BUNNY_MAX_UPLOAD_MB=5120
|
||||||
|
|
||||||
|
|||||||
20
PORTAINER.md
20
PORTAINER.md
@@ -10,6 +10,8 @@ To let instructors upload protected course videos, configure these API environme
|
|||||||
BUNNY_STREAM_LIBRARY_ID=123456
|
BUNNY_STREAM_LIBRARY_ID=123456
|
||||||
BUNNY_STREAM_API_KEY=your-bunny-library-api-key
|
BUNNY_STREAM_API_KEY=your-bunny-library-api-key
|
||||||
BUNNY_EMBED_TOKEN_KEY=your-bunny-embed-view-token-key
|
BUNNY_EMBED_TOKEN_KEY=your-bunny-embed-view-token-key
|
||||||
|
BUNNY_WEBHOOK_SECRET=your-bunny-read-only-api-key
|
||||||
|
BUNNY_EMBED_TOKEN_TTL_SECONDS=600
|
||||||
BUNNY_MAX_UPLOAD_MB=5120
|
BUNNY_MAX_UPLOAD_MB=5120
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,6 +28,16 @@ After deploying the new images:
|
|||||||
|
|
||||||
The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL.
|
The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL.
|
||||||
|
|
||||||
|
### Bunny processing webhooks
|
||||||
|
|
||||||
|
The editor can poll Bunny while a video encodes, but production should also configure Bunny's webhook so the Academy records the result even when no instructor page is open. In the library webhook settings, use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://YOUR-DOMAIN/api/v1/webhooks/bunny
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `BUNNY_WEBHOOK_SECRET` to Bunny's **Read-Only API key**. The Academy checks Bunny's HMAC signature against the unmodified request body and rejects unsigned requests. Do not use the normal library API key for this setting.
|
||||||
|
|
||||||
This is a Docker Swarm stack: Portainer pulls prebuilt API and web images from the Gitea Container Registry. It never builds Dockerfiles itself.
|
This is a Docker Swarm stack: Portainer pulls prebuilt API and web images from the Gitea Container Registry. It never builds Dockerfiles itself.
|
||||||
|
|
||||||
## Gitea Actions registry secrets
|
## Gitea Actions registry secrets
|
||||||
@@ -45,6 +57,7 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho
|
|||||||
- `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).
|
- `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).
|
||||||
|
- `JWT_SESSION_TTL`, `INVITATION_TTL_HOURS`, `PASSWORD_RESET_TTL_HOURS`, `BUNNY_EMBED_TOKEN_TTL_SECONDS`, and `AUDIT_LOG_PAGE_SIZE` tune operating policy without changing code.
|
||||||
|
|
||||||
Optional variables:
|
Optional variables:
|
||||||
|
|
||||||
@@ -63,4 +76,11 @@ Optional variables:
|
|||||||
5. Place the web service behind HTTPS, normally through your existing reverse proxy (Traefik, Nginx Proxy Manager, or Cloudflare Tunnel), and set `FRONTEND_ORIGIN` to that HTTPS address.
|
5. Place the web service behind HTTPS, normally through your existing reverse proxy (Traefik, Nginx Proxy Manager, or Cloudflare Tunnel), and set `FRONTEND_ORIGIN` to that HTTPS address.
|
||||||
6. Back up the `compor_postgres_data` volume before updates.
|
6. Back up the `compor_postgres_data` volume before updates.
|
||||||
|
|
||||||
|
## Reliability checklist
|
||||||
|
|
||||||
|
- Keep the API at one replica until PostgreSQL capacity and upload traffic justify scaling. Auth throttling is database-backed, so it will remain consistent if you later add replicas.
|
||||||
|
- Point an external monitor at `https://YOUR-DOMAIN/api/v1/ready`; alert when it returns anything other than HTTP 200.
|
||||||
|
- Test a PostgreSQL backup restoration into a separate temporary database at least once per quarter. A backup is only proven when it restores.
|
||||||
|
- Create a separate Portainer stack and database for staging. Use a different `FRONTEND_ORIGIN`, `JWT_SECRET`, Bunny library, and `WEB_PORT`; never point staging at production PostgreSQL or video credentials.
|
||||||
|
|
||||||
Do not expose port 5432 or port 3001 publicly.
|
Do not expose port 5432 or port 3001 publicly.
|
||||||
|
|||||||
@@ -24,6 +24,32 @@ import { LoginModal } from './LoginModal';
|
|||||||
import { MaterialCard } from './MaterialCard';
|
import { MaterialCard } from './MaterialCard';
|
||||||
import { courseApi, LessonPlayback } from '../services/api';
|
import { courseApi, LessonPlayback } from '../services/api';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
playerjs?: {
|
||||||
|
Player: new (element: HTMLIFrameElement) => {
|
||||||
|
on: (event: string, listener: (data?: { seconds?: number; duration?: number }) => void) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bunnyPlayerJsPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const loadBunnyPlayerJs = () => {
|
||||||
|
if (window.playerjs) return Promise.resolve();
|
||||||
|
if (bunnyPlayerJsPromise) return bunnyPlayerJsPromise;
|
||||||
|
bunnyPlayerJsPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://assets.mediadelivery.net/playerjs/player-0.1.0.min.js';
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error('Bunny player events could not be loaded'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return bunnyPlayerJsPromise;
|
||||||
|
};
|
||||||
|
|
||||||
interface CoursePlayerModalProps {
|
interface CoursePlayerModalProps {
|
||||||
course: Course | null;
|
course: Course | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -43,6 +69,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
const [playbackError, setPlaybackError] = useState('');
|
const [playbackError, setPlaybackError] = useState('');
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const bunnyFrameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const lastProgressSave = useRef<Record<string, number>>({});
|
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.
|
||||||
@@ -162,6 +189,31 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bunny's iframe does not expose an HTMLVideoElement. Player.js receives
|
||||||
|
// Bunny's trusted playback events and sends the same progress updates used
|
||||||
|
// for external video providers.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playback || playback.kind !== 'embed' || !currentLesson || !user) return;
|
||||||
|
let disposed = false;
|
||||||
|
const lessonId = currentLesson.id;
|
||||||
|
void loadBunnyPlayerJs().then(() => {
|
||||||
|
if (disposed || !bunnyFrameRef.current || !window.playerjs) return;
|
||||||
|
const player = new window.playerjs.Player(bunnyFrameRef.current);
|
||||||
|
player.on('timeupdate', (data) => {
|
||||||
|
const watchedSeconds = Math.floor(data?.seconds || 0);
|
||||||
|
if (disposed || watchedSeconds <= 0 || watchedSeconds - (lastProgressSave.current[lessonId] || 0) < 30) return;
|
||||||
|
lastProgressSave.current[lessonId] = watchedSeconds;
|
||||||
|
setWatchedSecondsByLesson((current) => ({ ...current, [lessonId]: watchedSeconds }));
|
||||||
|
saveLessonProgress(lessonId, watchedSeconds).catch(() => undefined);
|
||||||
|
});
|
||||||
|
player.on('ended', (data) => {
|
||||||
|
if (disposed) return;
|
||||||
|
markLessonCompleted(lessonId, Math.floor(data?.duration || data?.seconds || 0));
|
||||||
|
});
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return () => { disposed = true; };
|
||||||
|
}, [playback, currentLesson, user, completedLessonIds]);
|
||||||
|
|
||||||
const restoreWatchPosition = () => {
|
const restoreWatchPosition = () => {
|
||||||
if (!currentLesson || !videoRef.current) return;
|
if (!currentLesson || !videoRef.current) return;
|
||||||
const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0;
|
const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0;
|
||||||
@@ -267,6 +319,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
|
|||||||
playback.kind === 'embed' && playback.embedUrl ? (
|
playback.kind === 'embed' && playback.embedUrl ? (
|
||||||
<iframe
|
<iframe
|
||||||
key={currentLesson?.id}
|
key={currentLesson?.id}
|
||||||
|
ref={bunnyFrameRef}
|
||||||
src={playback.embedUrl}
|
src={playback.embedUrl}
|
||||||
title={currentLesson?.title || 'Vídeo da aula'}
|
title={currentLesson?.title || 'Vídeo da aula'}
|
||||||
className="w-full h-full border-0 rounded-[8px]"
|
className="w-full h-full border-0 rounded-[8px]"
|
||||||
|
|||||||
@@ -28,9 +28,17 @@ services:
|
|||||||
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
|
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
|
||||||
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD:-}
|
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD:-}
|
||||||
SUPERADMIN_NAME: ${SUPERADMIN_NAME:-Compor HUB Superadmin}
|
SUPERADMIN_NAME: ${SUPERADMIN_NAME:-Compor HUB Superadmin}
|
||||||
|
AUTH_RATE_LIMIT_MAX: ${AUTH_RATE_LIMIT_MAX:-10}
|
||||||
|
AUTH_RATE_LIMIT_WINDOW_SECONDS: ${AUTH_RATE_LIMIT_WINDOW_SECONDS:-900}
|
||||||
|
JWT_SESSION_TTL: ${JWT_SESSION_TTL:-7d}
|
||||||
|
INVITATION_TTL_HOURS: ${INVITATION_TTL_HOURS:-168}
|
||||||
|
PASSWORD_RESET_TTL_HOURS: ${PASSWORD_RESET_TTL_HOURS:-24}
|
||||||
|
AUDIT_LOG_PAGE_SIZE: ${AUDIT_LOG_PAGE_SIZE:-50}
|
||||||
BUNNY_STREAM_LIBRARY_ID: ${BUNNY_STREAM_LIBRARY_ID:-}
|
BUNNY_STREAM_LIBRARY_ID: ${BUNNY_STREAM_LIBRARY_ID:-}
|
||||||
BUNNY_STREAM_API_KEY: ${BUNNY_STREAM_API_KEY:-}
|
BUNNY_STREAM_API_KEY: ${BUNNY_STREAM_API_KEY:-}
|
||||||
BUNNY_EMBED_TOKEN_KEY: ${BUNNY_EMBED_TOKEN_KEY:-}
|
BUNNY_EMBED_TOKEN_KEY: ${BUNNY_EMBED_TOKEN_KEY:-}
|
||||||
|
BUNNY_WEBHOOK_SECRET: ${BUNNY_WEBHOOK_SECRET:-}
|
||||||
|
BUNNY_EMBED_TOKEN_TTL_SECONDS: ${BUNNY_EMBED_TOKEN_TTL_SECONDS:-600}
|
||||||
BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120}
|
BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ import {
|
|||||||
Unlock,
|
Unlock,
|
||||||
Globe,
|
Globe,
|
||||||
Download
|
Download
|
||||||
|
,Copy, ChevronUp, ChevronDown
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||||
import { CATEGORIES } from '../constants';
|
|
||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
import { BunnyVideo, instructorApi, InstructorAnalytics } from '../services/api';
|
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
||||||
|
|
||||||
// 1. Create/Edit Course Modal
|
// 1. Create/Edit Course Modal
|
||||||
const CourseModal: React.FC<{
|
const CourseModal: React.FC<{
|
||||||
@@ -44,8 +44,7 @@ const CourseModal: React.FC<{
|
|||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [status, setStatus] = useState<Course['status']>('draft');
|
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 [categories, setCategories] = useState<string[]>([]);
|
||||||
const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
|
|
||||||
|
|
||||||
// Lesson Management
|
// Lesson Management
|
||||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||||
@@ -83,19 +82,15 @@ const CourseModal: React.FC<{
|
|||||||
setCategory(initialData.category);
|
setCategory(initialData.category);
|
||||||
setStatus(initialData.status || 'published');
|
setStatus(initialData.status || 'published');
|
||||||
setThumbnailPreview(initialData.thumbnail);
|
setThumbnailPreview(initialData.thumbnail);
|
||||||
setInstructor(initialData.instructor || 'Mário Morgado');
|
|
||||||
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
|
|
||||||
setTips(initialData.tips || []);
|
setTips(initialData.tips || []);
|
||||||
setAttachments(initialData.attachments || []);
|
setAttachments(initialData.attachments || []);
|
||||||
setLessons(initialData.lessons || []);
|
setLessons(initialData.lessons || []);
|
||||||
} else {
|
} else {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setCategory(CATEGORIES[0]);
|
setCategory('');
|
||||||
setStatus('draft');
|
setStatus('draft');
|
||||||
setThumbnailPreview('');
|
setThumbnailPreview('');
|
||||||
setInstructor('Mário Morgado');
|
|
||||||
setInstructorRole('Especialista em Performance');
|
|
||||||
setTips([]);
|
setTips([]);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setLessons([]);
|
setLessons([]);
|
||||||
@@ -106,6 +101,12 @@ const CourseModal: React.FC<{
|
|||||||
instructorApi.bunnyConfiguration()
|
instructorApi.bunnyConfiguration()
|
||||||
.then((response) => setBunnyConfigured(response.data.configured))
|
.then((response) => setBunnyConfigured(response.data.configured))
|
||||||
.catch(() => setBunnyConfigured(false));
|
.catch(() => setBunnyConfigured(false));
|
||||||
|
courseApi.categories()
|
||||||
|
.then((response) => {
|
||||||
|
setCategories(response.data);
|
||||||
|
if (!initialData) setCategory((current) => current || response.data[0] || '');
|
||||||
|
})
|
||||||
|
.catch(() => setCategories([]));
|
||||||
}
|
}
|
||||||
}, [isOpen, initialData]);
|
}, [isOpen, initialData]);
|
||||||
|
|
||||||
@@ -242,6 +243,17 @@ const CourseModal: React.FC<{
|
|||||||
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
|
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const moveLesson = (id: string, direction: -1 | 1) => {
|
||||||
|
setLessons((current) => {
|
||||||
|
const index = current.findIndex((lesson) => lesson.id === id);
|
||||||
|
const nextIndex = index + direction;
|
||||||
|
if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current;
|
||||||
|
const next = [...current];
|
||||||
|
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddTip = () => {
|
const handleAddTip = () => {
|
||||||
if (newTip.trim()) {
|
if (newTip.trim()) {
|
||||||
setTips([...tips, newTip.trim()]);
|
setTips([...tips, newTip.trim()]);
|
||||||
@@ -295,12 +307,11 @@ const CourseModal: React.FC<{
|
|||||||
id: initialData?.id || `course-${Date.now()}`,
|
id: initialData?.id || `course-${Date.now()}`,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
category: category || CATEGORIES[0],
|
category: category || categories[0],
|
||||||
status,
|
status,
|
||||||
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
thumbnail: thumbnailPreview || '/course-placeholder.svg',
|
||||||
duration: durationStr,
|
duration: durationStr,
|
||||||
instructor,
|
instructor: initialData?.instructor || '',
|
||||||
instructorRole,
|
|
||||||
views: initialData?.views || 0,
|
views: initialData?.views || 0,
|
||||||
progress: initialData?.progress || 0,
|
progress: initialData?.progress || 0,
|
||||||
lessons: lessons,
|
lessons: lessons,
|
||||||
@@ -379,7 +390,7 @@ const CourseModal: React.FC<{
|
|||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{CATEGORIES.map(cat => (
|
{categories.map(cat => (
|
||||||
<option key={cat} value={cat} className="bg-zinc-900">{cat}</option>
|
<option key={cat} value={cat} className="bg-zinc-900">{cat}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -393,16 +404,6 @@ const CourseModal: React.FC<{
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={instructor}
|
|
||||||
onChange={(e) => setInstructor(e.target.value)}
|
|
||||||
placeholder="Nome do Instrutor"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -551,6 +552,8 @@ const CourseModal: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<button type="button" disabled={index === 0} onClick={() => moveLesson(lesson.id, -1)} className="p-1 text-gray-400 hover:text-white disabled:opacity-30" title="Mover aula para cima"><ChevronUp className="w-4 h-4" /></button>
|
||||||
|
<button type="button" disabled={index === lessons.length - 1} onClick={() => moveLesson(lesson.id, 1)} className="p-1 text-gray-400 hover:text-white disabled:opacity-30" title="Mover aula para baixo"><ChevronDown className="w-4 h-4" /></button>
|
||||||
{lesson.media?.[0]?.provider === 'bunny' && lesson.media[0].status !== 'ready' && (
|
{lesson.media?.[0]?.provider === 'bunny' && lesson.media[0].status !== 'ready' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -728,6 +731,16 @@ export const ManageCourses: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await duplicateCourse(id);
|
||||||
|
showToast('Cópia criada como rascunho.', 'success');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (course: Course) => {
|
const handleSave = async (course: Course) => {
|
||||||
await saveCourse(course);
|
await saveCourse(course);
|
||||||
showToast('Curso e materiais salvos com sucesso!', 'success');
|
showToast('Curso e materiais salvos com sucesso!', 'success');
|
||||||
@@ -906,6 +919,8 @@ export const ManageCourses: React.FC = () => {
|
|||||||
Editar Conteúdo
|
Editar Conteúdo
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => void handleDuplicate(course.id)} className="p-2.5 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors" title="Duplicar curso"><Copy className="w-4 h-4" /></button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(course.id)}
|
onClick={() => handleDelete(course.id)}
|
||||||
className="p-2.5 rounded-[980px] bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
|
className="p-2.5 rounded-[980px] bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Download, Loader2, RefreshCw, ScrollText, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCw, ScrollText, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
|
||||||
import { adminApi, AuditEntry, 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';
|
||||||
@@ -26,14 +26,20 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
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 [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [auditQuery, setAuditQuery] = useState('');
|
||||||
|
const [categories, setCategories] = useState<Array<{ id: string; name: string; position: number; isActive: boolean }>>([]);
|
||||||
|
const [newCategory, setNewCategory] = useState('');
|
||||||
|
const [homeConfiguration, setHomeConfiguration] = useState<{ featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } | null>(null);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [response, dashboard, auditLog] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog()]);
|
const [response, dashboard, auditLog, categoryResponse, homeResponse] = await Promise.all([adminApi.listUsers(), adminApi.dashboard(), adminApi.auditLog(), adminApi.categories(), adminApi.homeConfiguration()]);
|
||||||
setUsers(response.data);
|
setUsers(response.data);
|
||||||
setAnalytics(dashboard.data);
|
setAnalytics(dashboard.data);
|
||||||
setAuditEntries(auditLog.data);
|
setAuditEntries(auditLog.data);
|
||||||
|
setCategories(categoryResponse.data);
|
||||||
|
setHomeConfiguration(homeResponse.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 {
|
||||||
@@ -52,6 +58,16 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized),
|
account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized),
|
||||||
);
|
);
|
||||||
}, [query, users]);
|
}, [query, users]);
|
||||||
|
const filteredAuditEntries = useMemo(() => {
|
||||||
|
const normalized = auditQuery.trim().toLowerCase();
|
||||||
|
if (!normalized) return auditEntries;
|
||||||
|
return auditEntries.filter((entry) => `${entry.action} ${entry.actorName} ${entry.subjectType}`.toLowerCase().includes(normalized));
|
||||||
|
}, [auditEntries, auditQuery]);
|
||||||
|
const homeCourses = useMemo(() => {
|
||||||
|
if (!homeConfiguration) return [];
|
||||||
|
const order = new Map<string, number>(homeConfiguration.courseOrder.map((id, index) => [id, index]));
|
||||||
|
return homeConfiguration.courses.filter((course) => course.status === 'published').sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title));
|
||||||
|
}, [homeConfiguration]);
|
||||||
|
|
||||||
const updateUser = async (account: ManagedUser, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) => {
|
const updateUser = async (account: ManagedUser, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) => {
|
||||||
setUpdatingUserId(account.id);
|
setUpdatingUserId(account.id);
|
||||||
@@ -79,6 +95,42 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
try { const response = await adminApi.passwordReset(account.id); setAccessLink(response.data.resetUrl); showToast('Link de redefinição criado.', 'success'); }
|
try { const response = await adminApi.passwordReset(account.id); setAccessLink(response.data.resetUrl); showToast('Link de redefinição criado.', 'success'); }
|
||||||
catch { showToast('Não foi possível criar o link.', 'error'); }
|
catch { showToast('Não foi possível criar o link.', 'error'); }
|
||||||
};
|
};
|
||||||
|
const createCategory = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!newCategory.trim()) return;
|
||||||
|
try {
|
||||||
|
const response = await adminApi.createCategory(newCategory.trim());
|
||||||
|
setCategories((current) => [...current, response.data].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name)));
|
||||||
|
setNewCategory('');
|
||||||
|
showToast('Categoria criada.', 'success');
|
||||||
|
} catch { showToast('Não foi possível criar esta categoria. Ela pode já existir.', 'error'); }
|
||||||
|
};
|
||||||
|
const toggleCategory = async (category: { id: string; name: string; position: number; isActive: boolean }) => {
|
||||||
|
try {
|
||||||
|
const response = await adminApi.updateCategory(category.id, { name: category.name, isActive: !category.isActive });
|
||||||
|
setCategories((current) => current.map((item) => item.id === category.id ? response.data : item));
|
||||||
|
} catch { showToast('Não foi possível atualizar a categoria.', 'error'); }
|
||||||
|
};
|
||||||
|
const saveHomeConfiguration = async () => {
|
||||||
|
if (!homeConfiguration) return;
|
||||||
|
try {
|
||||||
|
await adminApi.updateHomeConfiguration({ featuredCourseId: homeConfiguration.featuredCourseId, courseOrder: homeConfiguration.courseOrder, defaultCoverImageUrl: homeConfiguration.defaultCoverImageUrl || null });
|
||||||
|
showToast('Configuração da página inicial salva.', 'success');
|
||||||
|
} catch { showToast('Não foi possível salvar a configuração inicial.', 'error'); }
|
||||||
|
};
|
||||||
|
const moveHomeCourse = (courseId: string, direction: -1 | 1) => {
|
||||||
|
setHomeConfiguration((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const published = current.courses.filter((course) => course.status === 'published');
|
||||||
|
const order = new Map<string, number>(current.courseOrder.map((id, index) => [id, index]));
|
||||||
|
const arranged = [...published].sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title));
|
||||||
|
const index = arranged.findIndex((course) => course.id === courseId);
|
||||||
|
const target = index + direction;
|
||||||
|
if (index < 0 || target < 0 || target >= arranged.length) return current;
|
||||||
|
[arranged[index], arranged[target]] = [arranged[target], arranged[index]];
|
||||||
|
return { ...current, courseOrder: arranged.map((course) => course.id) };
|
||||||
|
});
|
||||||
|
};
|
||||||
const showUserDetail = async (account: ManagedUser) => {
|
const showUserDetail = async (account: ManagedUser) => {
|
||||||
setLoadingDetailId(account.id);
|
setLoadingDetailId(account.id);
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +147,11 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
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 link = document.createElement('a'); link.href = url; link.download = 'usuarios-compor-hub.csv'; link.click(); URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
const exportAudit = () => {
|
||||||
|
const csv = ['Data,Ação,Responsável,Tipo', ...filteredAuditEntries.map((entry) => [new Date(entry.createdAt).toISOString(), entry.action, entry.actorName, entry.subjectType].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 = 'registro-de-atividades-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' };
|
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 (
|
||||||
@@ -142,6 +199,33 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
{accessLink && <div className="mt-4 flex gap-2"><input readOnly value={accessLink} className="flex-1 rounded-xl bg-black border border-white/10 px-3 py-2 text-xs text-gray-300" /><button onClick={() => navigator.clipboard.writeText(accessLink)} className="rounded-xl bg-white/10 px-4 text-xs font-semibold">Copiar</button></div>}
|
{accessLink && <div className="mt-4 flex gap-2"><input readOnly value={accessLink} className="flex-1 rounded-xl bg-black border border-white/10 px-3 py-2 text-xs text-gray-300" /><button onClick={() => navigator.clipboard.writeText(accessLink)} className="rounded-xl bg-white/10 px-4 text-xs font-semibold">Copiar</button></div>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-1">Categorias de cursos</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Estas opções aparecem no editor de cursos e na biblioteca.</p>
|
||||||
|
<form onSubmit={createCategory} className="flex gap-2 mb-4">
|
||||||
|
<input value={newCategory} onChange={(event) => setNewCategory(event.target.value)} placeholder="Nova categoria" className="min-w-0 flex-1 rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-sm text-white" />
|
||||||
|
<button className="rounded-xl bg-orange-500 hover:bg-orange-600 px-4 text-sm font-semibold text-white">Adicionar</button>
|
||||||
|
</form>
|
||||||
|
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||||
|
{categories.map((category) => <div key={category.id} className="flex items-center justify-between gap-3 rounded-xl bg-white/[0.03] px-3 py-2 text-sm"><span className={category.isActive ? 'text-gray-200' : 'text-gray-600 line-through'}>{category.name}</span><button onClick={() => void toggleCategory(category)} className={`rounded-full px-3 py-1 text-xs font-semibold ${category.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/10 text-gray-400'}`}>{category.isActive ? 'Ativa' : 'Inativa'}</button></div>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-1">Página inicial</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Defina o curso em destaque e a imagem padrão quando um curso não tem capa.</p>
|
||||||
|
{homeConfiguration && <div className="space-y-3">
|
||||||
|
<select value={homeConfiguration.featuredCourseId || ''} onChange={(event) => setHomeConfiguration((current) => current ? { ...current, featuredCourseId: event.target.value || null } : current)} className="w-full rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-sm text-white"><option value="">Nenhum curso em destaque</option>{homeConfiguration.courses.filter((course) => course.status === 'published').map((course) => <option key={course.id} value={course.id}>{course.title}</option>)}</select>
|
||||||
|
<input value={homeConfiguration.defaultCoverImageUrl || ''} onChange={(event) => setHomeConfiguration((current) => current ? { ...current, defaultCoverImageUrl: event.target.value || null } : current)} placeholder="URL da capa padrão (opcional)" className="w-full rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-sm text-white" />
|
||||||
|
<div className="max-h-32 space-y-1 overflow-y-auto rounded-xl bg-black/20 p-2">
|
||||||
|
{homeCourses.map((course, index) => <div key={course.id} className="flex items-center gap-2 text-xs text-gray-300"><span className="flex-1 truncate">{index + 1}. {course.title}</span><button onClick={() => moveHomeCourse(course.id, -1)} disabled={index === 0} className="p-1 disabled:opacity-30"><ChevronUp className="w-3.5 h-3.5" /></button><button onClick={() => moveHomeCourse(course.id, 1)} disabled={index === homeCourses.length - 1} className="p-1 disabled:opacity-30"><ChevronDown className="w-3.5 h-3.5" /></button></div>)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => void saveHomeConfiguration()} className="rounded-xl bg-white/10 hover:bg-white/15 px-4 py-2 text-sm font-semibold text-white">Salvar página inicial</button>
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedUser && (
|
{selectedUser && (
|
||||||
<section className="rounded-2xl border border-orange-500/30 bg-zinc-950/80 p-5 mb-8">
|
<section className="rounded-2xl border border-orange-500/30 bg-zinc-950/80 p-5 mb-8">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
@@ -236,8 +320,8 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden mt-8">
|
<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>
|
<div className="p-5 border-b border-white/10 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between"><div className="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><div className="flex gap-2"><input value={auditQuery} onChange={(event) => setAuditQuery(event.target.value)} placeholder="Filtrar registro" className="min-w-0 rounded-xl bg-zinc-900 border border-white/10 px-3 py-2 text-xs text-white" /><button onClick={exportAudit} className="rounded-xl bg-white/10 px-3 text-xs font-semibold text-white">CSV</button></div></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>}
|
{filteredAuditEntries.length === 0 ? <p className="px-5 py-10 text-sm text-gray-500">Nenhuma atividade administrativa encontrada.</p> : <div className="divide-y divide-white/5">{filteredAuditEntries.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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
8
public/course-placeholder.svg
Normal file
8
public/course-placeholder.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Compor HUB course cover placeholder</title>
|
||||||
|
<desc id="desc">Dark neutral course cover with an orange Compor HUB accent.</desc>
|
||||||
|
<defs><linearGradient id="bg" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#1d1d20"/><stop offset="1" stop-color="#09090b"/></linearGradient></defs>
|
||||||
|
<rect width="1600" height="900" fill="url(#bg)"/>
|
||||||
|
<circle cx="800" cy="450" r="180" fill="#ff6b18" opacity=".15"/><circle cx="800" cy="450" r="110" fill="none" stroke="#ff6b18" stroke-width="6" opacity=".7"/>
|
||||||
|
<text x="800" y="475" fill="#ffffff" font-family="Arial, sans-serif" font-size="58" font-weight="700" text-anchor="middle">compor <tspan fill="#ff6b18">HUB</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 798 B |
47
server/migrations/006_platform_content_and_security.sql
Normal file
47
server/migrations/006_platform_content_and_security.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
create table course_categories (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null unique,
|
||||||
|
position integer not null default 0,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint course_categories_name_check check (char_length(trim(name)) between 1 and 120)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into course_categories (name, position)
|
||||||
|
select category, row_number() over (order by category)
|
||||||
|
from (select distinct category from courses) categories
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
insert into course_categories (name, position)
|
||||||
|
values
|
||||||
|
('Tráfego Pago & Meta Ads', 10),
|
||||||
|
('Google Ads & YouTube', 20),
|
||||||
|
('Funis de Vendas & ROI', 30),
|
||||||
|
('Copywriting & Criativos', 40),
|
||||||
|
('Gestão & Processos de Agência', 50)
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
create table platform_settings (
|
||||||
|
key text primary key,
|
||||||
|
value jsonb not null,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
updated_by uuid references users(id) on delete set null
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into platform_settings (key, value)
|
||||||
|
values
|
||||||
|
('home.featuredCourseId', 'null'::jsonb),
|
||||||
|
('home.courseOrder', '[]'::jsonb),
|
||||||
|
('media.defaultCoverImageUrl', 'null'::jsonb)
|
||||||
|
on conflict (key) do nothing;
|
||||||
|
|
||||||
|
create table auth_rate_limits (
|
||||||
|
key text primary key,
|
||||||
|
window_started_at timestamptz not null,
|
||||||
|
attempts integer not null default 0 check (attempts >= 0),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index auth_rate_limits_updated_at_index on auth_rate_limits (updated_at);
|
||||||
|
create trigger course_categories_set_updated_at before update on course_categories for each row execute function set_updated_at();
|
||||||
@@ -10,10 +10,24 @@ import { manageCourseRoutes } from './routes/manage-courses.js';
|
|||||||
import { learningRoutes } from './routes/learning.js';
|
import { learningRoutes } from './routes/learning.js';
|
||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
import { mediaRoutes } from './routes/media.js';
|
import { mediaRoutes } from './routes/media.js';
|
||||||
|
import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js';
|
||||||
|
|
||||||
export function buildApp() {
|
export function buildApp() {
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
|
// Bunny signs the exact webhook byte sequence. Preserve the raw JSON body
|
||||||
|
// before parsing it so the signature can be verified server-side.
|
||||||
|
app.removeContentTypeParser('application/json');
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => {
|
||||||
|
const rawBody = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
||||||
|
(request as typeof request & { rawBody?: Buffer }).rawBody = rawBody;
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(rawBody.toString('utf8')));
|
||||||
|
} catch {
|
||||||
|
done(new Error('Invalid JSON body'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bunny uploads are streamed through the authenticated API. Keeping the body
|
// Bunny uploads are streamed through the authenticated API. Keeping the body
|
||||||
// as a stream avoids loading a whole course video into Node's memory.
|
// as a stream avoids loading a whole course video into Node's memory.
|
||||||
const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
|
const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
|
||||||
@@ -50,6 +64,7 @@ export function buildApp() {
|
|||||||
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
||||||
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
|
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
|
||||||
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });
|
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });
|
||||||
|
app.register(bunnyWebhookRoutes, { prefix: '/api/v1/webhooks' });
|
||||||
app.register(learningRoutes, { prefix: '/api/v1' });
|
app.register(learningRoutes, { prefix: '/api/v1' });
|
||||||
app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pool } from './db/pool.js';
|
import { pool } from './db/pool.js';
|
||||||
|
|
||||||
type AuditInput = {
|
type AuditInput = {
|
||||||
actorId: string;
|
actorId?: string;
|
||||||
action: string;
|
action: string;
|
||||||
subjectType: string;
|
subjectType: string;
|
||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
@@ -13,6 +13,6 @@ export const recordAudit = async (input: AuditInput) => {
|
|||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into audit_logs (actor_id, action, subject_type, subject_id, metadata, ip_address)
|
`insert into audit_logs (actor_id, action, subject_type, subject_id, metadata, ip_address)
|
||||||
values ($1, $2, $3, $4::uuid, $5::jsonb, $6::inet)`,
|
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],
|
[input.actorId ?? null, input.action, input.subjectType, input.subjectId ?? null, JSON.stringify(input.metadata ?? {}), input.ipAddress ?? null],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ const environmentSchema = z.object({
|
|||||||
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_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),
|
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
|
||||||
|
JWT_SESSION_TTL: z.string().regex(/^\d+[smhd]$/).default('7d'),
|
||||||
|
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
|
||||||
|
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
|
||||||
|
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
|
||||||
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
|
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
|
||||||
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
|
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
|
||||||
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
|
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
|
||||||
|
BUNNY_WEBHOOK_SECRET: optionalEnvironmentValue(z.string().min(20)),
|
||||||
|
BUNNY_EMBED_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86_400).default(600),
|
||||||
BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120),
|
BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const BUNNY_VIDEO_API = 'https://video.bunnycdn.com';
|
const BUNNY_VIDEO_API = 'https://video.bunnycdn.com';
|
||||||
@@ -41,7 +41,19 @@ export function isBunnyConfigured() {
|
|||||||
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_STREAM_API_KEY && config.BUNNY_EMBED_TOKEN_KEY);
|
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_STREAM_API_KEY && config.BUNNY_EMBED_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toMediaStatus(status: number): BunnyMediaStatus {
|
export function isBunnyWebhookConfigured() {
|
||||||
|
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_WEBHOOK_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyBunnyWebhookSignature(rawBody: Buffer, signature: string | undefined) {
|
||||||
|
if (!config.BUNNY_WEBHOOK_SECRET || !signature) return false;
|
||||||
|
const expected = createHmac('sha256', config.BUNNY_WEBHOOK_SECRET).update(rawBody).digest('hex');
|
||||||
|
const supplied = signature.trim().replace(/^sha256=/i, '');
|
||||||
|
if (supplied.length !== expected.length) return false;
|
||||||
|
return timingSafeEqual(Buffer.from(supplied, 'utf8'), Buffer.from(expected, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bunnyMediaStatus(status: number): BunnyMediaStatus {
|
||||||
if (status === 3 || status === 4) return 'ready';
|
if (status === 3 || status === 4) return 'ready';
|
||||||
if (status === 5 || status === 8) return 'failed';
|
if (status === 5 || status === 8) return 'failed';
|
||||||
return 'processing';
|
return 'processing';
|
||||||
@@ -51,7 +63,7 @@ function toVideo(video: BunnyApiVideo): BunnyVideo {
|
|||||||
return {
|
return {
|
||||||
id: video.guid,
|
id: video.guid,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
status: toMediaStatus(video.status),
|
status: bunnyMediaStatus(video.status),
|
||||||
providerStatus: video.status,
|
providerStatus: video.status,
|
||||||
encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))),
|
encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))),
|
||||||
durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.round(video.length) : null,
|
durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.round(video.length) : null,
|
||||||
@@ -102,7 +114,7 @@ export async function getBunnyVideo(videoId: string) {
|
|||||||
return toVideo(await response.json() as BunnyApiVideo);
|
return toVideo(await response.json() as BunnyApiVideo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = 10 * 60) {
|
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) {
|
||||||
const { libraryId, embedTokenKey } = getBunnyConfiguration();
|
const { libraryId, embedTokenKey } = getBunnyConfiguration();
|
||||||
const expires = Math.floor(Date.now() / 1000) + validitySeconds;
|
const expires = Math.floor(Date.now() / 1000) + validitySeconds;
|
||||||
const token = createHash('sha256').update(`${embedTokenKey}${videoId}${expires}`).digest('hex');
|
const token = createHash('sha256').update(`${embedTokenKey}${videoId}${expires}`).digest('hex');
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const updateUserSchema = z.object({
|
|||||||
message: 'Provide at least one field to update',
|
message: 'Provide at least one field to update',
|
||||||
});
|
});
|
||||||
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') });
|
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') });
|
||||||
|
const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() });
|
||||||
|
const categoryParamsSchema = z.object({ categoryId: z.string().uuid() });
|
||||||
|
const homeConfigurationSchema = z.object({
|
||||||
|
featuredCourseId: z.string().uuid().nullable(),
|
||||||
|
courseOrder: z.array(z.string().uuid()).max(500),
|
||||||
|
defaultCoverImageUrl: z.string().url().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
||||||
@@ -42,6 +49,88 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { data: result.rows[0] };
|
return { data: result.rows[0] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/categories', adminAccess, async () => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`select id, name, position, is_active as "isActive"
|
||||||
|
from course_categories order by position, name`,
|
||||||
|
);
|
||||||
|
return { data: result.rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/categories', adminAccess, async (request, reply) => {
|
||||||
|
const input = categorySchema.parse(request.body);
|
||||||
|
const result = await pool.query(
|
||||||
|
`insert into course_categories (name, position, is_active)
|
||||||
|
values ($1, coalesce($2, (select coalesce(max(position), 0) + 10 from course_categories)), coalesce($3, true))
|
||||||
|
returning id, name, position, is_active as "isActive"`,
|
||||||
|
[input.name, input.position ?? null, input.isActive ?? null],
|
||||||
|
);
|
||||||
|
await recordAudit({ actorId: request.user.id, action: 'category.created', subjectType: 'category', subjectId: result.rows[0].id, metadata: { name: input.name }, ipAddress: request.ip });
|
||||||
|
return reply.code(201).send({ data: result.rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/categories/:categoryId', adminAccess, async (request, reply) => {
|
||||||
|
const { categoryId } = categoryParamsSchema.parse(request.params);
|
||||||
|
const input = categorySchema.parse(request.body);
|
||||||
|
const result = await pool.query(
|
||||||
|
`update course_categories
|
||||||
|
set name = $2, position = coalesce($3, position), is_active = coalesce($4, is_active)
|
||||||
|
where id = $1
|
||||||
|
returning id, name, position, is_active as "isActive"`,
|
||||||
|
[categoryId, input.name, input.position ?? null, input.isActive ?? null],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return reply.code(404).send({ error: 'Category not found' });
|
||||||
|
await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip });
|
||||||
|
return { data: result.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/home-configuration', adminAccess, async () => {
|
||||||
|
const [settings, courses] = await Promise.all([
|
||||||
|
pool.query<{ key: string; value: unknown }>(`select key, value from platform_settings where key in ('home.featuredCourseId', 'home.courseOrder', 'media.defaultCoverImageUrl')`),
|
||||||
|
pool.query<{ id: string; title: string; status: string }>(`select id, title, status from courses where status <> 'archived' order by title`),
|
||||||
|
]);
|
||||||
|
const values = new Map(settings.rows.map((row) => [row.key, row.value]));
|
||||||
|
return { data: {
|
||||||
|
featuredCourseId: values.get('home.featuredCourseId') ?? null,
|
||||||
|
courseOrder: values.get('home.courseOrder') ?? [],
|
||||||
|
defaultCoverImageUrl: values.get('media.defaultCoverImageUrl') ?? null,
|
||||||
|
courses: courses.rows,
|
||||||
|
} };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/home-configuration', adminAccess, async (request, reply) => {
|
||||||
|
const input = homeConfigurationSchema.parse(request.body);
|
||||||
|
const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])];
|
||||||
|
if (ids.length) {
|
||||||
|
const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]);
|
||||||
|
if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Featured courses must exist and be published' });
|
||||||
|
}
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
'home.featuredCourseId': input.featuredCourseId,
|
||||||
|
'home.courseOrder': input.courseOrder,
|
||||||
|
'media.defaultCoverImageUrl': input.defaultCoverImageUrl,
|
||||||
|
})) {
|
||||||
|
await client.query(
|
||||||
|
`insert into platform_settings (key, value, updated_at, updated_by)
|
||||||
|
values ($1, $2::jsonb, now(), $3)
|
||||||
|
on conflict (key) do update set value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
||||||
|
[key, JSON.stringify(value), request.user.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('commit');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
await recordAudit({ actorId: request.user.id, action: 'home.configuration_updated', subjectType: 'platform', metadata: input, ipAddress: request.ip });
|
||||||
|
return { data: input };
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/audit-log', adminAccess, async () => {
|
app.get('/audit-log', adminAccess, async () => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`select a.id, a.action, a.subject_type as "subjectType", a.subject_id as "subjectId",
|
`select a.id, a.action, a.subject_type as "subjectType", a.subject_id as "subjectId",
|
||||||
@@ -49,7 +138,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
from audit_logs a
|
from audit_logs a
|
||||||
left join users u on u.id = a.actor_id
|
left join users u on u.id = a.actor_id
|
||||||
order by a.created_at desc
|
order by a.created_at desc
|
||||||
limit 50`,
|
limit $1`, [config.AUDIT_LOG_PAGE_SIZE],
|
||||||
);
|
);
|
||||||
return { data: result.rows };
|
return { data: result.rows };
|
||||||
});
|
});
|
||||||
@@ -73,8 +162,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const rawToken = createRawToken();
|
const rawToken = createRawToken();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||||
values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`,
|
values ($1, $2::user_role, 'invitation', $3, now() + ($4::int * interval '1 hour'), $5)`,
|
||||||
[input.email, input.role, hashToken(rawToken), request.user.id],
|
[input.email, input.role, hashToken(rawToken), config.INVITATION_TTL_HOURS, request.user.id],
|
||||||
);
|
);
|
||||||
await recordAudit({ actorId: request.user.id, action: 'invitation.created', subjectType: 'invitation', metadata: { email: input.email, role: input.role }, ipAddress: request.ip });
|
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}` } });
|
||||||
@@ -87,8 +176,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const rawToken = createRawToken();
|
const rawToken = createRawToken();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||||
values ($1, $2::user_role, 'password_reset', $3, now() + interval '1 day', $4)`,
|
values ($1, $2::user_role, 'password_reset', $3, now() + ($4::int * interval '1 hour'), $5)`,
|
||||||
[account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id],
|
[account.rows[0].email, account.rows[0].role, hashToken(rawToken), config.PASSWORD_RESET_TTL_HOURS, 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 });
|
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}` } };
|
||||||
|
|||||||
@@ -33,27 +33,35 @@ const serializeUser = (user: UserRow): AuthUser => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const publicAttempts = new Map<string, number[]>();
|
// This is stored in PostgreSQL rather than process memory so the limit keeps
|
||||||
const allowPublicAuthAttempt = (ip: string) => {
|
// working if the API is restarted or later scaled to more than one replica.
|
||||||
const now = Date.now();
|
const allowPublicAuthAttempt = async (ip: string) => {
|
||||||
const earliestAllowed = now - config.AUTH_RATE_LIMIT_WINDOW_SECONDS * 1000;
|
const result = await pool.query<{ attempts: number }>(
|
||||||
const attempts = (publicAttempts.get(ip) || []).filter((timestamp) => timestamp > earliestAllowed);
|
`insert into auth_rate_limits (key, window_started_at, attempts)
|
||||||
if (attempts.length >= config.AUTH_RATE_LIMIT_MAX) {
|
values ($1, now(), 1)
|
||||||
publicAttempts.set(ip, attempts);
|
on conflict (key) do update set
|
||||||
return false;
|
window_started_at = case
|
||||||
}
|
when auth_rate_limits.window_started_at <= now() - ($2::int * interval '1 second') then now()
|
||||||
attempts.push(now);
|
else auth_rate_limits.window_started_at
|
||||||
publicAttempts.set(ip, attempts);
|
end,
|
||||||
return true;
|
attempts = case
|
||||||
|
when auth_rate_limits.window_started_at <= now() - ($2::int * interval '1 second') then 1
|
||||||
|
else auth_rate_limits.attempts + 1
|
||||||
|
end,
|
||||||
|
updated_at = now()
|
||||||
|
returning attempts`,
|
||||||
|
[`public-auth:${ip}`, config.AUTH_RATE_LIMIT_WINDOW_SECONDS],
|
||||||
|
);
|
||||||
|
return result.rows[0].attempts <= config.AUTH_RATE_LIMIT_MAX;
|
||||||
};
|
};
|
||||||
const rejectIfRateLimited = (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
|
const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
|
||||||
if (allowPublicAuthAttempt(ip)) return false;
|
if (await allowPublicAuthAttempt(ip)) return false;
|
||||||
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
|
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.post('/accept-invitation', async (request, reply) => {
|
app.post('/accept-invitation', async (request, reply) => {
|
||||||
if (rejectIfRateLimited(request.ip, reply)) return;
|
if (await 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 {
|
||||||
@@ -74,7 +82,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
await client.query('commit');
|
await client.query('commit');
|
||||||
const user = serializeUser(result.rows[0]);
|
const user = serializeUser(result.rows[0]);
|
||||||
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user });
|
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
|
||||||
} catch {
|
} catch {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
return reply.code(409).send({ error: 'This invitation email already has an account' });
|
return reply.code(409).send({ error: 'This invitation email already has an account' });
|
||||||
@@ -84,7 +92,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;
|
if (await 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 {
|
||||||
@@ -109,7 +117,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;
|
if (await 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);
|
||||||
|
|
||||||
@@ -121,7 +129,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
[input.email, passwordHash, input.name],
|
[input.email, passwordHash, input.name],
|
||||||
);
|
);
|
||||||
const user = serializeUser(result.rows[0]);
|
const user = serializeUser(result.rows[0]);
|
||||||
const token = await reply.jwtSign(user, { expiresIn: '7d' });
|
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
|
||||||
return reply.code(201).send({ token, user });
|
return reply.code(201).send({ token, user });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
@@ -132,7 +140,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;
|
if (await 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`,
|
||||||
@@ -145,7 +153,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = serializeUser(account);
|
const user = serializeUser(account);
|
||||||
const token = await reply.jwtSign(user, { expiresIn: '7d' });
|
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
|
||||||
return { token, user };
|
return { token, user };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
40
server/src/routes/bunny-webhooks.ts
Normal file
40
server/src/routes/bunny-webhooks.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { recordAudit } from '../audit.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { pool } from '../db/pool.js';
|
||||||
|
import { bunnyMediaStatus, isBunnyWebhookConfigured, verifyBunnyWebhookSignature } from '../providers/bunny.js';
|
||||||
|
|
||||||
|
const webhookSchema = z.object({
|
||||||
|
VideoGuid: z.string().uuid(),
|
||||||
|
VideoLibraryId: z.coerce.number().int().positive(),
|
||||||
|
Status: z.coerce.number().int().nonnegative(),
|
||||||
|
Length: z.coerce.number().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.post('/bunny', async (request, reply) => {
|
||||||
|
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Bunny webhooks are not configured' });
|
||||||
|
const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody;
|
||||||
|
const signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
|
||||||
|
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
||||||
|
if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) {
|
||||||
|
return reply.code(401).send({ error: 'Invalid Bunny webhook signature' });
|
||||||
|
}
|
||||||
|
const input = webhookSchema.parse(request.body);
|
||||||
|
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
|
||||||
|
return reply.code(400).send({ error: 'Unexpected Bunny video library' });
|
||||||
|
}
|
||||||
|
const status = bunnyMediaStatus(input.Status);
|
||||||
|
await pool.query(
|
||||||
|
`update lesson_media
|
||||||
|
set status = $2::media_status,
|
||||||
|
duration_seconds = coalesce($3, duration_seconds),
|
||||||
|
metadata = metadata || jsonb_build_object('bunnyStatus', $4, 'bunnyWebhookAt', now())
|
||||||
|
where provider = 'bunny' and external_id = $1`,
|
||||||
|
[input.VideoGuid, status, input.Length ? Math.round(input.Length) : null, input.Status],
|
||||||
|
);
|
||||||
|
await recordAudit({ action: 'media.bunny.webhook_received', subjectType: 'video', subjectId: input.VideoGuid, metadata: { status, providerStatus: input.Status }, ipAddress: request.ip });
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -18,7 +18,9 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
|
|||||||
c.title,
|
c.title,
|
||||||
c.description,
|
c.description,
|
||||||
c.category,
|
c.category,
|
||||||
c.cover_image_url as "coverImageUrl",
|
coalesce(c.cover_image_url, (
|
||||||
|
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||||
|
)) as "coverImageUrl",
|
||||||
c.status,
|
c.status,
|
||||||
c.published_at as "publishedAt",
|
c.published_at as "publishedAt",
|
||||||
jsonb_build_object(
|
jsonb_build_object(
|
||||||
@@ -100,9 +102,27 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
app.get('/categories', async () => {
|
||||||
|
const result = await pool.query<{ name: string }>(`select name from course_categories where is_active order by position, name`);
|
||||||
|
return { data: result.rows.map((row) => row.name) };
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/', async (request) => {
|
app.get('/', async (request) => {
|
||||||
const authenticated = await hasSession(request);
|
const authenticated = await hasSession(request);
|
||||||
const result = await pool.query(`${courseSelect()} where c.status = 'published' order by c.published_at desc`);
|
const result = await pool.query(
|
||||||
|
`${courseSelect()}
|
||||||
|
left join platform_settings featured_setting on featured_setting.key = 'home.featuredCourseId'
|
||||||
|
left join platform_settings order_setting on order_setting.key = 'home.courseOrder'
|
||||||
|
where c.status = 'published'
|
||||||
|
order by
|
||||||
|
case when c.id::text = coalesce(featured_setting.value #>> '{}', '') then 0 else 1 end,
|
||||||
|
coalesce((
|
||||||
|
select position::int
|
||||||
|
from jsonb_array_elements_text(coalesce(order_setting.value, '[]'::jsonb)) with ordinality as ordered(id, position)
|
||||||
|
where ordered.id = c.id::text
|
||||||
|
), 999999),
|
||||||
|
c.published_at desc`,
|
||||||
|
);
|
||||||
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
|
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const paramsSchema = z.object({ courseId: z.string().uuid() });
|
|||||||
type CourseInput = z.infer<typeof courseSchema>;
|
type CourseInput = z.infer<typeof courseSchema>;
|
||||||
|
|
||||||
class CourseContentConflict extends Error {}
|
class CourseContentConflict extends Error {}
|
||||||
|
class CoursePublicationBlocked extends Error {}
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
return value
|
return value
|
||||||
@@ -82,6 +83,21 @@ async function insertAssets(client: PoolClient, parent: { courseId?: string; les
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertActiveCategory(category: string) {
|
||||||
|
const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]);
|
||||||
|
if (!result.rowCount) throw new CourseContentConflict('Select an active category created by the superadmin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPublishable(input: CourseInput) {
|
||||||
|
if (input.status !== 'published') return;
|
||||||
|
const unplayableLesson = input.lessons.find((lesson) =>
|
||||||
|
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
|
||||||
|
);
|
||||||
|
if (unplayableLesson) {
|
||||||
|
throw new CoursePublicationBlocked(`The course cannot be published while "${unplayableLesson.title}" has no ready video.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
|
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
|
||||||
const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]);
|
const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]);
|
||||||
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
||||||
@@ -184,6 +200,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
app.post('/', manageAccess, async (request, reply) => {
|
app.post('/', manageAccess, async (request, reply) => {
|
||||||
const input = courseSchema.parse(request.body);
|
const input = courseSchema.parse(request.body);
|
||||||
|
await assertActiveCategory(input.category);
|
||||||
|
assertPublishable(input);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('begin');
|
await client.query('begin');
|
||||||
@@ -200,7 +218,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
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 });
|
if (error instanceof CourseContentConflict || error instanceof CoursePublicationBlocked) return reply.code(409).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -212,6 +230,8 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const input = courseSchema.parse(request.body);
|
const input = courseSchema.parse(request.body);
|
||||||
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 assertActiveCategory(input.category);
|
||||||
|
assertPublishable(input);
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -230,7 +250,7 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
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 });
|
if (error instanceof CourseContentConflict || error instanceof CoursePublicationBlocked) return reply.code(409).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -245,4 +265,65 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/:courseId/duplicate', manageAccess, async (request, reply) => {
|
||||||
|
const { courseId } = paramsSchema.parse(request.params);
|
||||||
|
const accessError = await assertCanManageCourse(courseId, request.user);
|
||||||
|
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; instructor_id: string }>(
|
||||||
|
`select title, description, category, cover_image_url, instructor_id from courses where id = $1`, [courseId],
|
||||||
|
);
|
||||||
|
if (!original.rows[0]) {
|
||||||
|
await client.query('rollback');
|
||||||
|
return reply.code(404).send({ error: 'Course not found' });
|
||||||
|
}
|
||||||
|
const source = original.rows[0];
|
||||||
|
const title = `${source.title} (cópia)`;
|
||||||
|
const slug = await uniqueSlug(client, undefined, title);
|
||||||
|
const copiedCourse = await client.query<{ id: string }>(
|
||||||
|
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id)
|
||||||
|
values ($1, $2, $3, $4, $5, 'draft', $6) returning id`,
|
||||||
|
[slug, title, source.description, source.category, source.cover_image_url, source.instructor_id],
|
||||||
|
);
|
||||||
|
const newCourseId = copiedCourse.rows[0].id;
|
||||||
|
await client.query(
|
||||||
|
`insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
|
||||||
|
select $2, null, name, kind, url, size_bytes, description, access_level from assets where course_id = $1`,
|
||||||
|
[courseId, newCourseId],
|
||||||
|
);
|
||||||
|
const sourceLessons = await client.query<{ id: string; title: string; description: string; position: number; duration_seconds: number | null; access_level: 'public' | 'enrolled' }>(
|
||||||
|
`select id, title, description, position, duration_seconds, access_level from lessons where course_id = $1 order by position`, [courseId],
|
||||||
|
);
|
||||||
|
for (const lesson of sourceLessons.rows) {
|
||||||
|
const copiedLesson = await client.query<{ id: string }>(
|
||||||
|
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
|
||||||
|
values ($1, $2, $3, $4, $5, $6) returning id`,
|
||||||
|
[newCourseId, lesson.title, lesson.description, lesson.position, lesson.duration_seconds, lesson.access_level],
|
||||||
|
);
|
||||||
|
const newLessonId = copiedLesson.rows[0].id;
|
||||||
|
await client.query(
|
||||||
|
`insert into lesson_media (lesson_id, provider, external_id, playback_url, embed_url, status, duration_seconds, metadata)
|
||||||
|
select $2, provider, external_id, playback_url, embed_url, status, duration_seconds, metadata from lesson_media where lesson_id = $1`,
|
||||||
|
[lesson.id, newLessonId],
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
|
||||||
|
select null, $2, name, kind, url, size_bytes, description, access_level from assets where lesson_id = $1`,
|
||||||
|
[lesson.id, newLessonId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query('commit');
|
||||||
|
await recordAudit({ actorId: request.user.id, action: 'course.duplicated', subjectType: 'course', subjectId: newCourseId, metadata: { sourceCourseId: courseId, title }, ipAddress: request.ip });
|
||||||
|
return reply.code(201).send({ data: { id: newCourseId } });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
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'); },
|
async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); },
|
||||||
|
async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); },
|
||||||
|
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
|
||||||
|
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
|
||||||
|
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/admin/home-configuration'); },
|
||||||
|
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const instructorApi = {
|
export const instructorApi = {
|
||||||
@@ -138,6 +143,7 @@ export const instructorApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const courseApi = {
|
export const courseApi = {
|
||||||
|
async categories() { return apiRequest<{ data: string[] }>('/courses/categories'); },
|
||||||
async playback(courseId: string, lessonId: string) {
|
async playback(courseId: string, lessonId: string) {
|
||||||
return apiRequest<{ data: LessonPlayback }>(`/courses/${courseId}/lessons/${lessonId}/playback`);
|
return apiRequest<{ data: LessonPlayback }>(`/courses/${courseId}/lessons/${lessonId}/playback`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const toCourse = (course: ApiCourse): Course => ({
|
|||||||
title: course.title,
|
title: course.title,
|
||||||
description: course.description,
|
description: course.description,
|
||||||
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 || '/course-placeholder.svg',
|
||||||
instructor: course.instructor.name,
|
instructor: course.instructor.name,
|
||||||
status: course.status === 'draft' ? 'draft' : 'published',
|
status: course.status === 'draft' ? 'draft' : 'published',
|
||||||
progress: course.progress,
|
progress: course.progress,
|
||||||
@@ -186,6 +186,10 @@ export const deleteCourse = async (id: string): Promise<void> => {
|
|||||||
await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
|
await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const duplicateCourse = async (id: string): Promise<void> => {
|
||||||
|
await apiRequest(`/manage/courses/${id}/duplicate`, { method: 'POST' });
|
||||||
|
};
|
||||||
|
|
||||||
type ApiComment = {
|
type ApiComment = {
|
||||||
id: string;
|
id: string;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user