first commit
Some checks failed
CI / Validate frontend and API (push) Failing after 7m27s

This commit is contained in:
Cauê Faleiros
2026-08-31 12:38:34 -03:00
parent 684f929f28
commit 701788ea08
40 changed files with 5396 additions and 380 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
npm-debug.log
*.local

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Frontend (Vite): only values that are safe to expose in the browser belong here.
VITE_API_URL=http://localhost:3001
# API and PostgreSQL
APP_ENV=development
API_PORT=3001
DATABASE_URL=postgres://compor:compor_local_password@localhost:5433/compor_hub
FRONTEND_ORIGIN=http://localhost:3000
# Required in production. The development fallback must never be used outside localhost.
JWT_SECRET=replace-this-with-a-long-random-secret-before-deploying
# Run `npm run db:bootstrap-admin` after setting these values to create the first admin.
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
BOOTSTRAP_ADMIN_PASSWORD=change-this-password
BOOTSTRAP_ADMIN_NAME=Compor HUB Admin

29
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
validate:
name: Validate frontend and API
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci --include=dev
- run: npm run typecheck
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: frontend-build
path: dist
if-no-files-found: error

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -65,9 +65,9 @@ function AppContent() {
<Route <Route
path="/materiais" path="/materiais"
element={ element={
<MaterialsPage <ProtectedRoute>
onPlayCourse={setSelectedCourse} <MaterialsPage onPlayCourse={setSelectedCourse} />
/> </ProtectedRoute>
} }
/> />

35
BACKEND.md Normal file
View File

@@ -0,0 +1,35 @@
# Backend foundation
The project now has a small API and a PostgreSQL schema. Video delivery is provider-neutral: `lesson_media.provider` and `lesson_media.external_id` describe an external provider without coupling lessons to Panda, Vimeo, or any other service.
## Local setup
1. Copy `.env.example` to `.env` and use the default local values.
2. Start PostgreSQL with `docker compose up -d postgres`.
3. Install dependencies with `npm install`.
4. Apply the versioned schema with `npm run db:migrate`.
5. Run the API with `npm run dev:api` and the frontend with `npm run dev`.
The API health endpoint is available at `http://localhost:3001/api/v1/health`. The readiness endpoint verifies PostgreSQL at `/api/v1/ready`.
## Current API
- `GET /api/v1/courses` returns published courses.
- `GET /api/v1/courses/:courseId` returns one published course.
## Accounts and instructor access
- `POST /api/v1/auth/register` creates student accounts only.
- `POST /api/v1/auth/login` creates a seven-day signed session.
- `GET /api/v1/auth/me` restores an existing session.
- `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses.
To create the first local administrator, set `BOOTSTRAP_ADMIN_EMAIL`, `BOOTSTRAP_ADMIN_PASSWORD`, and optionally `BOOTSTRAP_ADMIN_NAME`, then run `npm run db:bootstrap-admin`. This command is deliberate and must not be exposed through the public API.
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.
## CI/CD status
GitHub Actions validates every pull request and every push to `main` by installing locked dependencies, type-checking the frontend and API, building the frontend, and uploading the production build as an artifact. Deployment remains intentionally unconfigured until the frontend and API hosting providers are chosen.

13
Dockerfile.api Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --include=dev
COPY server ./server
COPY constants.ts types.ts ./
ENV APP_ENV=production
EXPOSE 3001
CMD ["sh", "-c", "npm run db:migrate && npm run start:api"]

14
Dockerfile.web Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --include=dev
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

24
PORTAINER.md Normal file
View File

@@ -0,0 +1,24 @@
# Portainer deployment
Use `docker-compose.portainer.yml` as a Portainer Stack from this repository. It deploys the frontend, API, and PostgreSQL as one internal Docker network. Only the web container exposes a port; it proxies `/api` to the API container.
## Required Portainer environment variables
- `POSTGRES_PASSWORD`: a long, unique database password. Avoid characters that are not URL-safe because it is used in `DATABASE_URL`.
- `JWT_SECRET`: a unique random string of at least 32 characters.
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
Optional variables:
- `POSTGRES_DB` (default `compor_hub`)
- `POSTGRES_USER` (default `compor`)
- `WEB_PORT` (default `8080`)
## Before publishing
1. Deploy the stack with a temporary `WEB_PORT` and verify `/api/v1/health` through the public domain.
2. Create the production administrator using the API container's console and `npm run db:bootstrap-admin`, with the `BOOTSTRAP_ADMIN_*` variables supplied only for that one command.
3. 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.
4. Back up the `compor_postgres_data` volume before updates.
Do not expose port 5432 or port 3001 publicly.

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CourseRow } from './CourseRow'; import { CourseRow } from './CourseRow';
import { Section, Course } from '../types'; import { Section, Course } from '../types';
import { initDB, getCourses } from '../services/db'; import { getCourses } from '../services/db';
import { useAuth } from '../context/AuthContext';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react'; import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { CATEGORIES } from '../constants'; import { CATEGORIES } from '../constants';
@@ -17,6 +18,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState<string>('all'); const [activeCategory, setActiveCategory] = useState<string>('all');
const { user } = useAuth();
// Slider State & Refs for Categories // Slider State & Refs for Categories
const categoryScrollRef = useRef<HTMLDivElement>(null); const categoryScrollRef = useRef<HTMLDivElement>(null);
@@ -37,14 +39,15 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
await initDB(); try {
const allCourses = await getCourses(); setCourses(await getCourses());
setCourses(allCourses); } finally {
setLoading(false); setLoading(false);
}
}; };
loadData(); loadData();
}, []); }, [user]);
useEffect(() => { useEffect(() => {
checkCategoryBoundaries(); checkCategoryBoundaries();
@@ -225,4 +228,3 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
</section> </section>
); );
}; };

View File

@@ -107,7 +107,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id); const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id);
if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) { if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) {
const nextLesson = course.lessons[currentIndex + 1]; const nextLesson = course.lessons[currentIndex + 1];
if (nextLesson.isFree || user) { if (user) {
setTimeout(() => { setTimeout(() => {
setCurrentLesson(nextLesson); setCurrentLesson(nextLesson);
}, 1000); }, 1000);
@@ -181,7 +181,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const currentLessonAttachments = currentLesson?.attachments || []; const currentLessonAttachments = currentLesson?.attachments || [];
// Access Control Logic // Access Control Logic
const canWatch = currentLesson && (currentLesson.isFree || user); const canWatch = currentLesson && Boolean(user);
if (!course) return null; if (!course) return null;
@@ -398,7 +398,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
{course.lessons.map((lesson, index) => { {course.lessons.map((lesson, index) => {
const isActive = currentLesson?.id === lesson.id; const isActive = currentLesson?.id === lesson.id;
const isCompleted = completedLessonIds.includes(lesson.id); const isCompleted = completedLessonIds.includes(lesson.id);
const isLocked = !lesson.isFree && !user; const isLocked = !user;
const hasAttachments = lesson.attachments && lesson.attachments.length > 0; const hasAttachments = lesson.attachments && lesson.attachments.length > 0;
return ( return (

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Loader2, GraduationCap, School, KeyRound } from 'lucide-react'; import { X, Loader2, GraduationCap, School, KeyRound, UserPlus } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { UserRole } from '../types'; import { UserRole } from '../types';
@@ -12,62 +12,70 @@ interface LoginModalProps {
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => { export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [role, setRole] = useState<UserRole>('student'); // Default role const [name, setName] = useState('');
const [role, setRole] = useState<UserRole>('student');
const [isRegistering, setIsRegistering] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth(); const { login, register, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
if (!isOpen) return null; if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
e.preventDefault(); event.preventDefault();
setError(''); setError('');
setIsSubmitting(true); setIsSubmitting(true);
// Simulate network delay for "premium" feel if (isRegistering && role === 'professor') {
setTimeout(() => {
// In a real app, the backend verifies credentials AND role matches.
// Here we simulate checking if the user exists for that specific context.
const success = login(email);
setIsSubmitting(false); setIsSubmitting(false);
setError('Contas de instrutor são liberadas pelo administrador da plataforma.');
if (success) { return;
onClose(); }
if (email === 'admin@prof.com') {
// Force check: if they selected student but logged in as admin credentials, redirect correctly or warn. const signedInUser = isRegistering
// For UX simplicity here, we trust the credential mapping in AuthContext ? await register(name, email, password)
navigate('/gerenciar'); : await login(email, password);
} else { setIsSubmitting(false);
navigate('/');
} if (!signedInUser) {
} else { setError(isRegistering
setError('Credenciais inválidas. Verifique o email ou a função selecionada.'); ? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 12 caracteres.'
} : 'Credenciais inválidas. Verifique seu e-mail e senha.');
}, 800); return;
}
if (signedInUser.role !== role) {
logout();
setError(role === 'professor'
? 'Esta conta não possui acesso de instrutor.'
: 'Esta conta possui acesso de instrutor. Selecione a opção Instrutor.');
return;
}
onClose();
navigate(signedInUser.role === 'professor' ? '/gerenciar' : '/');
}; };
const handleDemoLogin = (roleType: UserRole) => { const handleDemoLogin = (roleType: UserRole) => {
const demoEmail = roleType === 'professor' ? 'admin@prof.com' : 'aluno@test.com';
setEmail(demoEmail);
setPassword('demo123');
setRole(roleType); setRole(roleType);
setIsRegistering(false);
if (roleType === 'professor') {
setEmail('admin@compor.local');
setPassword('ComporAdmin123!');
} else {
setEmail('student@compor.local');
setPassword('ComporStudent123!');
}
setError('');
}; };
return ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* Backdrop */} <div className="absolute inset-0 bg-black/60 backdrop-blur-xl transition-opacity" onClick={onClose} />
<div
className="absolute inset-0 bg-black/60 backdrop-blur-xl transition-opacity"
onClick={onClose}
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-[#1c1c1e] rounded-[8px] shadow-xl border border-white/10 p-6 md:p-8 animate-fade-in-up"> <div className="relative w-full max-w-md bg-[#1c1c1e] rounded-[8px] shadow-xl border border-white/10 p-6 md:p-8 animate-fade-in-up">
<button <button onClick={onClose} className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors" aria-label="Fechar">
onClick={onClose}
className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors"
>
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
@@ -76,91 +84,48 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
<p className="text-body-sm font-normal text-white/60 tracking-body-sm leading-body-sm">Acesse seu espaço de aprendizado e materiais</p> <p className="text-body-sm font-normal text-white/60 tracking-body-sm leading-body-sm">Acesse seu espaço de aprendizado e materiais</p>
</div> </div>
{/* Role Toggle */}
<div className="flex bg-black/40 p-1 rounded-[8px] mb-6 border border-white/5 relative"> <div className="flex bg-black/40 p-1 rounded-[8px] mb-6 border border-white/5 relative">
{/* Sliding Background */} <div className={`absolute top-1 bottom-1 w-[calc(50%-4px)] bg-orange-500/20 rounded-[8px] transition-all duration-300 ease-out border border-orange-500/30 shadow-md ${role === 'student' ? 'left-1' : 'left-[calc(50%+4px)]'}`} />
<div <button type="button" onClick={() => setRole('student')} className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'student' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}>
className={`absolute top-1 bottom-1 w-[calc(50%-4px)] bg-orange-500/20 rounded-[8px] transition-all duration-300 ease-out border border-orange-500/30 shadow-md ${role === 'student' ? 'left-1' : 'left-[calc(50%+4px)]'}`} <GraduationCap className="w-4 h-4" />
/> <span className="text-body-sm font-semibold tracking-body-sm">Aluno</span>
</button>
<button <button type="button" onClick={() => setRole('professor')} className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'professor' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}>
type="button" <School className="w-4 h-4" />
onClick={() => setRole('student')} <span className="text-body-sm font-semibold tracking-body-sm">Instrutor</span>
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'student' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`} </button>
>
<GraduationCap className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Aluno</span>
</button>
<button
type="button"
onClick={() => setRole('professor')}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'professor' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}
>
<School className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Instrutor</span>
</button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3"> <div className="space-y-3">
<div> {isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />}
<input <input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} />
type="email" <input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
placeholder="Seu e-mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body"
autoFocus
/>
</div>
<div>
<input
type="password"
placeholder="Sua senha"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body"
/>
</div>
</div> </div>
{/* Quick Demo Buttons */} {!isRegistering && (
<div className="flex gap-2 justify-center"> <div className="flex gap-2 justify-center">
<button <button type="button" onClick={() => handleDemoLogin('student')} className="text-caption font-semibold tracking-caption text-orange-400 hover:text-orange-300 px-3 py-1 rounded-[980px] bg-orange-500/10 border border-orange-500/20 flex items-center gap-1 transition-colors">
type="button"
onClick={() => handleDemoLogin('student')}
className="text-caption font-semibold tracking-caption text-orange-400 hover:text-orange-300 px-3 py-1 rounded-[980px] bg-orange-500/10 border border-orange-500/20 flex items-center gap-1 transition-colors"
>
<KeyRound className="w-3 h-3" /> Acesso Aluno (Demo) <KeyRound className="w-3 h-3" /> Acesso Aluno (Demo)
</button> </button>
<button <button type="button" onClick={() => handleDemoLogin('professor')} className="text-caption font-semibold tracking-caption text-emerald-400 hover:text-emerald-300 px-3 py-1 rounded-[980px] bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-1 transition-colors">
type="button"
onClick={() => handleDemoLogin('professor')}
className="text-caption font-semibold tracking-caption text-emerald-400 hover:text-emerald-300 px-3 py-1 rounded-[980px] bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-1 transition-colors"
>
<KeyRound className="w-3 h-3" /> Acesso Instrutor (Demo) <KeyRound className="w-3 h-3" /> Acesso Instrutor (Demo)
</button> </button>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3">
<p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p>
</div> </div>
)} )}
<button {error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
type="submit"
disabled={isSubmitting} <button type="submit" disabled={isSubmitting} className="w-full bg-white text-black text-body-sm font-semibold tracking-body-sm py-3.5 rounded-[980px] hover:bg-gray-200 active:scale-95 transition-all duration-200 flex items-center justify-center gap-2 shadow-xl">
className="w-full bg-white text-black text-body-sm font-semibold tracking-body-sm py-3.5 rounded-[980px] hover:bg-gray-200 active:scale-95 transition-all duration-200 flex items-center justify-center gap-2 shadow-xl" {isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : isRegistering ? <UserPlus className="w-5 h-5" /> : null}
> {isRegistering ? 'Criar conta' : 'Entrar'}
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
'Entrar'
)}
</button> </button>
</form> </form>
<button type="button" onClick={() => { setIsRegistering((value) => !value); setError(''); }} className="w-full mt-5 text-xs text-orange-400 hover:text-orange-300 transition-colors">
{isRegistering ? 'Já possui conta? Entrar' : 'Não possui conta? Criar acesso de aluno'}
</button>
</div> </div>
</div> </div>
); );
}; };

View File

@@ -1,9 +1,11 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { User, UserRole } from '../types'; import { User, UserRole } from '../types';
import { authApi, clearSession, getSession, saveSession } from '../services/api';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
login: (email: string) => boolean; login: (email: string, password: string) => Promise<User | null>;
register: (name: string, email: string, password: string) => Promise<User | null>;
logout: () => void; logout: () => void;
isLoading: boolean; isLoading: boolean;
} }
@@ -15,55 +17,73 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
// Check localStorage on mount with error handling const restoreSession = async () => {
const storedUser = localStorage.getItem('educlean_user'); const session = getSession();
if (storedUser) { if (!session) {
try { setIsLoading(false);
const parsedUser = JSON.parse(storedUser); return;
if (parsedUser && parsedUser.email) {
setUser(parsedUser);
}
} catch (error) {
console.error("Failed to parse user session, clearing storage:", error);
localStorage.removeItem('educlean_user');
} }
}
setIsLoading(false); try {
const { user: apiUser } = await authApi.me();
const restoredUser = toFrontendUser(apiUser);
saveSession({ ...session, user: apiUser });
setUser(restoredUser);
} catch {
clearSession();
} finally {
setIsLoading(false);
}
};
void restoreSession();
}, []); }, []);
const login = (email: string): boolean => { const login = async (email: string, password: string): Promise<User | null> => {
let newUser: User | null = null; try {
const session = await authApi.login(email, password);
if (email === 'admin@prof.com') { const signedInUser = toFrontendUser(session.user);
newUser = { email, name: 'Prof. Anderson', role: 'professor' }; saveSession(session);
} else if (email === 'aluno@test.com') { setUser(signedInUser);
newUser = { email, name: 'Aluno Demo', role: 'student' }; return signedInUser;
} catch {
return null;
} }
};
if (newUser) { const register = async (name: string, email: string, password: string): Promise<User | null> => {
setUser(newUser); try {
localStorage.setItem('educlean_user', JSON.stringify(newUser)); const session = await authApi.register(name, email, password);
return true; const signedInUser = toFrontendUser(session.user);
saveSession(session);
setUser(signedInUser);
return signedInUser;
} catch {
return null;
} }
return false;
}; };
const logout = () => { const logout = () => {
setUser(null); setUser(null);
localStorage.removeItem('educlean_user'); clearSession();
}; };
return ( return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}> <AuthContext.Provider value={{ user, login, register, logout, isLoading }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
const toFrontendUser = (user: { email: string; name: string; role: string }): User => ({
email: user.email,
name: user.name,
role: user.role === 'student' ? 'student' : 'professor',
});
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error('useAuth must be used within an AuthProvider');
} }
return context; return context;
}; };

View File

@@ -0,0 +1,43 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-compor_hub}
POSTGRES_USER: ${POSTGRES_USER:-compor}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Portainer}
volumes:
- compor_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile.api
restart: unless-stopped
environment:
APP_ENV: production
API_PORT: 3001
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?Set the public https URL in Portainer}
DATABASE_URL: postgresql://${POSTGRES_USER:-compor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-compor_hub}
JWT_SECRET: ${JWT_SECRET:?Set a long random JWT_SECRET in Portainer}
depends_on:
postgres:
condition: service_healthy
web:
build:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
ports:
- "${WEB_PORT:-8080}:80"
depends_on:
- api
volumes:
compor_postgres_data:

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: compor_hub
POSTGRES_USER: compor
POSTGRES_PASSWORD: compor_local_password
ports:
- "5433:5432"
volumes:
- compor_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U compor -d compor_hub"]
interval: 5s
timeout: 5s
retries: 10
volumes:
compor_postgres_data:

19
docker/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

3899
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,15 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"dev:api": "tsx watch server/src/index.ts",
"start:api": "tsx server/src/index.ts",
"db:migrate": "tsx server/src/db/migrate.ts",
"db:bootstrap-admin": "tsx server/src/db/bootstrap-admin.ts",
"db:seed-demo-content": "tsx server/src/db/seed-demo-content.ts",
"typecheck:client": "tsc --project tsconfig.json",
"typecheck:api": "tsc --project server/tsconfig.json",
"typecheck": "npm run typecheck:client && npm run typecheck:api"
}, },
"dependencies": { "dependencies": {
"react": "18.2.0", "react": "18.2.0",
@@ -14,12 +22,20 @@
"react-router-dom": "6.22.3", "react-router-dom": "6.22.3",
"lucide-react": "0.344.0", "lucide-react": "0.344.0",
"localforage": "1.10.0", "localforage": "1.10.0",
"@google/genai": "latest" "@fastify/cors": "^11.0.0",
"@fastify/jwt": "^9.1.0",
"dotenv": "^16.6.1",
"fastify": "^5.3.2",
"fastify-plugin": "^5.0.1",
"pg": "^8.16.3",
"zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.0" "vite": "^6.2.0",
"@types/pg": "^8.15.4",
"tsx": "^4.19.4"
} }
} }

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Plus, Plus,
Settings, Settings,
@@ -11,7 +11,6 @@ import {
Send, Send,
Image as ImageIcon, Image as ImageIcon,
Video, Video,
Sparkles,
Loader2, Loader2,
FileText, FileText,
FileSpreadsheet, FileSpreadsheet,
@@ -23,14 +22,12 @@ import {
Check, Check,
Lock, Lock,
Unlock, Unlock,
UploadCloud,
Globe, Globe,
Download Download
} from 'lucide-react'; } from 'lucide-react';
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types'; import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { getCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db'; import { getManagedCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
import { generateStudentReply } from '../services/ai';
import { CATEGORIES } from '../constants'; import { CATEGORIES } from '../constants';
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard'; import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
@@ -38,7 +35,7 @@ import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCar
const CourseModal: React.FC<{ const CourseModal: React.FC<{
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSave: (course: Course) => void; onSave: (course: Course) => Promise<void>;
initialData?: Course; initialData?: Course;
}> = ({ isOpen, onClose, onSave, initialData }) => { }> = ({ isOpen, onClose, onSave, initialData }) => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -55,10 +52,8 @@ const CourseModal: React.FC<{
const [newLessonTitle, setNewLessonTitle] = useState(''); const [newLessonTitle, setNewLessonTitle] = useState('');
const [newLessonDuration, setNewLessonDuration] = useState(''); const [newLessonDuration, setNewLessonDuration] = useState('');
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState(''); const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
const [newLessonProvider, setNewLessonProvider] = useState('external');
const [newLessonIsFree, setNewLessonIsFree] = useState(false); const [newLessonIsFree, setNewLessonIsFree] = useState(false);
const [videoSourceType, setVideoSourceType] = useState<'upload' | 'link'>('upload');
const [selectedFileName, setSelectedFileName] = useState('');
const lessonFileInputRef = useRef<HTMLInputElement>(null);
// Tips & Global Attachments // Tips & Global Attachments
const [tips, setTips] = useState<string[]>([]); const [tips, setTips] = useState<string[]>([]);
@@ -73,7 +68,6 @@ const CourseModal: React.FC<{
const [newAttDesc, setNewAttDesc] = useState(''); const [newAttDesc, setNewAttDesc] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -101,31 +95,13 @@ const CourseModal: React.FC<{
} }
}, [isOpen, initialData]); }, [isOpen, initialData]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const objectUrl = URL.createObjectURL(file);
setThumbnailPreview(objectUrl);
}
};
const handleLessonVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const objectUrl = URL.createObjectURL(file);
setNewLessonVideoUrl(objectUrl);
setSelectedFileName(file.name);
if (!newLessonDuration) setNewLessonDuration('15m');
}
};
const handleAddLesson = () => { const handleAddLesson = () => {
if (!newLessonTitle.trim()) { if (!newLessonTitle.trim()) {
alert("Digite um título para a aula."); alert("Digite um título para a aula.");
return; return;
} }
if (!newLessonVideoUrl.trim()) { if (!newLessonVideoUrl.trim()) {
alert("Selecione um vídeo ou insira um link."); alert("Enter a hosted video URL.");
return; return;
} }
@@ -134,6 +110,7 @@ const CourseModal: React.FC<{
title: newLessonTitle, title: newLessonTitle,
duration: newLessonDuration || '15m', duration: newLessonDuration || '15m',
videoUrl: newLessonVideoUrl, videoUrl: newLessonVideoUrl,
mediaProvider: newLessonProvider || 'external',
isFree: newLessonIsFree, isFree: newLessonIsFree,
description: '' description: ''
}; };
@@ -143,9 +120,8 @@ const CourseModal: React.FC<{
setNewLessonTitle(''); setNewLessonTitle('');
setNewLessonDuration(''); setNewLessonDuration('');
setNewLessonVideoUrl(''); setNewLessonVideoUrl('');
setSelectedFileName(''); setNewLessonProvider('external');
setNewLessonIsFree(false); setNewLessonIsFree(false);
if (lessonFileInputRef.current) lessonFileInputRef.current.value = '';
}; };
const removeLesson = (id: string) => { const removeLesson = (id: string) => {
@@ -193,7 +169,7 @@ const CourseModal: React.FC<{
setAttachments(attachments.filter(a => a.id !== id)); setAttachments(attachments.filter(a => a.id !== id));
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (lessons.length === 0) { if (lessons.length === 0) {
alert("Adicione pelo menos uma aula à trilha."); alert("Adicione pelo menos uma aula à trilha.");
@@ -204,24 +180,28 @@ const CourseModal: React.FC<{
const durationStr = `${lessons.length} Aulas`; const durationStr = `${lessons.length} Aulas`;
onSave({ try {
id: initialData?.id || `course-${Date.now()}`, await onSave({
title, id: initialData?.id || `course-${Date.now()}`,
description, title,
category: category || CATEGORIES[0], description,
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80', category: category || CATEGORIES[0],
duration: durationStr, thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
instructor, duration: durationStr,
instructorRole, instructor,
views: initialData?.views || 0, instructorRole,
progress: initialData?.progress || 0, views: initialData?.views || 0,
lessons: lessons, progress: initialData?.progress || 0,
tips: tips, lessons: lessons,
attachments: attachments tips: tips,
}); attachments: attachments
});
setIsSubmitting(false); onClose();
onClose(); } catch {
alert('We could not save this course. Confirm that you are still signed in and that all asset URLs are valid.');
} finally {
setIsSubmitting(false);
}
}; };
if (!isOpen) return null; if (!isOpen) return null;
@@ -243,7 +223,6 @@ const CourseModal: React.FC<{
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label> <label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
<div <div
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group" className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group"
onClick={() => fileInputRef.current?.click()}
> >
{thumbnailPreview ? ( {thumbnailPreview ? (
<> <>
@@ -255,17 +234,17 @@ const CourseModal: React.FC<{
) : ( ) : (
<div className="text-center p-6"> <div className="text-center p-6">
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" /> <ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
<p className="text-gray-300 text-xs font-medium">Clique para selecionar imagem</p> <p className="text-gray-300 text-xs font-medium">Add a hosted cover image URL below</p>
</div> </div>
)} )}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div> </div>
<input
type="url"
value={thumbnailPreview}
onChange={(event) => setThumbnailPreview(event.target.value)}
placeholder="Hosted cover image URL"
className="w-full mt-2 bg-black/50 text-white text-xs border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
/>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -492,52 +471,21 @@ const CourseModal: React.FC<{
/> />
</div> </div>
{/* Video Source Tabs */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex bg-white/5 p-0.5 rounded-lg border border-white/5"> <input
<button type="text"
type="button" value={newLessonProvider}
onClick={() => setVideoSourceType('upload')} onChange={(event) => setNewLessonProvider(event.target.value)}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'upload' ? 'bg-orange-500 text-white' : 'text-gray-400'}`} placeholder="Provider (external, Panda, Vimeo...)"
> className="w-40 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
Vídeo MP4 />
</button> <input
<button type="url"
type="button" value={newLessonVideoUrl}
onClick={() => setVideoSourceType('link')} onChange={(event) => setNewLessonVideoUrl(event.target.value)}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'link' ? 'bg-orange-500 text-white' : 'text-gray-400'}`} placeholder="Hosted video URL"
> className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
URL Externa />
</button>
</div>
<div className="flex-1">
{videoSourceType === 'upload' ? (
<div
onClick={() => lessonFileInputRef.current?.click()}
className="border border-dashed border-white/20 hover:border-orange-500/50 rounded-xl py-2 px-3 text-center cursor-pointer transition-all bg-white/[0.02]"
>
<input
ref={lessonFileInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleLessonVideoFileSelect}
/>
<span className="text-xs text-gray-300 truncate block">
{selectedFileName ? `Arquivo: ${selectedFileName}` : 'Selecionar arquivo de vídeo'}
</span>
</div>
) : (
<input
type="text"
value={newLessonVideoUrl}
onChange={(e) => setNewLessonVideoUrl(e.target.value)}
placeholder="https://commondatastorage.googleapis.com/...mp4"
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
)}
</div>
<button <button
type="button" type="button"
@@ -577,13 +525,12 @@ export const ManageCourses: React.FC = () => {
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]); const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
const [replyingTo, setReplyingTo] = useState<string | null>(null); const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState(''); const [replyText, setReplyText] = useState('');
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
const { showToast } = useToast(); const { showToast } = useToast();
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
const data = await getCourses(); const data = await getManagedCourses();
setCourses(data); setCourses(data);
// Load all comments // Load all comments
@@ -644,18 +591,6 @@ export const ManageCourses: React.FC = () => {
} }
}; };
const handleGenerateAIReply = async (commentText: string, courseTitle: string) => {
setIsGeneratingReply(true);
try {
const aiReply = await generateStudentReply(commentText, courseTitle);
setReplyText(aiReply);
} catch (e) {
showToast('Erro ao gerar resposta com IA.', 'error');
} finally {
setIsGeneratingReply(false);
}
};
// Metrics // Metrics
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0); const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0); const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
@@ -850,18 +785,7 @@ export const ManageCourses: React.FC = () => {
className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none" className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none"
rows={3} rows={3}
/> />
<div className="flex items-center justify-between"> <div className="flex justify-end gap-2">
<button
type="button"
onClick={() => handleGenerateAIReply(comment.text, courseTitle)}
disabled={isGeneratingReply}
className="inline-flex items-center gap-1.5 text-body-sm text-orange-400 hover:text-orange-300 font-semibold"
>
<Sparkles className="w-3.5 h-3.5" />
{isGeneratingReply ? 'Gerando com IA...' : 'Sugerir Resposta com IA'}
</button>
<div className="flex gap-2">
<button <button
onClick={() => { setReplyingTo(null); setReplyText(''); }} onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold" className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold"
@@ -876,7 +800,6 @@ export const ManageCourses: React.FC = () => {
</button> </button>
</div> </div>
</div> </div>
</div>
) : ( ) : (
<button <button
onClick={() => setReplyingTo(comment.id)} onClick={() => setReplyingTo(comment.id)}

View File

@@ -0,0 +1,140 @@
create extension if not exists pgcrypto;
create type user_role as enum ('student', 'instructor', 'admin');
create type course_status as enum ('draft', 'published', 'archived');
create type lesson_access_level as enum ('public', 'enrolled');
create type media_status as enum ('processing', 'ready', 'failed');
create type asset_kind as enum ('document', 'spreadsheet', 'archive', 'image', 'link');
create table users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
display_name text not null,
role user_role not null default 'student',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table courses (
id uuid primary key default gen_random_uuid(),
slug text not null unique,
title text not null,
description text not null default '',
category text not null,
cover_image_url text,
status course_status not null default 'draft',
instructor_id uuid not null references users(id),
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint courses_published_at_check check (
(status = 'published' and published_at is not null) or status <> 'published'
)
);
create table lessons (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references courses(id) on delete cascade,
title text not null,
description text not null default '',
position integer not null check (position > 0),
duration_seconds integer check (duration_seconds is null or duration_seconds >= 0),
access_level lesson_access_level not null default 'enrolled',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (course_id, position)
);
-- Video delivery is intentionally provider-neutral. A lesson can be moved from
-- one provider to another without changing the lesson itself.
create table lesson_media (
id uuid primary key default gen_random_uuid(),
lesson_id uuid not null references lessons(id) on delete cascade,
provider text not null,
external_id text not null,
playback_url text,
embed_url text,
status media_status not null default 'processing',
duration_seconds integer check (duration_seconds is null or duration_seconds >= 0),
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (provider, external_id)
);
create table course_enrollments (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references courses(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
enrolled_at timestamptz not null default now(),
revoked_at timestamptz,
unique (course_id, user_id)
);
create table lesson_progress (
lesson_id uuid not null references lessons(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
watched_seconds integer not null default 0 check (watched_seconds >= 0),
completed_at timestamptz,
updated_at timestamptz not null default now(),
primary key (lesson_id, user_id)
);
create table assets (
id uuid primary key default gen_random_uuid(),
course_id uuid references courses(id) on delete cascade,
lesson_id uuid references lessons(id) on delete cascade,
name text not null,
kind asset_kind not null,
url text not null,
size_bytes bigint check (size_bytes is null or size_bytes >= 0),
description text not null default '',
access_level lesson_access_level not null default 'enrolled',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint assets_parent_check check (
(course_id is not null and lesson_id is null) or (course_id is null and lesson_id is not null)
)
);
create table comments (
id uuid primary key default gen_random_uuid(),
course_id uuid not null references courses(id) on delete cascade,
lesson_id uuid references lessons(id) on delete set null,
author_id uuid not null references users(id),
body text not null check (char_length(body) between 1 and 4000),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table comment_replies (
id uuid primary key default gen_random_uuid(),
comment_id uuid not null unique references comments(id) on delete cascade,
author_id uuid not null references users(id),
body text not null check (char_length(body) between 1 and 4000),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index courses_published_index on courses (published_at desc) where status = 'published';
create index lessons_course_position_index on lessons (course_id, position);
create index lesson_media_lesson_index on lesson_media (lesson_id);
create index enrollments_active_index on course_enrollments (user_id, course_id) where revoked_at is null;
create index comments_course_created_index on comments (course_id, created_at desc);
create function set_updated_at() returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger users_set_updated_at before update on users for each row execute function set_updated_at();
create trigger courses_set_updated_at before update on courses for each row execute function set_updated_at();
create trigger lessons_set_updated_at before update on lessons for each row execute function set_updated_at();
create trigger lesson_media_set_updated_at before update on lesson_media for each row execute function set_updated_at();
create trigger lesson_progress_set_updated_at before update on lesson_progress for each row execute function set_updated_at();
create trigger assets_set_updated_at before update on assets for each row execute function set_updated_at();
create trigger comments_set_updated_at before update on comments for each row execute function set_updated_at();
create trigger comment_replies_set_updated_at before update on comment_replies for each row execute function set_updated_at();

View File

@@ -0,0 +1,2 @@
alter table lesson_media drop constraint lesson_media_provider_external_id_key;
alter table lesson_media add constraint lesson_media_lesson_provider_external_id_key unique (lesson_id, provider, external_id);

43
server/src/app.ts Normal file
View File

@@ -0,0 +1,43 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import { authPlugin } from './auth/plugin.js';
import { config } from './config.js';
import { pool } from './db/pool.js';
import { authRoutes } from './routes/auth.js';
import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js';
export function buildApp() {
const app = Fastify({ logger: true });
app.register(cors, {
origin: config.FRONTEND_ORIGIN,
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
});
app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid request', details: error.flatten() });
}
app.log.error(error);
return reply.code(500).send({ error: 'Internal server error' });
});
app.get('/api/v1/health', async () => ({ status: 'ok' }));
app.get('/api/v1/ready', async (_request, reply) => {
try {
await pool.query('select 1');
return { status: 'ready' };
} catch {
return reply.code(503).send({ status: 'unavailable' });
}
});
app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
return app;
}

View File

@@ -0,0 +1,20 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCallback);
const keyLength = 64;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const derivedKey = await scrypt(password, salt, keyLength) as Buffer;
return `scrypt$${salt}$${derivedKey.toString('hex')}`;
}
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [algorithm, salt, encodedKey] = storedHash.split('$');
if (algorithm !== 'scrypt' || !salt || !encodedKey) return false;
const storedKey = Buffer.from(encodedKey, 'hex');
const derivedKey = await scrypt(password, salt, keyLength) as Buffer;
return storedKey.length === derivedKey.length && timingSafeEqual(storedKey, derivedKey);
}

53
server/src/auth/plugin.ts Normal file
View File

@@ -0,0 +1,53 @@
import fastifyJwt from '@fastify/jwt';
import type { FastifyPluginAsync, preHandlerHookHandler } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { config } from '../config.js';
export type UserRole = 'student' | 'instructor' | 'admin';
export interface AuthUser {
id: string;
email: string;
role: UserRole;
name: string;
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AuthUser;
user: AuthUser;
}
}
declare module 'fastify' {
interface FastifyInstance {
authenticate: preHandlerHookHandler;
requireRoles: (roles: UserRole[]) => preHandlerHookHandler;
}
}
const registerAuth: FastifyPluginAsync = async (app) => {
await app.register(fastifyJwt, { secret: config.JWT_SECRET });
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
});
app.decorate('requireRoles', (roles: UserRole[]) => async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' });
}
});
};
export const authPlugin = fastifyPlugin(registerAuth, { name: 'auth' });

19
server/src/config.ts Normal file
View File

@@ -0,0 +1,19 @@
import 'dotenv/config';
import { z } from 'zod';
const environmentSchema = z.object({
API_PORT: z.coerce.number().int().positive().default(3001),
DATABASE_URL: z.string().url().default('postgres://compor:compor_local_password@localhost:5433/compor_hub'),
FRONTEND_ORIGIN: z.string().url().default('http://localhost:3000'),
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
BOOTSTRAP_ADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Admin'),
});
export const config = environmentSchema.parse(process.env);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
throw new Error('JWT_SECRET must be set to a unique value in production.');
}

View File

@@ -0,0 +1,29 @@
import { hashPassword } from '../auth/passwords.js';
import { config } from '../config.js';
import { closePool, pool } from './pool.js';
async function bootstrapAdmin() {
if (!config.BOOTSTRAP_ADMIN_EMAIL || !config.BOOTSTRAP_ADMIN_PASSWORD) {
throw new Error('Set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD before running this command.');
}
const passwordHash = await hashPassword(config.BOOTSTRAP_ADMIN_PASSWORD);
const result = await pool.query<{ email: string }>(
`insert into users (email, password_hash, display_name, role)
values ($1, $2, $3, 'admin')
on conflict (email) do update
set password_hash = excluded.password_hash,
display_name = excluded.display_name,
role = 'admin'
returning email`,
[config.BOOTSTRAP_ADMIN_EMAIL, passwordHash, config.BOOTSTRAP_ADMIN_NAME],
);
console.log(`Administrator ready: ${result.rows[0].email}`);
}
bootstrapAdmin()
.catch((error: unknown) => {
console.error('Admin bootstrap failed', error);
process.exitCode = 1;
})
.finally(closePool);

50
server/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,50 @@
import { readdir, readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool, closePool } from './pool.js';
const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
async function migrate() {
await pool.query(`
create table if not exists schema_migrations (
name text primary key,
applied_at timestamptz not null default now()
)
`);
const migrations = (await readdir(migrationsDirectory))
.filter((file) => file.endsWith('.sql'))
.sort();
for (const migration of migrations) {
const alreadyApplied = await pool.query<{ name: string }>(
'select name from schema_migrations where name = $1',
[migration],
);
if (alreadyApplied.rowCount) continue;
const sql = await readFile(join(migrationsDirectory, migration), 'utf8');
const client = await pool.connect();
try {
await client.query('begin');
await client.query(sql);
await client.query('insert into schema_migrations (name) values ($1)', [migration]);
await client.query('commit');
console.log(`Applied ${migration}`);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
}
migrate()
.catch((error: unknown) => {
console.error('Migration failed', error);
process.exitCode = 1;
})
.finally(closePool);

8
server/src/db/pool.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Pool } from 'pg';
import { config } from '../config.js';
export const pool = new Pool({
connectionString: config.DATABASE_URL,
});
export const closePool = () => pool.end();

View File

@@ -0,0 +1,71 @@
import { INITIAL_COURSES } from '../../../constants.js';
import { closePool, pool } from './pool.js';
const instructorEmail = process.env.SEED_INSTRUCTOR_EMAIL || 'admin@compor.local';
const slugify = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
const durationToSeconds = (duration: string) => {
const hours = Number(duration.match(/(\d+)h/)?.[1] || 0);
const minutes = Number(duration.match(/(\d+)m/)?.[1] || 0);
return hours * 3600 + minutes * 60 || null;
};
async function seedDemoContent() {
const instructor = await pool.query<{ id: string }>('select id from users where email = $1', [instructorEmail]);
if (!instructor.rowCount) {
throw new Error(`No administrator found for ${instructorEmail}. Run db:bootstrap-admin first.`);
}
let created = 0;
for (const [courseIndex, course] of INITIAL_COURSES.entries()) {
const exists = await pool.query('select 1 from courses where title = $1', [course.title]);
if (exists.rowCount) continue;
const client = await pool.connect();
try {
await client.query('begin');
const insertedCourse = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, 'published', $6, now()) returning id`,
[`${slugify(course.title)}-${courseIndex + 1}`, course.title, course.description, course.category, course.thumbnail, instructor.rows[0].id],
);
const courseId = insertedCourse.rows[0].id;
for (const [lessonIndex, lesson] of course.lessons.entries()) {
const insertedLesson = await client.query<{ id: string }>(
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
values ($1, $2, $3, $4, $5, 'public') returning id`,
[courseId, lesson.title, lesson.description || '', lessonIndex + 1, durationToSeconds(lesson.duration)],
);
if (lesson.videoUrl.startsWith('http')) {
await client.query(
`insert into lesson_media (lesson_id, provider, external_id, playback_url, status)
values ($1, 'external', $2, $2, 'ready')`,
[insertedLesson.rows[0].id, lesson.videoUrl],
);
}
}
await client.query('commit');
created += 1;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
console.log(`Demo catalogue ready: ${created} courses added.`);
}
seedDemoContent()
.catch((error: unknown) => {
console.error('Demo catalogue seed failed', error);
process.exitCode = 1;
})
.finally(closePool);

25
server/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { buildApp } from './app.js';
import { config } from './config.js';
import { closePool } from './db/pool.js';
const app = buildApp();
async function start() {
try {
await app.listen({ host: '0.0.0.0', port: config.API_PORT });
} catch (error) {
app.log.error(error);
await closePool();
process.exit(1);
}
}
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => {
app.close()
.then(closePool)
.finally(() => process.exit(0));
});
}
void start();

72
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js';
const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()),
password: z.string().min(12).max(200),
});
const registerSchema = credentialsSchema.extend({
name: z.string().trim().min(2).max(120),
});
type UserRow = {
id: string;
email: string;
display_name: string;
role: AuthUser['role'];
password_hash: string;
};
const serializeUser = (user: UserRow): AuthUser => ({
id: user.id,
email: user.email,
name: user.display_name,
role: user.role,
});
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/register', async (request, reply) => {
const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password);
try {
const result = await pool.query<UserRow>(
`insert into users (email, password_hash, display_name)
values ($1, $2, $3)
returning id, email, display_name, role, password_hash`,
[input.email, passwordHash, input.name],
);
const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
return reply.code(201).send({ token, user });
} catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
return reply.code(409).send({ error: 'An account with this email already exists' });
}
throw error;
}
});
app.post('/login', async (request, reply) => {
const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>(
`select id, email, display_name, role, password_hash from users where email = $1`,
[input.email],
);
const account = result.rows[0];
if (!account || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' });
}
const user = serializeUser(account);
const token = await reply.jwtSign(user, { expiresIn: '7d' });
return { token, user };
});
app.get('/me', { preHandler: app.authenticate }, async (request) => ({ user: request.user }));
};

View File

@@ -0,0 +1,115 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
const paramsSchema = z.object({
courseId: z.string().uuid(),
});
export const courseSelect = (publicOnly = false) => `
select
c.id,
c.slug,
c.title,
c.description,
c.category,
c.cover_image_url as "coverImageUrl",
c.status,
c.published_at as "publishedAt",
jsonb_build_object(
'id', u.id,
'name', u.display_name
) as instructor,
coalesce((
select jsonb_agg(
jsonb_build_object(
'id', l.id,
'title', l.title,
'description', l.description,
'position', l.position,
'durationSeconds', l.duration_seconds,
'accessLevel', l.access_level,
'assets', coalesce((
select jsonb_agg(jsonb_build_object(
'id', a.id,
'name', a.name,
'kind', a.kind,
'url', a.url,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
from assets a
where a.lesson_id = l.id ${publicOnly ? "and a.access_level = 'public'" : ''}
), '[]'::jsonb),
'media', coalesce((
select jsonb_agg(
jsonb_build_object(
'id', lm.id,
'provider', lm.provider,
'status', lm.status,
'embedUrl', lm.embed_url,
'playbackUrl', lm.playback_url,
'durationSeconds', lm.duration_seconds
) order by lm.created_at
)
from lesson_media lm
where lm.lesson_id = l.id and lm.status = 'ready'
), '[]'::jsonb)
) order by l.position
)
from lessons l
where l.course_id = c.id ${publicOnly ? "and l.access_level = 'public'" : ''}
), '[]'::jsonb) as lessons
, coalesce((
select jsonb_agg(jsonb_build_object(
'id', a.id,
'name', a.name,
'kind', a.kind,
'url', a.url,
'description', a.description,
'accessLevel', a.access_level
) order by a.created_at)
from assets a
where a.course_id = c.id ${publicOnly ? "and a.access_level = 'public'" : ''}
), '[]'::jsonb) as assets
from courses c
join users u on u.id = c.instructor_id
`;
export const courseRoutes: FastifyPluginAsync = async (app) => {
const withoutProtectedMedia = (course: Record<string, unknown>) => ({
...course,
assets: [],
lessons: Array.isArray(course.lessons)
? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] }))
: [],
});
const hasSession = async (request: { jwtVerify: () => Promise<unknown> }) => {
try {
await request.jwtVerify();
return true;
} catch {
return false;
}
};
app.get('/', async (request) => {
const authenticated = await hasSession(request);
const result = await pool.query(`${courseSelect()} where c.status = 'published' order by c.published_at desc`);
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
});
app.get('/:courseId', async (request, reply) => {
const authenticated = await hasSession(request);
const { courseId } = paramsSchema.parse(request.params);
const result = await pool.query(`${courseSelect()} where c.id = $1 and c.status = 'published'`, [courseId]);
const course = result.rows[0];
if (!course) {
return reply.code(404).send({ error: 'Course not found' });
}
return { data: authenticated ? course : withoutProtectedMedia(course) };
});
};

View File

@@ -0,0 +1,188 @@
import { randomUUID } from 'node:crypto';
import type { FastifyPluginAsync } from 'fastify';
import type { PoolClient } from 'pg';
import { z } from 'zod';
import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js';
const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80),
externalId: z.string().trim().min(1).max(500),
playbackUrl: z.string().url().nullable().optional(),
embedUrl: z.string().url().nullable().optional(),
status: z.enum(['processing', 'ready', 'failed']).default('ready'),
durationSeconds: z.number().int().nonnegative().nullable().optional(),
});
const assetSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(),
description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
});
const lessonSchema = z.object({
title: z.string().trim().min(1).max(255),
description: z.string().max(5000).default(''),
durationSeconds: z.number().int().nonnegative().nullable().optional(),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
media: z.array(mediaSchema).default([]),
assets: z.array(assetSchema).default([]),
});
const courseSchema = z.object({
title: z.string().trim().min(1).max(255),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120),
coverImageUrl: z.string().url().nullable().optional(),
status: z.enum(['draft', 'published']).default('draft'),
lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]),
});
const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>;
function slugify(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'course';
}
async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined, title: string, currentCourseId?: string) {
const base = requestedSlug ?? slugify(title);
let candidate = base;
while (true) {
const result = await client.query(
'select 1 from courses where slug = $1 and ($2::uuid is null or id <> $2::uuid)',
[candidate, currentCourseId ?? null],
);
if (!result.rowCount) return candidate;
candidate = `${base}-${randomUUID().slice(0, 8)}`;
}
}
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) {
await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel],
);
}
}
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
// This replacement strategy is safe before student progress exists. The next
// iteration will switch to per-lesson updates to preserve historical progress.
await client.query('delete from lessons where course_id = $1', [courseId]);
await client.query('delete from assets where course_id = $1', [courseId]);
await insertAssets(client, { courseId }, input.assets);
for (const [index, lesson] of input.lessons.entries()) {
const lessonResult = 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`,
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
);
const lessonId = lessonResult.rows[0].id;
for (const media of lesson.media) {
await client.query(
`insert into lesson_media
(lesson_id, provider, external_id, playback_url, embed_url, status, duration_seconds)
values ($1, $2, $3, $4, $5, $6, $7)`,
[lessonId, media.provider, media.externalId, media.playbackUrl ?? null, media.embedUrl ?? null, media.status, media.durationSeconds ?? null],
);
}
await insertAssets(client, { lessonId }, lesson.assets);
}
}
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0];
if (!course) return { error: 'Course not found', statusCode: 404 as const };
if (user.role !== 'admin' && course.instructor_id !== user.id) {
return { error: 'You can only manage your own courses', statusCode: 403 as const };
}
return null;
}
export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
app.get('/', manageAccess, async (request) => {
const filters = request.user.role === 'admin'
? { sql: "where c.status <> 'archived' order by c.created_at desc", values: [] as string[] }
: { sql: "where c.instructor_id = $1 and c.status <> 'archived' order by c.created_at desc", values: [request.user.id] };
const result = await pool.query(`${courseSelect()} ${filters.sql}`, filters.values);
return { data: result.rows };
});
app.post('/', manageAccess, async (request, reply) => {
const input = courseSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const slug = await uniqueSlug(client, input.slug, input.title);
const course = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, $6::course_status, $7, case when $6::course_status = 'published'::course_status then now() else null end)
returning id`,
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status, request.user.id],
);
await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit');
return reply.code(201).send({ data: { id: course.rows[0].id } });
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.patch('/:courseId', manageAccess, async (request, reply) => {
const { courseId } = paramsSchema.parse(request.params);
const input = courseSchema.parse(request.body);
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 slug = await uniqueSlug(client, input.slug, input.title, courseId);
await client.query(
`update courses
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6,
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
where id = $1`,
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status],
);
await replaceCourseContents(client, courseId, input);
await client.query('commit');
return { data: { id: courseId } };
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.delete('/:courseId', 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 });
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
return reply.code(204).send();
});
};

13
server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -1,33 +0,0 @@
import { GoogleGenAI } from "@google/genai";
export const generateStudentReply = async (studentComment: string, courseContext: string): Promise<string> => {
try {
const apiKey = process.env.API_KEY;
if (!apiKey) {
console.warn("EduClean: API Key is missing. AI features are disabled.");
return "Peço desculpas, mas não consigo gerar uma resposta no momento. Por favor, contate o professor diretamente.";
}
const ai = new GoogleGenAI({ apiKey });
const prompt = `
Você é um assistente de professor útil, encorajador e claro.
Contexto: O aluno está fazendo uma pergunta sobre o curso: "${courseContext}".
Pergunta do Aluno: "${studentComment}"
Tarefa: Escreva uma resposta concisa, didática e amigável (máximo de 2 frases) em Português do Brasil.
Responda diretamente ao ponto do aluno.
`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
});
return response.text || "";
} catch (error) {
console.error("Gemini API Error:", error);
return "Desculpe, não consegui gerar uma resposta automática agora. Por favor, tente novamente.";
}
};

69
services/api.ts Normal file
View File

@@ -0,0 +1,69 @@
const API_URL = import.meta.env.VITE_API_URL || '/api/v1';
const SESSION_KEY = 'compor_hub_session';
export type ApiRole = 'student' | 'instructor' | 'admin';
export interface ApiUser {
id: string;
email: string;
name: string;
role: ApiRole;
}
interface Session {
token: string;
user: ApiUser;
}
export class ApiError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
export function getSession(): Session | null {
const value = localStorage.getItem(SESSION_KEY);
if (!value) return null;
try {
return JSON.parse(value) as Session;
} catch {
localStorage.removeItem(SESSION_KEY);
return null;
}
}
export function saveSession(session: Session) {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
export function clearSession() {
localStorage.removeItem(SESSION_KEY);
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const session = getSession();
const headers = new Headers(options.headers);
if (options.body) headers.set('Content-Type', 'application/json');
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { ...options, headers });
if (response.status === 204) return undefined as T;
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(body.error || 'The request could not be completed', response.status);
}
return body as T;
}
export const authApi = {
async login(email: string, password: string) {
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
},
async register(name: string, email: string, password: string) {
return apiRequest<Session>('/auth/register', { method: 'POST', body: JSON.stringify({ name, email, password }) });
},
async me() {
return apiRequest<{ user: ApiUser }>('/auth/me');
},
};

View File

@@ -1,81 +1,166 @@
import localforage from 'localforage'; import localforage from 'localforage';
import { Course, Comment, Section } from '../types'; import { Attachment, AttachmentType, Comment, Course, Lesson } from '../types';
import { INITIAL_COURSES, generateMockSections } from '../constants'; import { apiRequest } from './api';
const COURSES_KEY = 'compor_hub_courses_v1';
const COMMENTS_KEY = 'compor_hub_comments_v1'; const COMMENTS_KEY = 'compor_hub_comments_v1';
const PROGRESS_KEY = 'compor_hub_progress_v1';
// Initialize DB with seed courses export const initDB = async (): Promise<void> => undefined;
export const initDB = async (): Promise<void> => {
const existingCourses = await localforage.getItem<Course[]>(COURSES_KEY); type ApiAsset = {
if (!existingCourses || existingCourses.length === 0) { id: string;
await localforage.setItem(COURSES_KEY, INITIAL_COURSES); name: string;
} kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
url: string;
description: string;
}; };
type ApiCourse = {
id: string;
title: string;
description: string;
category: string;
coverImageUrl: string | null;
instructor: { name: string };
lessons: Array<{
id: string;
title: string;
description: string;
durationSeconds: number | null;
accessLevel: 'public' | 'enrolled';
media: Array<{ provider: string; playbackUrl: string | null; embedUrl: string | null }>;
assets: ApiAsset[];
}>;
assets: ApiAsset[];
};
const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => {
if (kind === 'document') return 'doc';
if (kind === 'archive') return 'zip';
return kind;
};
const toAttachment = (asset: ApiAsset): Attachment => ({
id: asset.id,
name: asset.name,
type: toAttachmentType(asset.kind),
url: asset.url,
description: asset.description,
});
const toDuration = (seconds: number | null) => {
if (!seconds) return '—';
const minutes = Math.round(seconds / 60);
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
};
const toCourse = (course: ApiCourse): Course => ({
id: course.id,
title: course.title,
description: course.description,
category: course.category,
thumbnail: course.coverImageUrl || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
instructor: course.instructor.name,
duration: `${course.lessons.length} Aulas`,
lessons: course.lessons.map((lesson): Lesson => ({
id: lesson.id,
title: lesson.title,
description: lesson.description,
duration: toDuration(lesson.durationSeconds),
isFree: lesson.accessLevel === 'public',
videoUrl: lesson.media[0]?.playbackUrl || lesson.media[0]?.embedUrl || '',
mediaProvider: lesson.media[0]?.provider,
attachments: lesson.assets.map(toAttachment),
})),
attachments: course.assets.map(toAttachment),
});
const toSeconds = (duration: string) => {
const hours = Number(duration.match(/(\d+)h/)?.[1] || 0);
const minutes = Number(duration.match(/(\d+)m/)?.[1] || 0);
const seconds = hours * 3600 + minutes * 60;
return seconds || null;
};
const toAssetKind = (type: AttachmentType): ApiAsset['kind'] => {
if (type === 'doc' || type === 'pdf') return 'document';
if (type === 'zip') return 'archive';
if (type === 'video') return 'link';
return type;
};
const toAssetPayload = (attachment: Attachment) => ({
name: attachment.name,
kind: toAssetKind(attachment.type),
url: attachment.url,
description: attachment.description || '',
accessLevel: 'enrolled' as const,
});
const toCoursePayload = (course: Course) => ({
title: course.title,
description: course.description,
category: course.category,
coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null,
status: 'published' as const,
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({
title: lesson.title,
description: lesson.description || '',
durationSeconds: toSeconds(lesson.duration),
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
media: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }]
: [],
assets: (lesson.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
})),
});
export const getCourses = async (): Promise<Course[]> => { export const getCourses = async (): Promise<Course[]> => {
const courses = await localforage.getItem<Course[]>(COURSES_KEY); const response = await apiRequest<{ data: ApiCourse[] }>('/courses');
if (!courses || courses.length === 0) { return response.data.map(toCourse);
await initDB(); };
return INITIAL_COURSES;
} export const getManagedCourses = async (): Promise<Course[]> => {
return courses; const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
return response.data.map(toCourse);
}; };
export const getCourseById = async (id: string): Promise<Course | null> => { export const getCourseById = async (id: string): Promise<Course | null> => {
const courses = await getCourses(); try {
return courses.find(c => c.id === id) || null; const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`);
return toCourse(response.data);
} catch {
return null;
}
}; };
export const saveCourse = async (course: Course): Promise<void> => { export const saveCourse = async (course: Course): Promise<void> => {
const courses = await getCourses(); const payload = toCoursePayload(course);
const index = courses.findIndex(c => c.id === course.id); const isExisting = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(course.id);
if (index >= 0) { if (isExisting) {
courses[index] = course; await apiRequest(`/manage/courses/${course.id}`, { method: 'PATCH', body: JSON.stringify(payload) });
} else { } else {
courses.unshift(course); await apiRequest('/manage/courses', { method: 'POST', body: JSON.stringify(payload) });
} }
await localforage.setItem(COURSES_KEY, courses);
}; };
export const deleteCourse = async (id: string): Promise<void> => { export const deleteCourse = async (id: string): Promise<void> => {
const courses = await getCourses(); await apiRequest(`/manage/courses/${id}`, { method: 'DELETE' });
const filtered = courses.filter(c => c.id !== id);
await localforage.setItem(COURSES_KEY, filtered);
}; };
// Comments and learner progress move to PostgreSQL in the next milestone.
export const getComments = async (courseId: string): Promise<Comment[]> => { export const getComments = async (courseId: string): Promise<Comment[]> => {
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || []; const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
return allComments.filter(c => c.courseId === courseId); return allComments.filter((comment) => comment.courseId === courseId);
}; };
export const saveComment = async (comment: Comment): Promise<void> => { export const saveComment = async (comment: Comment): Promise<void> => {
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || []; const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
const index = allComments.findIndex(c => c.id === comment.id); const index = allComments.findIndex((item) => item.id === comment.id);
if (index >= 0) allComments[index] = comment;
if (index >= 0) { else allComments.unshift(comment);
allComments[index] = comment;
} else {
allComments.unshift(comment);
}
await localforage.setItem(COMMENTS_KEY, allComments); await localforage.setItem(COMMENTS_KEY, allComments);
}; };
export const saveProgress = async (courseId: string, progress: number): Promise<void> => { export const saveProgress = async (_courseId: string, _progress: number): Promise<void> => undefined;
const courses = await getCourses(); export const incrementViews = async (_courseId: string): Promise<void> => undefined;
const course = courses.find(c => c.id === courseId);
if (course) {
course.progress = progress;
await saveCourse(course);
}
};
export const incrementViews = async (courseId: string): Promise<void> => {
const courses = await getCourses();
const course = courses.find(c => c.id === courseId);
if (course) {
course.views = (course.views || 0) + 1;
await saveCourse(course);
}
};

View File

@@ -11,7 +11,8 @@
], ],
"skipLibCheck": true, "skipLibCheck": true,
"types": [ "types": [
"node" "node",
"vite/client"
], ],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"isolatedModules": true, "isolatedModules": true,
@@ -25,5 +26,8 @@
}, },
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noEmit": true "noEmit": true
} },
} "exclude": [
"server"
]
}

View File

@@ -18,6 +18,7 @@ export interface Lesson {
title: string; title: string;
duration: string; duration: string;
videoUrl: string; videoUrl: string;
mediaProvider?: string;
isFree: boolean; // true = Public, false = Pro (Login required) isFree: boolean; // true = Public, false = Pro (Login required)
description?: string; description?: string;
attachments?: Attachment[]; // Lesson-specific downloadable materials attachments?: Attachment[]; // Lesson-specific downloadable materials
@@ -63,4 +64,4 @@ export interface Comment {
date: string; date: string;
reply?: string; // Professor's reply reply?: string; // Professor's reply
replyDate?: string; replyDate?: string;
} }

View File

@@ -1,19 +1,17 @@
import path from 'path'; import path from 'path';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => { export default defineConfig(() => {
const env = loadEnv(mode, '.', '');
return { return {
server: { server: {
port: 3000, port: 3000,
host: '0.0.0.0', host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:3001',
},
}, },
plugins: [react()], plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),