Compare commits

..

47 Commits

Author SHA1 Message Date
Cauê Faleiros
95a4bd4ac7 fix: download bunny materials through academy
All checks were successful
CI / Validate frontend and API (push) Successful in 52s
CI / Build and publish Docker images (push) Successful in 24s
2026-09-09 16:20:49 -03:00
Cauê Faleiros
a861644f6c feat: add batch material uploads and links
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-09 14:24:54 -03:00
Cauê Faleiros
6981092589 feat: upload course materials to bunny storage
All checks were successful
CI / Validate frontend and API (push) Successful in 2m1s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-09 14:16:00 -03:00
Cauê Faleiros
61b7bba5af fix: make trail course selection explicit
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-08 16:37:18 -03:00
Cauê Faleiros
81a67bf123 fix: load trail courses from course manager
All checks were successful
CI / Validate frontend and API (push) Successful in 30s
CI / Build and publish Docker images (push) Successful in 18s
2026-09-08 16:27:00 -03:00
Cauê Faleiros
d9184372ca fix: show specific validation errors 2026-09-08 16:24:15 -03:00
Cauê Faleiros
61359a22a5 fix: allow editing empty draft trails
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-08 16:10:53 -03:00
Cauê Faleiros
bdaff3f5df fix: rename draft status to not published 2026-09-08 16:03:47 -03:00
Cauê Faleiros
f5aa826b32 feat: add editable ordered trail pages
All checks were successful
CI / Validate frontend and API (push) Successful in 36s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-08 15:53:20 -03:00
Cauê Faleiros
aec9c001d3 fix: simplify materials library header 2026-09-08 15:41:42 -03:00
Cauê Faleiros
10c3d03d9c feat: simplify materials library and use real metadata
All checks were successful
CI / Validate frontend and API (push) Successful in 34s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-08 15:22:45 -03:00
Cauê Faleiros
54f598cd99 feat: let students customize public profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 1m2s
CI / Build and publish Docker images (push) Successful in 23s
2026-09-08 14:46:16 -03:00
Cauê Faleiros
ad736758a7 fix: localize validation and api messages
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-08 13:40:38 -03:00
Cauê Faleiros
987eb210eb feat: standardize passwords at eight characters
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-08 13:31:32 -03:00
Cauê Faleiros
8b94a25807 feat: harden media upload validation 2026-09-08 13:05:16 -03:00
Cauê Faleiros
3ae9c03614 chore: pass smtp settings to api container
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 46s
2026-09-08 12:40:32 -03:00
Cauê Faleiros
ee8d365321 feat: send welcome emails through smtp 2026-09-08 12:17:41 -03:00
Cauê Faleiros
03a6491989 feat: improve account management and welcome emails 2026-09-08 11:47:33 -03:00
Cauê Faleiros
7b2d25aabb feat: add public instructor profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 54s
CI / Build and publish Docker images (push) Successful in 26s
2026-09-08 10:50:35 -03:00
Cauê Faleiros
07fbc7d009 feat: move account navigation into user menu 2026-09-08 10:31:21 -03:00
Cauê Faleiros
37ce8e8afb feat: filter cms courses by trail
All checks were successful
CI / Validate frontend and API (push) Successful in 53s
CI / Build and publish Docker images (push) Successful in 29s
2026-09-08 09:56:25 -03:00
Cauê Faleiros
815d3f93c1 feat: group cms courses by trail
All checks were successful
CI / Validate frontend and API (push) Successful in 2m3s
CI / Build and publish Docker images (push) Successful in 28s
2026-09-08 09:30:01 -03:00
Cauê Faleiros
a6f53ff0d3 fix: use existing course groups as trails 2026-09-04 18:15:40 -03:00
Cauê Faleiros
ae39fefa7d feat: manage categories with trails
All checks were successful
CI / Validate frontend and API (push) Successful in 36s
CI / Build and publish Docker images (push) Successful in 28s
2026-09-04 17:18:08 -03:00
Cauê Faleiros
c18e63f0e4 refactor: consolidate formations into trails
All checks were successful
CI / Validate frontend and API (push) Successful in 43s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-04 17:04:26 -03:00
Cauê Faleiros
895b0dd00e fix: recover failed banner cover previews
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-04 16:52:29 -03:00
Cauê Faleiros
5b86d6ef0d feat: manage homepage banner carousel
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-04 16:44:34 -03:00
Cauê Faleiros
09d85a7123 feat: separate trails and formations in cms
All checks were successful
CI / Validate frontend and API (push) Successful in 41s
CI / Build and publish Docker images (push) Successful in 25s
2026-09-04 16:32:54 -03:00
Cauê Faleiros
79d1c70c44 feat: add managed learning paths
All checks were successful
CI / Validate frontend and API (push) Successful in 41s
CI / Build and publish Docker images (push) Successful in 23s
2026-09-04 16:22:09 -03:00
Cauê Faleiros
e18bb76630 fix: show short video durations accurately
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 19s
2026-09-04 15:54:15 -03:00
Cauê Faleiros
ed00f60281 fix: fall back to Bunny Storage for cover images
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-04 15:26:06 -03:00
Cauê Faleiros
66671f65c3 feat: delete lesson videos from Bunny 2026-09-04 15:18:10 -03:00
Cauê Faleiros
1a79fa447b feat: derive course durations from video media
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-04 14:52:47 -03:00
Cauê Faleiros
7a01be2b59 feat: add course cover positioning
All checks were successful
CI / Validate frontend and API (push) Successful in 37s
CI / Build and publish Docker images (push) Successful in 18s
2026-09-04 14:42:04 -03:00
Cauê Faleiros
ad860074fd feat: add resilient cover previews and zoom
All checks were successful
CI / Validate frontend and API (push) Successful in 1m10s
CI / Build and publish Docker images (push) Successful in 50s
2026-09-04 14:29:07 -03:00
Cauê Faleiros
1d5eafbbd0 feat: simplify instructor video uploads
All checks were successful
CI / Validate frontend and API (push) Successful in 46s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-04 14:16:02 -03:00
Cauê Faleiros
b1bbf35751 feat: streamline course editor workflow 2026-09-04 13:53:35 -03:00
Cauê Faleiros
e15a44e733 feat: add Bunny Storage course cover uploads
All checks were successful
CI / Validate frontend and API (push) Successful in 44s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-04 13:39:30 -03:00
Cauê Faleiros
3498d47182 fix: make course cover control actionable 2026-09-04 11:52:07 -03:00
Cauê Faleiros
9b6fea4b81 feat: complete platform operations roadmap
All checks were successful
CI / Validate frontend and API (push) Successful in 1m58s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-04 10:07:07 -03:00
Cauê Faleiros
2c137529bc feat: integrate Bunny Stream course videos
All checks were successful
CI / Validate frontend and API (push) Successful in 1m50s
CI / Build and publish Docker images (push) Successful in 18s
2026-09-02 10:09:36 -03:00
Cauê Faleiros
3d4c72e85c feat: improve learning and platform operations
All checks were successful
CI / Validate frontend and API (push) Successful in 45s
CI / Build and publish Docker images (push) Successful in 24s
2026-09-01 15:38:14 -03:00
Cauê Faleiros
4736fb5208 feat: expand admin and instructor tools
All checks were successful
CI / Validate frontend and API (push) Successful in 50s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-01 13:19:29 -03:00
Cauê Faleiros
caaf904281 fix: probe API health over IPv4
All checks were successful
CI / Validate frontend and API (push) Successful in 28s
CI / Build and publish Docker images (push) Successful in 14s
2026-09-01 12:36:38 -03:00
Cauê Faleiros
8e28f0feb5 fix: wait for database during API startup
All checks were successful
CI / Validate frontend and API (push) Successful in 31s
CI / Build and publish Docker images (push) Successful in 16s
2026-09-01 12:05:40 -03:00
Cauê Faleiros
006281f062 fix: run API as container primary process
All checks were successful
CI / Validate frontend and API (push) Successful in 37s
CI / Build and publish Docker images (push) Successful in 15s
2026-09-01 11:45:38 -03:00
Cauê Faleiros
7c33b7a7d3 fix: defer API DNS resolution in nginx
All checks were successful
CI / Validate frontend and API (push) Successful in 31s
CI / Build and publish Docker images (push) Successful in 15s
2026-09-01 11:33:07 -03:00
68 changed files with 4247 additions and 1553 deletions

View File

@@ -14,8 +14,42 @@ JWT_SECRET=replace-this-with-a-long-random-secret-before-deploying
# Keep them out of Git and use a password with at least 12 characters. # Keep them out of Git and use a password with at least 12 characters.
SUPERADMIN_EMAIL=admin@example.com SUPERADMIN_EMAIL=admin@example.com
SUPERADMIN_PASSWORD=change-this-password SUPERADMIN_PASSWORD=change-this-password
AUTH_RATE_LIMIT_MAX=10
AUTH_RATE_LIMIT_WINDOW_SECONDS=900
JWT_SESSION_TTL=7d
INVITATION_TTL_HOURS=168
PASSWORD_RESET_TTL_HOURS=24
AUDIT_LOG_PAGE_SIZE=50
# Optional welcome e-mails sent when a student creates their own account.
# Configure all five values together. Port 465 uses SSL; port 587 uses STARTTLS.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM=Compor HUB <noreply@your-domain.com>
SUPERADMIN_NAME=Compor HUB Superadmin SUPERADMIN_NAME=Compor HUB Superadmin
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
# signed embedded playback. Keep every value server-side; none are VITE_ vars.
BUNNY_STREAM_LIBRARY_ID=
BUNNY_STREAM_API_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.
BUNNY_MAX_UPLOAD_MB=5120
# Bunny Storage course banners. Create a Storage Zone and linked Pull Zone;
# copy the Storage endpoint shown in Bunny's FTP & API Access panel.
BUNNY_STORAGE_ZONE=
BUNNY_STORAGE_PASSWORD=
BUNNY_STORAGE_ENDPOINT=
BUNNY_STORAGE_CDN_HOST=
BUNNY_COVER_MAX_UPLOAD_MB=10
BUNNY_ASSET_MAX_UPLOAD_MB=100
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images. # Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web

14
App.tsx
View File

@@ -10,6 +10,12 @@ import { AuthProvider, useAuth } from './context/AuthContext';
import { ToastProvider } from './context/ToastContext'; import { ToastProvider } from './context/ToastContext';
import { Course, UserRole } from './types'; import { Course, UserRole } from './types';
import { SuperAdmin } from './pages/SuperAdmin'; import { SuperAdmin } from './pages/SuperAdmin';
import { AccessTokenPage } from './pages/AccessTokenPage';
import { MyLearning } from './pages/MyLearning';
import { ProfilePage } from './pages/ProfilePage';
import { InstructorProfile } from './pages/InstructorProfile';
import { TrailDetail } from './pages/TrailDetail';
import { TrailPage } from './pages/TrailPage';
// Protected Route Component // Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => { const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
@@ -90,6 +96,14 @@ function AppContent() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route path="/admin/trilhas/:trailId" element={<ProtectedRoute allowedRoles={['superadmin']}><TrailDetail /></ProtectedRoute>} />
<Route path="/trilhas/:trailId" element={<TrailPage onPlay={setSelectedCourse} />} />
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
<Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/perfis/:profileId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/invite" element={<AccessTokenPage mode="invite" />} />
<Route path="/reset-password" element={<AccessTokenPage mode="reset" />} />
{/* Catch all redirect */} {/* Catch all redirect */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -16,19 +16,33 @@ The API health endpoint is available at `http://localhost:3001/api/v1/health` an
- `GET /api/v1/courses` returns published courses. - `GET /api/v1/courses` returns published courses.
- `GET /api/v1/courses/:courseId` returns one published course. - `GET /api/v1/courses/:courseId` returns one published course.
- `GET /api/v1/courses/me/learning` returns the authenticated learner's started courses, ordered by their last activity, with completion percentage.
- `GET /api/v1/courses/:courseId/progress` and `PUT /api/v1/lessons/:lessonId/progress` persist completion and watch position.
- `GET /api/v1/admin/audit-log` exposes the last 50 administrative actions to superadmins.
## Accounts and instructor access ## Accounts and instructor access
- `POST /api/v1/auth/register` creates student accounts only. - `POST /api/v1/auth/register` creates student accounts only.
- `POST /api/v1/auth/login` creates a seven-day signed session. - `POST /api/v1/auth/login` creates a seven-day signed session.
- `GET /api/v1/auth/me` restores an existing session. - `GET /api/v1/auth/me` restores an existing session.
- Public authentication endpoints are rate-limited per source IP. Configure `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` if the defaults (10 attempts / 15 minutes) do not fit your environment.
- `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses. - `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses.
Courses can be saved as a draft or published. Drafts are visible only in the managing instructor's dashboard and are never exposed by public course endpoints. Existing lessons keep their IDs when a course is edited, preserving learner progress and comment history; a lesson with progress or comments cannot be removed.
To create the first local administrator, set `SUPERADMIN_EMAIL`, `SUPERADMIN_PASSWORD`, and optionally `SUPERADMIN_NAME`, then run `npm run db:bootstrap-admin`. In Docker/Portainer, the API runs this command automatically after migrations. To create the first local administrator, set `SUPERADMIN_EMAIL`, `SUPERADMIN_PASSWORD`, and optionally `SUPERADMIN_NAME`, then run `npm run db:bootstrap-admin`. In Docker/Portainer, the API runs this command automatically after migrations.
For local demos, `npm run db:seed-demo-content` imports the original frontend catalogue into PostgreSQL. It requires the bootstrap administrator to exist and skips courses already present. For local demos, `npm run db:seed-demo-content` imports the original frontend catalogue into PostgreSQL. It requires the bootstrap administrator to exist and skips courses already present.
Public course endpoints return only public lessons and public assets. Enrolment checks, learner progress, and comments will be added in the next milestone. Anonymous visitors can browse course and lesson information, but media and download links are removed from their response. A signed-in account is required to play lessons, download materials, track progress, or participate in discussion.
## Provider boundaries
Videos remain provider-neutral through `lesson_media.provider` and `lesson_media.external_id`. The platform does not yet create signed playback URLs because that requires the chosen provider's credentials and API. Do not add a provider secret until Panda Video, Vimeo, or another provider has been selected. Invitations and password resets currently generate secure, expiring links for the superadmin to copy; email delivery will be connected once an email service is chosen.
## Administrative operations
The superadmin page supports user activation/role changes, invitations, password reset links, user learning details, CSV export, platform metrics, and an immutable-style activity log. Audit records cover invitations, reset links, user updates, course publishing/drafts/archive, and instructor comment moderation.
## CI/CD status ## CI/CD status

View File

@@ -10,4 +10,4 @@ COPY constants.ts types.ts ./
ENV APP_ENV=production ENV APP_ENV=production
EXPOSE 3001 EXPOSE 3001
CMD ["sh", "-c", "npm run db:migrate && npm run db:bootstrap-admin && npm run start:api"] CMD ["sh", "-c", "npm run db:migrate && npm run db:bootstrap-admin && exec ./node_modules/.bin/tsx server/src/index.ts"]

View File

@@ -2,6 +2,57 @@
Use `docker-compose.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. Use `docker-compose.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.
## Bunny Stream video hosting
To let instructors upload protected course videos, configure these API environment variables in the Portainer Stack. They are server secrets: never add a `VITE_` version or place them in the frontend container.
```env
BUNNY_STREAM_LIBRARY_ID=123456
BUNNY_STREAM_API_KEY=your-bunny-library-api-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
```
In Bunny Stream, create one Video Library using the Volume tier. In that library's security settings, enable MediaCage Basic DRM and Embed View Token Authentication, then copy its token key into `BUNNY_EMBED_TOKEN_KEY`. Disable Direct Play and MP4 fallback if they are not needed, and add the Academy production hostname to Bunny's allowed referrers. This keeps playback inside Bunny's protected iframe and makes the Academy API issue a short-lived embed token only after a logged-in user requests a lesson.
The deployed Nginx proxy permits uploads up to **5 GB**. Keep `BUNNY_MAX_UPLOAD_MB` at or below `5120`; reduce it if you want a smaller application-level limit. Large uploads stream through the API rather than being held in memory.
After deploying the new images:
1. Sign in as an instructor and open **Gerenciar Cursos**.
2. Enter the lesson title first, then choose **Selecionar vídeo** in the Bunny Stream section.
3. Wait for the upload message, add the lesson, and save the course. Bunny encoding continues asynchronously.
4. The editor refreshes Bunny processing status automatically; it also has an **Atualizar** action for a pending lesson.
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 Storage course banners
Course banners use Bunny Storage, not the Stream library. Create one Standard Storage Zone in São Paulo and link one Standard Pull Zone to it. The Pull Zone hostname is the public CDN host for uploaded banners. Add these server-only variables to Portainer:
```env
BUNNY_STORAGE_ZONE=compor-academy-storage
BUNNY_STORAGE_PASSWORD=the-storage-zone-password
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
BUNNY_COVER_MAX_UPLOAD_MB=10
BUNNY_ASSET_MAX_UPLOAD_MB=100
```
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload JPG, PNG, or WebP cover images and PDF, DOCX, XLSX, CSV, or ZIP support materials; the API validates the file bytes and determines the material type and size automatically. Covers are stored under `covers/` and course materials under `materials/`.
### 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
@@ -19,7 +70,9 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho
- `JWT_SECRET`: a unique random string of at least 32 characters. - `JWT_SECRET`: a unique random string of at least 32 characters.
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`. - `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator. - `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
- `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters). - `SUPERADMIN_PASSWORD`: password for that administrator (at least 8 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).
- `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:
@@ -38,4 +91,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.

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react'; import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react';
import { Course, SectionVariant } from '../types'; import { Course, SectionVariant } from '../types';
import { CourseCoverImage } from './CourseCoverImage';
import { useNavigate } from 'react-router-dom';
interface CourseCardProps { interface CourseCardProps {
course: Course; course: Course;
@@ -15,6 +17,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
onClick, onClick,
onOpenMaterials onOpenMaterials
}) => { }) => {
const navigate = useNavigate();
const widthClass = variant === 'landscape' const widthClass = variant === 'landscape'
? 'w-[320px] md:w-[420px]' ? 'w-[320px] md:w-[420px]'
: 'w-[200px] md:w-[260px]'; : 'w-[200px] md:w-[260px]';
@@ -37,13 +40,17 @@ export const CourseCard: React.FC<CourseCardProps> = ({
onClick={() => onClick?.(course)} onClick={() => onClick?.(course)}
className={`relative ${aspectRatioClass} w-full overflow-hidden rounded-[8px] bg-zinc-900 shadow-xl transition-all duration-300 ease-out group-hover:scale-[1.02] group-hover:shadow-2xl group-hover:shadow-orange-500/10 group-hover:z-10 ring-0 ring-white/0 group-hover:ring-2 group-hover:ring-orange-500/40`} className={`relative ${aspectRatioClass} w-full overflow-hidden rounded-[8px] bg-zinc-900 shadow-xl transition-all duration-300 ease-out group-hover:scale-[1.02] group-hover:shadow-2xl group-hover:shadow-orange-500/10 group-hover:z-10 ring-0 ring-white/0 group-hover:ring-2 group-hover:ring-orange-500/40`}
> >
<img <CourseCoverImage
src={course.thumbnail} source={course.thumbnail}
alt={course.title} alt={course.title}
loading="lazy" loading="lazy"
draggable={false} draggable={false}
onDragStart={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()}
className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none" className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none"
style={{
transform: `scale(${course.coverImageZoom || 1})`,
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
}}
/> />
{/* Badges on Thumbnail */} {/* Badges on Thumbnail */}
@@ -94,7 +101,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
{course.instructor && ( {course.instructor && (
<> <>
<span className="w-1 h-1 rounded-[980px] bg-gray-600"></span> <span className="w-1 h-1 rounded-[980px] bg-gray-600"></span>
<span className="truncate">{course.instructor}</span> {course.instructorId ? <button onClick={(event) => { event.stopPropagation(); navigate(`/instrutores/${course.instructorId}`); }} className="truncate text-left transition hover:text-orange-400 hover:underline">{course.instructor}</button> : <span className="truncate">{course.instructor}</span>}
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,39 @@
import React, { useEffect, useMemo, useState } from 'react';
type CourseCoverImageProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src' | 'onError'> & {
source: string;
onFinalError?: () => void;
};
const fallbackCoverUrl = (source: string) => {
try {
const parsed = new URL(source, window.location.origin);
const filename = parsed.pathname.split('/').pop() || '';
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i.test(filename)) return null;
const apiBase = import.meta.env.VITE_API_URL || '/api/v1';
return `${apiBase}/courses/covers/${filename}`;
} catch {
return null;
}
};
export const CourseCoverImage: React.FC<CourseCoverImageProps> = ({ source, onFinalError, ...props }) => {
const fallbackUrl = useMemo(() => fallbackCoverUrl(source), [source]);
const [usingFallback, setUsingFallback] = useState(false);
useEffect(() => setUsingFallback(false), [source]);
return (
<img
{...props}
src={usingFallback && fallbackUrl ? fallbackUrl : source}
onError={() => {
if (!usingFallback && fallbackUrl) {
setUsingFallback(true);
return;
}
onFinalError?.();
}}
/>
);
};

View File

@@ -1,9 +1,10 @@
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 { getCourses } from '../services/db'; import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react'; import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { LearningPathRow } from './LearningPathRow';
interface CourseGridProps { interface CourseGridProps {
onCourseSelect?: (course: Course) => void; onCourseSelect?: (course: Course) => void;
@@ -15,6 +16,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
selectedCategoryFilter selectedCategoryFilter
}) => { }) => {
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [paths, setPaths] = useState<PublicLearningPath[]>([]);
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(); const { user } = useAuth();
@@ -39,7 +41,9 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setCourses(await getCourses()); const [loadedCourses, loadedPaths] = await Promise.all([getCourses(), getLearningPaths()]);
setCourses(loadedCourses);
setPaths(loadedPaths);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -138,11 +142,6 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
variant: 'landscape', variant: 'landscape',
courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0)) courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0))
}, },
{
title: 'Formações Recomendadas',
variant: 'portrait',
courses: courses
}
]; ];
return ( return (
@@ -238,6 +237,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
/> />
) )
))} ))}
{activeCategory === 'all' && paths.length > 0 && <LearningPathRow paths={paths} courses={courses} onCourseSelect={onCourseSelect} />}
</section> </section>
); );
}; };

View File

@@ -19,9 +19,36 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Course, Comment, Lesson, Attachment } from '../types'; import { Course, Comment, Lesson, Attachment } from '../types';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { getComments, getCompletedLessonIds, saveComment, saveLessonCompletion, incrementViews } from '../services/db'; import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard'; import { MaterialCard } from './MaterialCard';
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;
@@ -34,21 +61,33 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null); const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]); const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]);
const [watchedSecondsByLesson, setWatchedSecondsByLesson] = useState<Record<string, number>>({});
const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist'); const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
const [copiedLink, setCopiedLink] = useState(false); const [copiedLink, setCopiedLink] = useState(false);
const [playback, setPlayback] = useState<LessonPlayback | null>(null);
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>>({});
// Load course data and this learner's saved state from PostgreSQL. // Load course data and this learner's saved state from PostgreSQL.
useEffect(() => { useEffect(() => {
if (course) { if (course) {
incrementViews(course.id); incrementViews(course.id);
setCompletedLessonIds([]); setCompletedLessonIds([]);
setWatchedSecondsByLesson({});
lastProgressSave.current = {};
if (user) { if (user) {
getComments(course.id).then(setComments).catch(() => setComments([])); getComments(course.id).then(setComments).catch(() => setComments([]));
getCompletedLessonIds(course.id).then((completed) => { getLessonProgress(course.id).then((progress) => {
const completed = progress.filter((item) => item.completedAt).map((item) => item.lessonId);
setCompletedLessonIds(completed); setCompletedLessonIds(completed);
setWatchedSecondsByLesson(Object.fromEntries(progress.map((item) => [item.lessonId, item.watchedSeconds])));
lastProgressSave.current = Object.fromEntries(progress.map((item) => [item.lessonId, item.watchedSeconds]));
const nextIncomplete = course.lessons.find((lesson) => !completed.includes(lesson.id));
if (nextIncomplete) setCurrentLesson(nextIncomplete);
}).catch(() => setCompletedLessonIds([])); }).catch(() => setCompletedLessonIds([]));
} else { } else {
setComments([]); setComments([]);
@@ -61,6 +100,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
} }
}, [course, user]); }, [course, user]);
// The API verifies the session before returning a time-limited Bunny embed
// URL. A video provider key is never sent to the browser.
useEffect(() => {
if (!course || !user || !currentLesson) {
setPlayback(null);
setPlaybackError('');
return;
}
let cancelled = false;
setPlayback(null);
setPlaybackError('');
courseApi.playback(course.id, currentLesson.id)
.then((response) => {
if (!cancelled) setPlayback(response.data);
})
.catch((error) => {
if (!cancelled) setPlaybackError(error instanceof Error ? error.message : 'Não foi possível carregar este vídeo.');
});
return () => { cancelled = true; };
}, [course, user, currentLesson]);
// Persist completed lessons and recalculate progress // Persist completed lessons and recalculate progress
const updateCompletedLessons = (newCompleted: string[]) => { const updateCompletedLessons = (newCompleted: string[]) => {
setCompletedLessonIds(newCompleted); setCompletedLessonIds(newCompleted);
@@ -78,17 +138,21 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
updateCompletedLessons(updated); updateCompletedLessons(updated);
saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds)); saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds));
} else { } else {
const updated = [...completedLessonIds, lessonId]; markLessonCompleted(lessonId);
updateCompletedLessons(updated);
saveLessonCompletion(lessonId, true).catch(() => updateCompletedLessons(completedLessonIds));
} }
}; };
const markLessonCompleted = (lessonId: string, watchedSeconds?: number) => {
if (!user || completedLessonIds.includes(lessonId)) return;
const previous = completedLessonIds;
updateCompletedLessons([...previous, lessonId]);
saveLessonCompletion(lessonId, true, watchedSeconds).catch(() => updateCompletedLessons(previous));
};
// Video finished automatically -> mark lesson as completed // Video finished automatically -> mark lesson as completed
const handleVideoEnded = () => { const handleVideoEnded = () => {
if (currentLesson && !completedLessonIds.includes(currentLesson.id)) { if (currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id]; markLessonCompleted(currentLesson.id, Math.round(videoRef.current?.duration || 0));
updateCompletedLessons(updated);
} }
// Auto-advance to next lesson if available // Auto-advance to next lesson if available
@@ -110,16 +174,52 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
if (videoRef.current) { if (videoRef.current) {
const { currentTime, duration } = videoRef.current; const { currentTime, duration } = videoRef.current;
if (duration > 0) { if (duration > 0) {
if (user && currentLesson && currentTime - (lastProgressSave.current[currentLesson.id] || 0) >= 30) {
const watchedSeconds = Math.floor(currentTime);
lastProgressSave.current[currentLesson.id] = watchedSeconds;
setWatchedSecondsByLesson((current) => ({ ...current, [currentLesson.id]: watchedSeconds }));
saveLessonProgress(currentLesson.id, watchedSeconds).catch(() => undefined);
}
const percent = Math.round((currentTime / duration) * 100); const percent = Math.round((currentTime / duration) * 100);
// Automatically mark as complete when reaching 95%+ // Automatically mark as complete when reaching 95%+
if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) { if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id]; markLessonCompleted(currentLesson.id, Math.round(currentTime));
updateCompletedLessons(updated);
} }
} }
} }
}; };
// 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 = () => {
if (!currentLesson || !videoRef.current) return;
const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0;
if (watchedSeconds > 0 && watchedSeconds < videoRef.current.duration - 5) videoRef.current.currentTime = watchedSeconds;
};
const handleAddComment = async (e: React.FormEvent) => { const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newComment.trim() || !course) return; if (!newComment.trim() || !course) return;
@@ -215,18 +315,39 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
{/* Video Player Section */} {/* Video Player Section */}
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group"> <div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
{canWatch ? ( {canWatch ? (
playback ? (
playback.kind === 'embed' && playback.embedUrl ? (
<iframe
key={currentLesson?.id}
ref={bunnyFrameRef}
src={playback.embedUrl}
title={currentLesson?.title || 'Vídeo da aula'}
className="w-full h-full border-0 rounded-[8px]"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
/>
) : playback.source ? (
<video <video
key={currentLesson?.id} key={currentLesson?.id}
ref={videoRef} ref={videoRef}
src={currentLesson?.videoUrl} src={playback.source}
className="w-full h-full object-contain rounded-[8px]" className="w-full h-full object-contain rounded-[8px]"
controls controls
autoPlay autoPlay
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded} onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
> >
Seu navegador não suporta a tag de vídeo. Seu navegador não suporta a tag de vídeo.
</video> </video>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-gray-400">Vídeo indisponível.</div>
)
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-6 text-sm text-gray-400">
{playbackError ? <span className="text-amber-300">{playbackError}</span> : <span>Carregando vídeo seguro...</span>}
</div>
)
) : ( ) : (
// Locked State // Locked State
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-10 text-center p-6 rounded-[8px]"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-10 text-center p-6 rounded-[8px]">

View File

@@ -1,81 +1,24 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Play, Plus, Sparkles } from 'lucide-react'; import { ChevronLeft, ChevronRight, Play, Sparkles } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses, getHomeBanners, PublicHomeBanner } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface HeroProps { interface HeroProps { onPlay?: (course: Course) => void; }
onPlay?: (course: Course) => void;
}
export const Hero: React.FC<HeroProps> = ({ onPlay }) => { export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
const [featuredCourse, setFeaturedCourse] = useState<Course | null>(null); const [banners, setBanners] = useState<PublicHomeBanner[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
useEffect(() => { const [index, setIndex] = useState(0);
getCourses().then((courses) => setFeaturedCourse(courses[0] || null)).catch(() => setFeaturedCourse(null)); useEffect(() => { Promise.all([getHomeBanners(), getCourses()]).then(([loadedBanners, loadedCourses]) => { setBanners(loadedBanners); setCourses(loadedCourses); }).catch(() => setBanners([])); }, []);
}, []); useEffect(() => { if (banners.length < 2) return; const timer = window.setInterval(() => setIndex((current) => (current + 1) % banners.length), 7000); return () => window.clearInterval(timer); }, [banners.length]);
const fallback = courses[0];
if (!featuredCourse) return null; const banner = banners[index];
const course = banner?.courseId ? courses.find((item) => item.id === banner.courseId) : fallback;
return ( if (!banner && !fallback) return null;
<div className="relative w-full min-h-[75vh] lg:min-h-[82vh] max-h-[800px] h-auto overflow-hidden group flex flex-col justify-end pt-28 pb-12 sm:pb-16 px-6 md:px-12"> const title = banner ? (banner.kind === 'course' ? banner.courseTitle || banner.title : banner.title) : fallback.title;
{/* Background Image with Cinematic Gradient Overlay */} const description = banner ? (banner.kind === 'course' ? banner.courseDescription || banner.description : banner.description) : fallback.description;
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105"> const image = banner ? (banner.kind === 'course' ? banner.courseImageUrl : banner.imageUrl) : fallback.thumbnail;
<img const canPlay = Boolean(course && (banner?.kind !== 'custom' || banner.courseId));
src={featuredCourse.thumbnail} return <div className="relative flex h-[75vh] max-h-[800px] min-h-[560px] w-full flex-col justify-end overflow-hidden px-6 pb-12 pt-28 md:px-12"><div className="absolute inset-0">{image && <CourseCoverImage source={image} alt={title} className="h-full w-full object-cover" style={{ transform: `scale(${banner?.coverImageZoom || course?.coverImageZoom || 1})`, objectPosition: `${banner?.coverImagePositionX ?? course?.coverImagePositionX ?? 50}% ${banner?.coverImagePositionY ?? course?.coverImagePositionY ?? 50}%` }} />}<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" /><div className="absolute inset-0 bg-gradient-to-r from-black/95 via-black/60 to-transparent" /></div><div className="relative z-10 mx-auto w-full max-w-[1440px]"><div className="max-w-3xl"><div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-white/90"><span className="flex items-center gap-1 rounded-full bg-orange-500 px-3 py-1"><Sparkles className="h-3 w-3" /> Destaque</span>{banner?.kind === 'course' && <span>{course?.category}</span>}</div><h1 className="font-display text-4xl font-extrabold leading-tight tracking-tight text-white md:text-6xl">{title}</h1>{description && <p className="mt-4 max-w-2xl text-base leading-relaxed text-gray-200 md:text-lg">{description}</p>}{canPlay && <button onClick={() => onPlay?.(course!)} className="mt-7 inline-flex items-center gap-3 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-black"><Play className="h-5 w-5 fill-current" /> Assistir curso</button>}</div></div>{banners.length > 1 && <div className="absolute bottom-7 right-6 z-20 flex items-center gap-3 md:right-12"><button onClick={() => setIndex((index - 1 + banners.length) % banners.length)} className="rounded-full bg-black/50 p-2 text-white"><ChevronLeft className="h-5 w-5" /></button><div className="flex gap-1.5">{banners.map((item, itemIndex) => <button key={item.id} onClick={() => setIndex(itemIndex)} className={`h-2 rounded-full transition-all ${itemIndex === index ? 'w-6 bg-orange-500' : 'w-2 bg-white/50'}`} aria-label={`Banner ${itemIndex + 1}`} />)}</div><button onClick={() => setIndex((index + 1) % banners.length)} className="rounded-full bg-black/50 p-2 text-white"><ChevronRight className="h-5 w-5" /></button></div>}</div>;
alt={featuredCourse.title}
className="w-full h-full object-cover object-center"
/>
{/* Gradients for text readability and seamless transition */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" />
<div className="absolute inset-0 bg-gradient-to-r from-black/95 via-black/65 to-transparent" />
</div>
{/* Content */}
<div className="relative z-10 max-w-[1440px] w-full mx-auto">
<div className="max-w-3xl lg:max-w-4xl animate-fade-in-up flex flex-col items-start">
{/* Metadata Badges / Tags */}
<div className="flex items-center gap-2.5 text-xs font-semibold uppercase tracking-wider text-white/90 mb-3.5">
<span className="bg-orange-500 text-white px-3.5 py-1 rounded-[980px] shadow-xl flex items-center gap-1">
<Sparkles className="w-3 h-3 fill-current" />
Destaque
</span>
<span className="text-gray-300">{featuredCourse.category}</span>
<span className="text-gray-500">•</span>
<span className="text-gray-300">{featuredCourse.duration}</span>
</div>
{/* Main Display Headline */}
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight drop-shadow-2xl mb-4">
{featuredCourse.title}
</h1>
{/* Subhead / Lead Description */}
<p className="text-sm sm:text-base md:text-lg font-normal text-gray-200 line-clamp-2 sm:line-clamp-3 max-w-2xl leading-relaxed opacity-90 mb-6 sm:mb-8">
{featuredCourse.description}
</p>
{/* Action Buttons with comfortable width, borders, and spacing */}
<div className="flex flex-wrap items-center gap-4">
{/* Primary CTA */}
<button
onClick={() => onPlay?.(featuredCourse)}
className="flex items-center justify-center gap-3 bg-white text-black px-8 sm:px-10 py-3.5 sm:py-4 rounded-[980px] text-sm sm:text-base font-bold hover:bg-gray-100 active:scale-95 transition-all duration-200 shadow-xl min-w-[200px]"
>
<Play className="w-4 h-4 sm:w-5 sm:h-5 fill-current" />
<span>Assistir Aula 1</span>
</button>
{/* Add to List */}
<button
className="flex items-center justify-center w-12 h-12 sm:w-14 sm:h-14 rounded-[980px] bg-white/10 hover:bg-white/20 border border-white/20 backdrop-blur-xl text-white transition-all duration-200 active:scale-95 group shadow-xl shrink-0"
title="Adicionar aos Favoritos"
>
<Plus className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
</div>
</div>
</div>
</div>
);
}; };

View File

@@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { ArrowRight, Check, Layers3, X } from 'lucide-react';
import { Course } from '../types';
import { PublicLearningPath } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface LearningPathRowProps {
paths: PublicLearningPath[];
courses: Course[];
onCourseSelect?: (course: Course) => void;
}
export const LearningPathRow: React.FC<LearningPathRowProps> = ({ paths, courses, onCourseSelect }) => {
const [selectedPath, setSelectedPath] = useState<PublicLearningPath | null>(null);
const courseById = new Map(courses.map((course) => [course.id, course]));
return <>
<section className="mx-auto w-full max-w-[1440px] px-6 py-6 md:px-12">
<div className="mb-4 flex items-baseline gap-3">
<h2 className="font-display text-xl font-bold text-gray-100 md:text-2xl">Trilhas</h2>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">{paths.length} {paths.length === 1 ? 'trilha' : 'trilhas'}</span>
</div>
<div className="flex gap-5 overflow-x-auto pb-4 hide-scrollbar snap-x snap-mandatory">
{paths.map((path) => <button key={path.id} onClick={() => setSelectedPath(path)} className="group relative w-[300px] shrink-0 snap-start overflow-hidden rounded-xl border border-white/10 bg-zinc-950 text-left shadow-xl transition hover:-translate-y-1 hover:border-orange-500/60">
<div className="relative aspect-[16/8] overflow-hidden bg-zinc-900">
{path.coverImageUrl ? <CourseCoverImage source={path.coverImageUrl} alt={path.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" style={{ transform: `scale(${path.coverImageZoom || 1})`, objectPosition: `${path.coverImagePositionX ?? 50}% ${path.coverImagePositionY ?? 50}%` }} /> : <div className="flex h-full items-center justify-center bg-gradient-to-br from-orange-500/30 to-zinc-900"><Layers3 className="h-9 w-9 text-orange-300" /></div>}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
<span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'}</span>
</div>
<div className="p-4"><h3 className="truncate font-semibold text-white group-hover:text-orange-300">{path.title}</h3><p className="mt-1 line-clamp-2 min-h-10 text-sm leading-relaxed text-gray-500">{path.description || 'Uma trilha organizada para avançar passo a passo.'}</p><span className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-orange-400">Ver trilha <ArrowRight className="h-4 w-4" /></span></div>
</button>)}
</div>
</section>
{selectedPath && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={selectedPath.title}>
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-6"><div><div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400"><Layers3 className="h-4 w-4" /> Formação</div><h2 className="mt-2 text-2xl font-bold text-white">{selectedPath.title}</h2><p className="mt-2 text-sm leading-relaxed text-gray-400">{selectedPath.description}</p></div><button onClick={() => setSelectedPath(null)} className="rounded-full p-2 text-gray-400 hover:bg-white/10 hover:text-white" aria-label="Fechar"><X className="h-5 w-5" /></button></div><ol className="space-y-2 p-5">{selectedPath.courses.map((pathCourse, index) => { const course = courseById.get(pathCourse.id); return <li key={pathCourse.id}><button onClick={() => { if (course) { setSelectedPath(null); onCourseSelect?.(course); } }} disabled={!course} className="flex w-full items-center gap-4 rounded-xl bg-white/[0.03] px-4 py-3 text-left transition hover:bg-white/[0.07] disabled:cursor-default"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-500/15 text-xs font-bold text-orange-400">{index + 1}</span><span className="min-w-0 flex-1"><span className="block truncate font-semibold text-white">{pathCourse.title}</span><span className="mt-0.5 block text-xs text-gray-500">{pathCourse.category}</span></span>{course && <span className="text-xs font-semibold text-orange-400">Abrir curso</span>}</button></li>; })}</ol><div className="border-t border-white/10 p-4 text-center text-xs text-gray-500"><Check className="mr-1 inline h-3.5 w-3.5 text-emerald-400" /> Siga os cursos nesta ordem.</div></div>
</div>}
</>;
};

View File

@@ -25,6 +25,18 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
if (!email.trim() || !/^\S+@\S+\.\S+$/.test(email)) {
setError('Informe um e-mail válido.');
return;
}
if (isRegistering && name.trim().length < 2) {
setError('Informe seu nome.');
return;
}
if (password.length < 8) {
setError('A senha deve ter pelo menos 8 caracteres.');
return;
}
setIsSubmitting(true); setIsSubmitting(true);
if (isRegistering && role === 'professor') { if (isRegistering && role === 'professor') {
@@ -40,7 +52,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
if (!signedInUser) { if (!signedInUser) {
setError(isRegistering setError(isRegistering
? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 12 caracteres.' ? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 8 caracteres.'
: 'Credenciais inválidas. Verifique seu e-mail e senha.'); : 'Credenciais inválidas. Verifique seu e-mail e senha.');
return; return;
} }
@@ -83,11 +95,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5"> <form noValidate onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3"> <div className="space-y-3">
{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 />} {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" autoFocus />}
<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} /> <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" autoFocus={!isRegistering} />
<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 /> <input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(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" />
</div> </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>} {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>}

View File

@@ -13,6 +13,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Attachment, AttachmentType } from '../types'; import { Attachment, AttachmentType } from '../types';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { downloadCourseAsset } from '../services/api';
interface MaterialCardProps { interface MaterialCardProps {
attachment: Attachment; attachment: Attachment;
@@ -67,32 +68,29 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
const { showDownloadToast, showToast } = useToast(); const { showDownloadToast, showToast } = useToast();
const badge = getAttachmentBadge(attachment.type); const badge = getAttachmentBadge(attachment.type);
const handleAction = (e: React.MouseEvent) => { const handleAction = async () => {
setDownloaded(true);
if (attachment.type === 'link') { if (attachment.type === 'link') {
showToast(`Acessando link "${attachment.name}"`, 'info'); showToast(`Acessando link "${attachment.name}"`, 'info');
} else { window.open(attachment.url, '_blank', 'noopener,noreferrer');
showDownloadToast(attachment.name, attachment.size); return;
} }
if (onDownload) { try {
onDownload(attachment); await downloadCourseAsset(attachment.id, attachment.name);
} setDownloaded(true);
// If it has a real URL that is not '#', let the default anchor do its work showDownloadToast(attachment.name, attachment.size);
if (!attachment.url || attachment.url === '#') { onDownload?.(attachment);
e.preventDefault(); window.setTimeout(() => setDownloaded(false), 3000);
// Trigger a synthetic download notification reset } catch (error) {
setTimeout(() => setDownloaded(false), 3000); showToast(error instanceof Error ? error.message : 'Não foi possível baixar este material.', 'error');
} }
}; };
if (compact) { if (compact) {
return ( return (
<a <button
href={attachment.url || '#'} type="button"
target={attachment.type === 'link' ? '_blank' : '_self'} onClick={() => void handleAction()}
rel="noreferrer"
onClick={handleAction}
className="flex items-center justify-between p-3.5 rounded-xl bg-zinc-900/90 hover:bg-zinc-800/90 transition-all duration-200 group shadow-sm" className="flex items-center justify-between p-3.5 rounded-xl bg-zinc-900/90 hover:bg-zinc-800/90 transition-all duration-200 group shadow-sm"
> >
<div className="flex items-center gap-3.5 min-w-0 pr-3"> <div className="flex items-center gap-3.5 min-w-0 pr-3">
@@ -119,8 +117,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
Baixado Baixado
</span> </span>
) : ( ) : (
<button <span
type="button"
className="flex items-center gap-1.5 text-xs font-semibold bg-white/10 hover:bg-orange-500 hover:text-white text-gray-200 px-3.5 py-1.5 rounded-lg transition-all active:scale-95" className="flex items-center gap-1.5 text-xs font-semibold bg-white/10 hover:bg-orange-500 hover:text-white text-gray-200 px-3.5 py-1.5 rounded-lg transition-all active:scale-95"
> >
{attachment.type === 'link' ? ( {attachment.type === 'link' ? (
@@ -134,10 +131,10 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
Baixar Baixar
</> </>
)} )}
</button> </span>
)} )}
</div> </div>
</a> </button>
); );
} }
@@ -169,11 +166,9 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
{attachment.size || 'Disponível'} {attachment.size || 'Disponível'}
</span> </span>
<a <button
href={attachment.url || '#'} type="button"
target={attachment.type === 'link' ? '_blank' : '_self'} onClick={() => void handleAction()}
rel="noreferrer"
onClick={handleAction}
className="inline-flex items-center gap-2 text-xs font-bold text-black bg-white hover:bg-orange-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 active:scale-95 shadow-md" className="inline-flex items-center gap-2 text-xs font-bold text-black bg-white hover:bg-orange-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 active:scale-95 shadow-md"
> >
{attachment.type === 'link' ? ( {attachment.type === 'link' ? (
@@ -187,7 +182,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
Baixar Arquivo Baixar Arquivo
</> </>
)} )}
</a> </button>
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { User as UserIcon, LogOut, Search, Folder } from 'lucide-react'; import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck, ChevronDown, ShieldCheck, Settings, UserRound } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { SearchModal } from './SearchModal'; import { SearchModal } from './SearchModal';
@@ -15,6 +15,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false); const [showSearchModal, setShowSearchModal] = useState(false);
const [showAccountMenu, setShowAccountMenu] = useState(false);
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -34,6 +35,10 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
const isMaterialsPage = location.pathname === '/materiais'; const isMaterialsPage = location.pathname === '/materiais';
const isHomePage = location.pathname === '/'; const isHomePage = location.pathname === '/';
const goTo = (path: string) => {
setShowAccountMenu(false);
navigate(path);
};
return ( return (
<> <>
@@ -100,36 +105,22 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</button> </button>
) : ( ) : (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.role === 'superadmin' ? ( <div className="relative">
<button <button onClick={() => setShowAccountMenu((current) => !current)} className="flex items-center gap-2 rounded-[980px] px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors" aria-expanded={showAccountMenu} aria-haspopup="menu">
onClick={() => navigate('/admin')} <span className="hidden md:block">Olá, <strong className="font-semibold text-white">{user.name.split(' ')[0]}</strong></span>
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${ <ChevronDown className={`h-4 w-4 transition-transform ${showAccountMenu ? 'rotate-180' : ''}`} />
location.pathname === '/admin'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10'
}`}
>
Painel Administrativo
</button> </button>
) : user.role === 'professor' && ( {showAccountMenu && <div role="menu" className="absolute right-0 top-full mt-2 w-56 overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-1.5 shadow-2xl shadow-black/60">
<button <button role="menuitem" onClick={() => goTo('/perfil')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><UserRound className="h-4 w-4 text-orange-400" /> Meu perfil</button>
onClick={() => navigate('/gerenciar')} {user.role === 'student' && <button role="menuitem" onClick={() => goTo('/meu-aprendizado')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><BookOpenCheck className="h-4 w-4 text-orange-400" /> Meu aprendizado</button>}
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${ {user.role === 'professor' && <button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Painel do instrutor</button>}
location.pathname === '/gerenciar' {user.role === 'superadmin' && <><button role="menuitem" onClick={() => goTo('/admin')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><ShieldCheck className="h-4 w-4 text-orange-400" /> Painel administrativo</button><button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Gerenciar cursos</button></>}
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' </div>}
: 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10' </div>
}`}
>
Painel do Instrutor
</button>
)}
<div className="h-4 w-[1px] bg-white/20 hidden sm:block"></div> <div className="h-4 w-[1px] bg-white/20 hidden sm:block"></div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="text-sm text-gray-300 hidden md:block">
Olá, <strong className="text-white font-semibold">{user.name.split(' ')[0]}</strong>
</span>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-2 bg-red-500/10 hover:bg-red-500/20 active:scale-95 transition-all duration-300 px-4 py-2 rounded-[980px] border border-red-500/20 group text-xs font-semibold text-red-400" className="flex items-center gap-2 bg-red-500/10 hover:bg-red-500/20 active:scale-95 transition-all duration-300 px-4 py-2 rounded-[980px] border border-red-500/20 group text-xs font-semibold text-red-400"
@@ -150,4 +141,3 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</> </>
); );
}; };

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react'; import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -132,10 +133,14 @@ export const SearchModal: React.FC<SearchModalProps> = ({ isOpen, onClose, onPla
className="cursor-pointer group bg-zinc-900/60 rounded-[8px] p-3 border border-white/5 hover:border-orange-500/40 transition-all duration-300 shadow-xl" className="cursor-pointer group bg-zinc-900/60 rounded-[8px] p-3 border border-white/5 hover:border-orange-500/40 transition-all duration-300 shadow-xl"
> >
<div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative"> <div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative">
<img <CourseCoverImage
src={course.thumbnail} source={course.thumbnail}
alt={course.title} alt={course.title}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]" className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]"
style={{
transform: `scale(${course.coverImageZoom || 1})`,
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
}}
/> />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40"> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40">
<PlayCircle className="w-10 h-10 text-orange-500" /> <PlayCircle className="w-10 h-10 text-orange-500" />

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useState } from 'react';
import { Copy, Download, KeyRound, Loader2, Pencil, Plus, Trash2, UserCheck, UserX, X } from 'lucide-react';
import { ManagedUser } from '../services/api';
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white outline-none focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15';
type Props = {
users: ManagedUser[];
isLoading: boolean;
query: string;
setQuery: (value: string) => void;
accessLink: string;
inviteEmail: string;
setInviteEmail: (value: string) => void;
updatingUserId: string | null;
currentUserId?: string;
onInvite: (event: React.FormEvent) => void;
onUpdateUser: (account: ManagedUser, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) => void;
onDeleteUser: (account: ManagedUser) => void;
onResetPassword: (account: ManagedUser) => void;
onExport: () => void;
};
export const UserManagementPanel: React.FC<Props> = ({ users, isLoading, query, setQuery, accessLink, inviteEmail, setInviteEmail, updatingUserId, currentUserId, onInvite, onUpdateUser, onDeleteUser, onResetPassword, onExport }) => {
const [editing, setEditing] = useState<ManagedUser | null>(null);
const [draft, setDraft] = useState({ name: '', email: '', role: 'student' as ManagedUser['role'], isActive: true });
const isSelf = editing?.id === currentUserId;
useEffect(() => {
if (editing) setDraft({ name: editing.name, email: editing.email, role: editing.role, isActive: editing.isActive });
}, [editing]);
const save = (event: React.FormEvent) => {
event.preventDefault();
if (!editing) return;
onUpdateUser(editing, isSelf ? { name: draft.name, email: draft.email } : draft);
setEditing(null);
};
return <>
<section className="mb-6 overflow-hidden rounded-2xl border border-orange-500/20 bg-gradient-to-r from-orange-500/10 via-zinc-950/80 to-zinc-950/80 p-5 sm:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Acesso de instrutores</p><h2 className="mt-2 text-xl font-bold text-white">Convidar instrutor</h2><p className="mt-1 max-w-xl text-sm text-gray-400">Alunos criam a própria conta na tela de entrada — sem aprovação manual — e recebem a mensagem de boas-vindas quando o e-mail estiver configurado.</p></div><form onSubmit={onInvite} className="flex w-full max-w-xl gap-2"><input required type="email" value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="E-mail do instrutor" className={inputClass} /><button className="inline-flex shrink-0 items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-600"><Plus className="h-4 w-4" /> Gerar convite</button></form></div>
{accessLink && <div className="mt-5 flex flex-col gap-2 rounded-xl border border-white/10 bg-black/25 p-3 sm:flex-row"><input readOnly value={accessLink} className="min-w-0 flex-1 bg-transparent px-1 text-xs text-gray-300 outline-none" /><button type="button" onClick={() => void navigator.clipboard.writeText(accessLink)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-white/10 px-3 py-2 text-xs font-semibold text-white hover:bg-white/15"><Copy className="h-3.5 w-3.5" /> Copiar link</button></div>}
</section>
<section className="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80"><div className="flex flex-col gap-4 border-b border-white/10 p-5 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="text-lg font-bold text-white">Pessoas e acessos</h2><p className="mt-1 text-sm text-gray-500">Edite dados, função e acesso sem precisar sair do painel.</p></div><div className="flex gap-2"><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar por nome ou e-mail" className="w-full min-w-0 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white sm:w-72" /><button onClick={onExport} className="rounded-xl bg-white/10 px-3 text-sm text-white hover:bg-white/15" title="Exportar CSV"><Download className="h-4 w-4" /></button></div></div>
{isLoading ? <div className="flex h-56 items-center justify-center"><Loader2 className="h-7 w-7 animate-spin text-orange-400" /></div> : <div className="overflow-x-auto"><table className="w-full min-w-[850px] text-left text-sm"><thead className="bg-white/[0.03] text-xs uppercase tracking-wider text-gray-500"><tr><th className="px-5 py-4">Pessoa</th><th className="px-5 py-4">Acesso</th><th className="px-5 py-4">Status</th><th className="px-5 py-4">Cadastro</th><th className="px-5 py-4 text-right">Ações</th></tr></thead><tbody className="divide-y divide-white/5">{users.map((account) => { const busy = updatingUserId === account.id; const current = account.id === currentUserId; return <tr key={account.id} className="text-gray-300"><td className="px-5 py-4"><p className="font-semibold text-white">{account.name}</p><p className="mt-1 text-xs text-gray-500">{account.email}</p></td><td className="px-5 py-4"><span className="rounded-full bg-white/5 px-2.5 py-1 text-xs font-semibold text-gray-300">{roleLabel[account.role]}</span></td><td className="px-5 py-4"><span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${account.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-300'}`}>{account.isActive ? <UserCheck className="h-3.5 w-3.5" /> : <UserX className="h-3.5 w-3.5" />}{account.isActive ? 'Ativo' : 'Desativado'}</span></td><td className="px-5 py-4 text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}</td><td className="px-5 py-4"><div className="flex justify-end gap-2"><button disabled={busy} onClick={() => setEditing(account)} className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10 disabled:opacity-50"><Pencil className="h-3.5 w-3.5" /> Editar</button><button disabled={busy || current} onClick={() => onResetPassword(account)} className="rounded-lg border border-white/10 p-2 text-orange-300 hover:bg-orange-500/10 disabled:opacity-40" title="Gerar redefinição de senha"><KeyRound className="h-4 w-4" /></button><button disabled={busy || current} onClick={() => onDeleteUser(account)} className="rounded-lg border border-red-500/20 p-2 text-red-400 hover:bg-red-500/10 disabled:opacity-40" title={current ? 'Você não pode excluir a própria conta' : 'Excluir usuário'}><Trash2 className="h-4 w-4" /></button></div></td></tr>; })}</tbody></table></div>}
</section>
{editing && <div className="fixed inset-0 z-[110] flex items-center justify-center p-4"><button type="button" className="absolute inset-0 bg-black/75 backdrop-blur-sm" onClick={() => setEditing(null)} aria-label="Fechar" /><form onSubmit={save} className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-5"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Editar acesso</p><h2 className="mt-1 text-xl font-bold text-white">{editing.name}</h2></div><button type="button" onClick={() => setEditing(null)} className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white"><X className="h-5 w-5" /></button></div><div className="space-y-4 p-5"><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">Nome</span><input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} className={inputClass} required /></label><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">E-mail</span><input type="email" value={draft.email} onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))} className={inputClass} required /></label>{!isSelf && <><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">Função</span><select value={draft.role} onChange={(event) => setDraft((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))} className={inputClass}><option value="student">Aluno</option><option value="instructor">Instrutor</option><option value="admin">Superadmin</option></select></label><label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={draft.isActive} onChange={(event) => setDraft((current) => ({ ...current, isActive: event.target.checked }))} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Conta ativa</span><span className="mt-1 block text-xs text-gray-500">Desative para bloquear o login sem apagar os dados.</span></span></label></>}</div><div className="flex justify-end gap-3 border-t border-white/10 p-5"><button type="button" onClick={() => setEditing(null)} className="rounded-xl px-4 py-2.5 text-sm font-semibold text-gray-300 hover:bg-white/10">Cancelar</button><button className="rounded-xl bg-orange-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-orange-600">Salvar alterações</button></div></form></div>}
</>;
};

View File

@@ -1,12 +1,13 @@
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'; import { ApiUser, authApi, clearSession, getSession, saveSession } from '../services/api';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
login: (email: string, password: string) => Promise<User | null>; login: (email: string, password: string) => Promise<User | null>;
register: (name: string, email: string, password: string) => Promise<User | null>; register: (name: string, email: string, password: string) => Promise<User | null>;
logout: () => void; logout: () => void;
updateCurrentUser: (user: ApiUser) => void;
isLoading: boolean; isLoading: boolean;
} }
@@ -67,8 +68,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
clearSession(); clearSession();
}; };
const updateCurrentUser = (apiUser: ApiUser) => {
const session = getSession();
if (session) saveSession({ ...session, user: apiUser });
setUser(toFrontendUser(apiUser));
};
return ( return (
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}> <AuthContext.Provider value={{ user, login, register, logout, updateCurrentUser, isLoading }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -28,8 +28,31 @@ 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}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
MAIL_FROM: ${MAIL_FROM:-}
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_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_STORAGE_ZONE: ${BUNNY_STORAGE_ZONE:-}
BUNNY_STORAGE_PASSWORD: ${BUNNY_STORAGE_PASSWORD:-}
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100}
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost: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"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@@ -3,10 +3,24 @@ server {
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Bunny uploads pass through this reverse proxy. Keep the same 5 GB ceiling
# as the API default and stream bodies instead of buffering them on disk.
client_max_body_size 5g;
# Docker Swarm may start this container before the API service receives a DNS
# record. Resolving through Docker DNS at request time keeps Nginx alive while
# the API starts or is replaced during a rolling deployment.
resolver 127.0.0.11 ipv6=off valid=10s;
resolver_timeout 5s;
set $api_upstream api;
location /api/ { location /api/ {
proxy_pass http://api:3001; proxy_pass http://$api_upstream:3001;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -68,6 +68,21 @@ h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
user-select: none; user-select: none;
} }
/* Shared native controls used inside focused admin dialogs. */
.input {
width: 100%;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 0.75rem;
background: #18181b;
padding: 0.625rem 0.75rem;
color: #fff;
font-size: 0.875rem;
}
.input::placeholder {
color: #71717a;
}
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;

View File

@@ -2,6 +2,23 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
// Covers uploaded to Bunny Storage are normally served directly by its CDN.
// If that CDN URL is unavailable in a browser, retry the safe Academy proxy.
// This also protects previews in administrative screens that render native img
// elements (for example a homepage-banner preview).
document.addEventListener('error', (event) => {
const image = event.target;
if (!(image instanceof HTMLImageElement) || image.dataset.coverFallbackApplied === 'true') return;
try {
const filename = new URL(image.currentSrc || image.src, window.location.origin).pathname.split('/').pop() || '';
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i.test(filename)) return;
image.dataset.coverFallbackApplied = 'true';
image.src = `${import.meta.env.VITE_API_URL || '/api/v1'}/courses/covers/${filename}`;
} catch {
// Keep the browser's normal failed-image behavior for non-cover assets.
}
}, true);
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error("Could not find root element to mount to"); throw new Error("Could not find root element to mount to");

21
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"fastify": "^5.3.2", "fastify": "^5.3.2",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"lucide-react": "0.344.0", "lucide-react": "0.344.0",
"nodemailer": "^10.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
@@ -22,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"tsx": "^4.19.4", "tsx": "^4.19.4",
@@ -1475,6 +1477,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -2231,6 +2243,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz",
"integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/obliterator": { "node_modules/obliterator": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",

View File

@@ -23,6 +23,7 @@
"fastify": "^5.3.2", "fastify": "^5.3.2",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"lucide-react": "0.344.0", "lucide-react": "0.344.0",
"nodemailer": "^10.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
@@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"tsx": "^4.19.4", "tsx": "^4.19.4",

27
pages/AccessTokenPage.tsx Normal file
View File

@@ -0,0 +1,27 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { authApi, saveSession } from '../services/api';
export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }) => {
const location = useLocation();
const navigate = useNavigate();
const token = new URLSearchParams(location.search).get('token') || '';
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault(); setError('');
if (mode === 'invite' && name.trim().length < 2) { setError('Informe seu nome.'); return; }
if (password.length < 8) { setError('A senha deve ter pelo menos 8 caracteres.'); return; }
setSaving(true);
try {
if (mode === 'invite') {
const session = await authApi.acceptInvitation(token, name, password);
saveSession(session); navigate(session.user.role === 'admin' ? '/admin' : session.user.role === 'instructor' ? '/gerenciar' : '/');
} else { await authApi.resetPassword(token, password); navigate('/'); }
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
finally { setSaving(false); }
};
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form noValidate onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
};

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Course } from '../types';
import { getPublicProfile, InstructorProfileData } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { CourseCard } from '../components/CourseCard';
const networks = [
{ key: 'websiteUrl', label: 'Site', icon: Globe2 },
{ key: 'linkedinUrl', label: 'LinkedIn', icon: Linkedin },
{ key: 'instagramUrl', label: 'Instagram', icon: Instagram },
{ key: 'youtubeUrl', label: 'YouTube', icon: Youtube },
] as const;
export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const { instructorId, profileId } = useParams<{ instructorId?: string; profileId?: string }>();
const publicProfileId = instructorId || profileId;
const [profile, setProfile] = useState<InstructorProfileData | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading');
useEffect(() => {
if (!publicProfileId) return;
setState('loading');
getPublicProfile(publicProfileId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing'));
}, [publicProfileId]);
if (state === 'loading') return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
if (state === 'missing' || !profile) return <main className="mx-auto flex min-h-[65vh] max-w-5xl items-center px-6 pt-24"><div><p className="text-sm font-semibold uppercase tracking-widest text-orange-400">Perfil indisponível</p><h1 className="mt-3 text-3xl font-bold">Este perfil não está público.</h1></div></main>;
const isInstructor = profile.role === 'instructor' || profile.role === 'admin';
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">{isInstructor ? 'Instrutor' : 'Aluno'}</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className={`mx-auto max-w-6xl gap-12 px-6 py-12 sm:px-8 ${isInstructor ? 'grid lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]' : 'max-w-3xl'}`}><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || `${isInstructor ? 'Este instrutor' : 'Este aluno'} ainda não adicionou uma apresentação.`}</p></aside>{isInstructor && <section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section>}</div></main>;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

40
pages/MyLearning.tsx Normal file
View File

@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react';
import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react';
import { Course } from '../types';
import { getMyLearningCourses } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getMyLearningCourses().then(setCourses).catch(() => setCourses([])).finally(() => setLoading(false));
}, []);
return (
<main className="min-h-screen max-w-[1440px] mx-auto pt-28 pb-20 px-6 md:px-12">
<div className="mb-10">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400 mb-3"><BookOpenCheck className="w-4 h-4" /> Minha área</div>
<h1 className="text-3xl md:text-4xl font-bold text-white">Continue aprendendo</h1>
<p className="mt-2 text-gray-400">Retome suas aulas exatamente de onde parou.</p>
</div>
{loading ? <div className="h-64 flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-orange-400" /></div> : courses.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-zinc-950/80 py-16 px-6 text-center">
<BookOpenCheck className="w-10 h-10 text-orange-400 mx-auto mb-4" />
<h2 className="font-bold text-white text-lg">Você ainda não iniciou nenhum curso</h2>
<p className="mt-2 text-sm text-gray-400">Escolha uma aula na página de cursos para começar.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{courses.map((course) => (
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
<div className="aspect-video relative overflow-hidden bg-zinc-900"><CourseCoverImage source={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 transition-opacity group-hover:opacity-100" style={{ transform: `scale(${course.coverImageZoom || 1})`, objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%` }} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
</button>
))}
</div>
)}
</main>
);
};

135
pages/ProfilePage.tsx Normal file
View File

@@ -0,0 +1,135 @@
import React, { useEffect, useRef, useState } from 'react';
import { Camera, ExternalLink, Globe2, Instagram, Linkedin, Loader2, Save, UserRound, Youtube } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { Profile, ProfileInput, instructorApi, profileApi } from '../services/api';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { useToast } from '../context/ToastContext';
import { useNavigate } from 'react-router-dom';
const emptyProfile = (user: { id: string; email: string; name: string; role: string }): Profile => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role === 'superadmin' ? 'admin' : user.role === 'professor' ? 'instructor' : 'student',
avatarImageUrl: null,
headline: '',
bio: '',
websiteUrl: null,
linkedinUrl: null,
instagramUrl: null,
youtubeUrl: null,
isPublic: true,
});
const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-4 py-3 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15';
export const ProfilePage: React.FC = () => {
const { user, updateCurrentUser } = useAuth();
const { showToast } = useToast();
const navigate = useNavigate();
const fileInput = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (!user) return;
profileApi.me()
.then((response) => setProfile(response.data))
.catch(() => {
setProfile(emptyProfile(user));
showToast('Não foi possível carregar todos os dados do perfil.', 'error');
})
.finally(() => setIsLoading(false));
}, [showToast, user]);
if (isLoading || !profile || !user) {
return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
}
const update = <K extends keyof Profile>(key: K, value: Profile[K]) => setProfile((current) => current ? { ...current, [key]: value } : current);
const uploadAvatar = async (file?: File) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast('Selecione uma imagem JPG, PNG ou WebP.', 'error');
return;
}
setIsUploading(true);
try {
const response = await instructorApi.uploadCover(file);
update('avatarImageUrl', response.data.coverImageUrl);
showToast('Foto enviada. Salve o perfil para publicar a alteração.', 'success');
} catch {
showToast('Não foi possível enviar a foto.', 'error');
} finally {
setIsUploading(false);
}
};
const save = async (event: React.FormEvent) => {
event.preventDefault();
const input: ProfileInput = {
name: profile.name,
avatarImageUrl: profile.avatarImageUrl,
headline: profile.headline,
bio: profile.bio,
websiteUrl: profile.websiteUrl,
linkedinUrl: profile.linkedinUrl,
instagramUrl: profile.instagramUrl,
youtubeUrl: profile.youtubeUrl,
isPublic: profile.isPublic,
};
setIsSaving(true);
try {
const response = await profileApi.update(input);
setProfile(response.data);
updateCurrentUser({ id: user.id, email: user.email, name: response.data.name, role: response.data.role });
showToast('Perfil salvo com sucesso.', 'success');
} catch {
showToast('Não foi possível salvar o perfil. Verifique os links informados.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<main className="mx-auto min-h-screen w-full max-w-5xl px-5 pb-20 pt-28 sm:px-8">
<div className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua conta</p>
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Meu perfil</h1>
<p className="mt-2 max-w-xl text-sm text-gray-400">Apresente quem você é, compartilhe seus links e escolha a visibilidade do seu perfil.</p>
</div>
{profile.isPublic && <button onClick={() => navigate(`/perfis/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>}
</div>
<form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30">
<section className="border-b border-white/10 bg-gradient-to-r from-orange-500/15 via-zinc-950 to-zinc-950 p-6 sm:p-8">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-full border-4 border-zinc-950 bg-zinc-900 shadow-xl">
{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-11 w-11 text-orange-300" /></div>}
<button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>
</div>
<div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p><p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis quando seu perfil estiver público.</p></div>
</div>
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} />
</section>
<div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]">
<section className="space-y-5">
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Nome</span><input value={profile.name} onChange={(event) => update('name', event.target.value)} className={inputClass} maxLength={120} required /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sobre você, seus interesses ou sua experiência." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
</section>
<section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que outras pessoas vejam sua apresentação e seus links. Perfis de instrutores também exibem seus cursos publicados.</span></span></label></section>
</div>
<div className="flex justify-end border-t border-white/10 bg-black/20 p-5 sm:px-8"><button disabled={isSaving} className="inline-flex min-w-40 items-center justify-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white shadow-lg shadow-orange-500/20 transition hover:bg-orange-600 disabled:opacity-60">{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} Salvar perfil</button></div>
</form>
</main>
);
};
const UrlField: React.FC<{ icon: React.ReactNode; label: string; value: string | null; placeholder: string; onChange: (value: string) => void }> = ({ icon, label, value, placeholder, onChange }) => <label className="block"><span className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400"><span className="text-orange-400 [&>svg]:h-4 [&>svg]:w-4">{icon}</span>{label}</span><input type="url" value={value || ''} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={inputClass} /></label>;

File diff suppressed because one or more lines are too long

112
pages/TrailDetail.tsx Normal file

File diff suppressed because one or more lines are too long

34
pages/TrailPage.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import { ArrowLeft, BookOpen, Loader2 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { Course } from '../types';
import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { CourseCard } from '../components/CourseCard';
export const TrailPage: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const { trailId } = useParams<{ trailId: string }>();
const navigate = useNavigate();
const [trail, setTrail] = useState<PublicLearningPath | null>(null);
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!trailId) return;
Promise.all([getLearningPaths(), getCourses()])
.then(([paths, availableCourses]) => {
const found = paths.find((item) => item.id === trailId) || null;
setTrail(found);
if (found) {
const byId = new Map(availableCourses.map((course) => [course.id, course]));
setCourses(found.courses.map((course) => byId.get(course.id)).filter((course): course is Course => Boolean(course)));
}
})
.finally(() => setIsLoading(false));
}, [trailId]);
if (isLoading) return <main className="flex min-h-[65vh] items-center justify-center pt-24"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
if (!trail) return <main className="mx-auto flex min-h-[65vh] max-w-6xl items-center px-6 pt-24"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha indisponível</p><h1 className="mt-3 text-3xl font-bold text-white">Esta trilha não está publicada.</h1></div></main>;
return <main className="min-h-screen pb-20 pt-20"><section className="relative overflow-hidden border-b border-white/10 bg-zinc-950"><div className="absolute inset-0 opacity-30">{trail.coverImageUrl && <CourseCoverImage source={trail.coverImageUrl} alt="" className="h-full w-full object-cover" style={{ transform: `scale(${trail.coverImageZoom})`, objectPosition: `${trail.coverImagePositionX}% ${trail.coverImagePositionY}%` }} />}</div><div className="absolute inset-0 bg-gradient-to-r from-black via-black/90 to-black/45" /><div className="relative mx-auto max-w-6xl px-6 py-14 sm:px-8 sm:py-20"><button onClick={() => navigate('/')} className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-gray-300 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Voltar para cursos</button><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha de cursos</p><h1 className="mt-3 max-w-3xl text-4xl font-bold tracking-tight text-white sm:text-5xl">{trail.title}</h1>{trail.description && <p className="mt-5 max-w-2xl text-base leading-7 text-gray-300">{trail.description}</p>}<div className="mt-7 inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-4 py-2 text-sm font-semibold text-white"><BookOpen className="h-4 w-4 text-orange-400" />{courses.length} {courses.length === 1 ? 'curso na trilha' : 'cursos na trilha'}</div></div></section><section className="mx-auto max-w-6xl px-6 py-12 sm:px-8"><div className="mb-7"><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua sequência</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos da trilha</h2></div>{courses.length ? <div className="grid gap-7 sm:grid-cols-2 lg:grid-cols-3">{courses.map((course, index) => <div key={course.id}><p className="mb-3 text-xs font-bold text-orange-300">{String(index + 1).padStart(2, '0')} · Curso</p><CourseCard course={course} variant="landscape" onClick={onPlay} /></div>)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-10 text-center text-sm text-gray-500">Nenhum curso publicado nesta trilha.</div>}</section></main>;
};

View 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

View File

@@ -0,0 +1,15 @@
create type account_token_purpose as enum ('invitation', 'password_reset');
create table account_access_tokens (
id uuid primary key default gen_random_uuid(),
email text not null,
role user_role not null default 'student',
purpose account_token_purpose not null,
token_hash text not null unique,
expires_at timestamptz not null,
used_at timestamptz,
created_by uuid not null references users(id),
created_at timestamptz not null default now()
);
create index account_access_tokens_lookup_index on account_access_tokens (token_hash) where used_at is null;

View File

@@ -0,0 +1,13 @@
create table audit_logs (
id uuid primary key default gen_random_uuid(),
actor_id uuid references users(id) on delete set null,
action text not null check (char_length(action) between 1 and 120),
subject_type text not null check (char_length(subject_type) between 1 and 80),
subject_id uuid,
metadata jsonb not null default '{}'::jsonb,
ip_address inet,
created_at timestamptz not null default now()
);
create index audit_logs_created_index on audit_logs (created_at desc);
create index audit_logs_actor_index on audit_logs (actor_id, created_at desc);

View 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();

View File

@@ -0,0 +1,3 @@
alter table courses
add column cover_image_zoom real not null default 1
check (cover_image_zoom >= 1 and cover_image_zoom <= 2.5);

View File

@@ -0,0 +1,5 @@
alter table courses
add column cover_image_position_x real not null default 50
check (cover_image_position_x >= 0 and cover_image_position_x <= 100),
add column cover_image_position_y real not null default 50
check (cover_image_position_y >= 0 and cover_image_position_y <= 100);

View File

@@ -0,0 +1,32 @@
-- A learning path is deliberately only a curated, ordered collection of
-- existing courses. Lessons remain owned by their courses, so a course edit
-- is reflected everywhere that course appears.
create table learning_paths (
id uuid primary key default gen_random_uuid(),
title text not null check (char_length(trim(title)) between 1 and 160),
description text not null default '',
cover_image_url text,
cover_image_zoom real not null default 1 check (cover_image_zoom between 1 and 2.5),
cover_image_position_x real not null default 50 check (cover_image_position_x between 0 and 100),
cover_image_position_y real not null default 50 check (cover_image_position_y between 0 and 100),
status course_status not null default 'draft',
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint learning_paths_published_at_check check (
(status = 'published' and published_at is not null) or status <> 'published'
)
);
create table learning_path_courses (
learning_path_id uuid not null references learning_paths(id) on delete cascade,
course_id uuid not null references courses(id) on delete restrict,
position integer not null check (position > 0),
primary key (learning_path_id, course_id),
unique (learning_path_id, position)
);
create index learning_paths_published_index on learning_paths (published_at desc) where status = 'published';
create index learning_path_courses_position_index on learning_path_courses (learning_path_id, position);
create trigger learning_paths_set_updated_at before update on learning_paths for each row execute function set_updated_at();

View File

@@ -0,0 +1,8 @@
-- Both content types are intentionally composed from existing courses. The
-- distinction is editorial: a "trilha" is a guided sequence and a
-- "formação" is a broader curriculum collection.
alter table learning_paths
add column kind text not null default 'trail'
check (kind in ('trail', 'formation'));
create index learning_paths_kind_index on learning_paths (kind, published_at desc);

View File

@@ -0,0 +1,21 @@
-- Homepage banners are independent editorial content. A banner can either
-- reference a course (and always use its current cover) or use an uploaded
-- image stored in Bunny Storage.
create table home_banners (
id uuid primary key default gen_random_uuid(),
kind text not null check (kind in ('course', 'custom')),
course_id uuid references courses(id) on delete cascade,
image_url text,
title text not null default '',
description text not null default '',
position integer not null check (position > 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint home_banners_source_check check (
(kind = 'course' and course_id is not null and image_url is null) or
(kind = 'custom' and course_id is null and image_url is not null)
),
unique (position)
);
create trigger home_banners_set_updated_at before update on home_banners for each row execute function set_updated_at();

View File

@@ -0,0 +1,5 @@
-- Trilhas and Formações described the same object: an ordered collection of
-- existing courses. Preserve every record while collapsing the duplicate type.
update learning_paths set kind = 'trail' where kind = 'formation';
drop index if exists learning_paths_kind_index;
alter table learning_paths drop column kind;

View File

@@ -0,0 +1,13 @@
alter table users
add column if not exists avatar_image_url text,
add column if not exists profile_headline text not null default '',
add column if not exists profile_bio text not null default '',
add column if not exists website_url text,
add column if not exists linkedin_url text,
add column if not exists instagram_url text,
add column if not exists youtube_url text,
add column if not exists profile_is_public boolean not null default true;
create index if not exists users_public_instructor_profiles_index
on users (id)
where profile_is_public and role in ('instructor', 'admin');

View File

@@ -0,0 +1,16 @@
create table if not exists bunny_video_uploads (
video_id text primary key,
owner_id uuid not null references users(id) on delete cascade,
created_at timestamptz not null default now()
);
create index if not exists bunny_video_uploads_owner_index on bunny_video_uploads (owner_id);
-- Preserve access to Bunny videos created before ownership was tracked.
insert into bunny_video_uploads (video_id, owner_id)
select distinct on (lm.external_id) lm.external_id, c.instructor_id
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where lm.provider = 'bunny'
on conflict (video_id) do nothing;

View File

@@ -0,0 +1,20 @@
-- Course categories were previously presented as trilhas but only stored a
-- label. Turn every existing label into a real ordered trilha without moving
-- or deleting any course.
insert into learning_paths (title, description, status, published_at)
select category.name, '', 'draft', null
from course_categories category
where not exists (
select 1 from learning_paths path where lower(path.title) = lower(category.name)
);
insert into learning_path_courses (learning_path_id, course_id, position)
select path.id, course.id, ranked.position
from (
select id, category, row_number() over (partition by category order by published_at nulls last, title, id)::integer as position
from courses
where status <> 'archived'
) ranked
join courses course on course.id = ranked.id
join learning_paths path on lower(path.title) = lower(ranked.category)
on conflict (learning_path_id, course_id) do nothing;

View File

@@ -9,22 +9,60 @@ import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js'; 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 { bunnyWebhookRoutes } from './routes/bunny-webhooks.js';
import { profileRoutes } from './routes/profiles.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
// 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);
app.addContentTypeParser('application/octet-stream', rawUploadParser);
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
const assetContentTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/zip',
'text/csv',
];
for (const contentType of assetContentTypes) {
app.addContentTypeParser(contentType, { parseAs: 'buffer', bodyLimit: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
}
app.register(cors, { app.register(cors, {
origin: config.FRONTEND_ORIGIN, origin: config.FRONTEND_ORIGIN,
methods: ['GET', 'POST', 'PATCH', 'DELETE'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
}); });
app.setErrorHandler((error, _request, reply) => { app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) { if (error instanceof ZodError) {
return reply.code(400).send({ error: 'Invalid request', details: error.flatten() }); return reply.code(400).send({
error: error.issues[0]?.message || 'Dados inválidos.',
details: error.flatten(),
});
} }
app.log.error(error); app.log.error(error);
return reply.code(500).send({ error: 'Internal server error' }); return reply.code(500).send({ error: 'Erro interno no servidor.' });
}); });
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => { const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {
@@ -40,8 +78,11 @@ export function buildApp() {
app.register(authPlugin); app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' }); app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(profileRoutes, { prefix: '/api/v1/profiles' });
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(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;

18
server/src/audit.ts Normal file
View File

@@ -0,0 +1,18 @@
import { pool } from './db/pool.js';
type AuditInput = {
actorId?: string;
action: string;
subjectType: string;
subjectId?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
};
export const recordAudit = async (input: AuditInput) => {
await pool.query(
`insert into audit_logs (actor_id, action, subject_type, subject_id, metadata, ip_address)
values ($1, $2, $3, $4::uuid, $5::jsonb, $6::inet)`,
[input.actorId ?? null, input.action, input.subjectType, input.subjectId ?? null, JSON.stringify(input.metadata ?? {}), input.ipAddress ?? null],
);
};

View File

@@ -0,0 +1,4 @@
import { createHash, randomBytes } from 'node:crypto';
export const createRawToken = () => randomBytes(32).toString('base64url');
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');

View File

@@ -34,7 +34,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
try { try {
await request.jwtVerify(); await request.jwtVerify();
} catch { } catch {
reply.code(401).send({ error: 'Authentication required' }); reply.code(401).send({ error: 'Autenticação necessária.' });
return false; return false;
} }
@@ -44,7 +44,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
); );
const account = result.rows[0]; const account = result.rows[0];
if (!account) { if (!account) {
reply.code(401).send({ error: 'This account is no longer active' }); reply.code(401).send({ error: 'Esta conta não está mais ativa.' });
return false; return false;
} }
@@ -61,7 +61,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
if (!(await verifyActiveUser(request, reply))) return; if (!(await verifyActiveUser(request, reply))) return;
if (!roles.includes(request.user.role)) { if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' }); return reply.code(403).send({ error: 'Você não tem permissão para esta ação.' });
} }
}); });
}; };

View File

@@ -11,12 +11,61 @@ const environmentSchema = z.object({
APP_ENV: z.enum(['development', 'test', 'production']).default('development'), APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'), JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()), SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)), SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(8)),
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'), SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
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),
SMTP_HOST: optionalEnvironmentValue(z.string().trim().min(1).max(255)),
SMTP_PORT: optionalEnvironmentValue(z.coerce.number().int().min(1).max(65_535)),
SMTP_USER: optionalEnvironmentValue(z.string().trim().min(1).max(320)),
SMTP_PASS: optionalEnvironmentValue(z.string().min(1).max(1_000)),
MAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)),
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
BUNNY_STREAM_API_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_STORAGE_ZONE: optionalEnvironmentValue(z.string().trim().min(1).max(120)),
BUNNY_STORAGE_PASSWORD: optionalEnvironmentValue(z.string().min(1)),
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
BUNNY_STORAGE_CDN_HOST: optionalEnvironmentValue(z.string().url()),
BUNNY_COVER_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(50).default(10),
BUNNY_ASSET_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(512).default(100),
}); });
export const config = environmentSchema.parse(process.env); export const config = environmentSchema.parse(process.env);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') { 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.'); throw new Error('JWT_SECRET deve ter um valor único em produção.');
}
const bunnyConfigurationValues = [
config.BUNNY_STREAM_LIBRARY_ID,
config.BUNNY_STREAM_API_KEY,
config.BUNNY_EMBED_TOKEN_KEY,
];
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY e BUNNY_EMBED_TOKEN_KEY devem ser configurados juntos.');
}
const bunnyStorageConfigurationValues = [
config.BUNNY_STORAGE_ZONE,
config.BUNNY_STORAGE_PASSWORD,
config.BUNNY_STORAGE_ENDPOINT,
config.BUNNY_STORAGE_CDN_HOST,
];
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT e BUNNY_STORAGE_CDN_HOST devem ser configurados juntos.');
}
const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM];
if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) {
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS e MAIL_FROM devem ser configurados juntos.');
} }

View File

@@ -4,8 +4,31 @@ import { fileURLToPath } from 'node:url';
import { pool, closePool } from './pool.js'; import { pool, closePool } from './pool.js';
const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations'); const migrationsDirectory = join(dirname(fileURLToPath(import.meta.url)), '../../migrations');
const databaseRetryDelayMs = 2_000;
const databaseRetryAttempts = 30;
const delay = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function waitForDatabase() {
let lastError: unknown;
for (let attempt = 1; attempt <= databaseRetryAttempts; attempt += 1) {
try {
await pool.query('select 1');
return;
} catch (error) {
lastError = error;
if (attempt === databaseRetryAttempts) break;
console.warn(`Database is not ready (attempt ${attempt}/${databaseRetryAttempts}); retrying in 2 seconds.`);
await delay(databaseRetryDelayMs);
}
}
throw lastError;
}
async function migrate() { async function migrate() {
await waitForDatabase();
await pool.query(` await pool.query(`
create table if not exists schema_migrations ( create table if not exists schema_migrations (
name text primary key, name text primary key,

View File

@@ -16,6 +16,7 @@ async function start() {
for (const signal of ['SIGINT', 'SIGTERM']) { for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, () => { process.once(signal, () => {
app.log.info({ signal }, 'Shutdown signal received');
app.close() app.close()
.then(closePool) .then(closePool)
.finally(() => process.exit(0)); .finally(() => process.exit(0));

View File

@@ -0,0 +1,112 @@
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
export class BunnyStorageConfigurationError extends Error {}
export class BunnyStorageRequestError extends Error {}
type CoverImage = { body: Buffer; contentType: string };
type StoredAsset = { body: Buffer; contentType: string; extension: string };
function bunnyStorageConfiguration() {
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
throw new BunnyStorageConfigurationError('O Bunny Storage não está configurado. Peça a um administrador para adicionar as variáveis da Storage Zone.');
}
return {
zone: config.BUNNY_STORAGE_ZONE,
password: config.BUNNY_STORAGE_PASSWORD,
endpoint: config.BUNNY_STORAGE_ENDPOINT.replace(/\/$/, ''),
cdnHost: config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, ''),
};
}
export function isBunnyStorageConfigured() {
return Boolean(config.BUNNY_STORAGE_ZONE && config.BUNNY_STORAGE_PASSWORD && config.BUNNY_STORAGE_ENDPOINT && config.BUNNY_STORAGE_CDN_HOST);
}
function imageExtension(image: CoverImage) {
const { body, contentType } = image;
const isPng = body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const isJpeg = body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff;
const isWebp = body.length >= 12 && body.subarray(0, 4).toString('ascii') === 'RIFF' && body.subarray(8, 12).toString('ascii') === 'WEBP';
if (contentType === 'image/png' && isPng) return 'png';
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
if (contentType === 'image/webp' && isWebp) return 'webp';
throw new BunnyStorageRequestError('Envie apenas imagens JPG, PNG ou WebP válidas.');
}
export async function uploadBunnyCover(image: CoverImage) {
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
const extension = imageExtension(image);
const key = `covers/${randomUUID()}.${extension}`;
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
method: 'PUT',
headers: {
AccessKey: password,
'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
body: image.body,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage upload failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return { key, coverImageUrl: `${cdnHost}/${key}` };
}
export async function uploadBunnyAsset(asset: StoredAsset) {
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
const key = `materials/${randomUUID()}.${asset.extension}`;
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
method: 'PUT',
headers: {
AccessKey: password,
'Content-Type': asset.contentType,
'Cache-Control': 'private, max-age=31536000, immutable',
},
body: asset.body,
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage upload failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return { key, assetUrl: `${cdnHost}/${key}` };
}
export function bunnyAssetKeyFromUrl(url: string) {
if (!config.BUNNY_STORAGE_CDN_HOST) return null;
const cdnHost = config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, '');
if (!url.startsWith(`${cdnHost}/materials/`)) return null;
const key = url.slice(cdnHost.length + 1);
return /^materials\/[0-9a-f-]{36}\.(?:pdf|docx|doc|xlsx|xls|csv|zip)$/i.test(key) ? key : null;
}
export async function downloadBunnyAsset(key: string) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
headers: { AccessKey: password },
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage download failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return {
body: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || 'application/octet-stream',
};
}
export async function downloadBunnyCover(filename: string) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
headers: { AccessKey: password },
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyStorageRequestError(`Bunny Storage download failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
}
return {
body: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || 'application/octet-stream',
};
}

View File

@@ -0,0 +1,131 @@
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
const BUNNY_VIDEO_API = 'https://video.bunnycdn.com';
export type BunnyMediaStatus = 'processing' | 'ready' | 'failed';
export type BunnyVideo = {
id: string;
title: string;
status: BunnyMediaStatus;
providerStatus: number;
encodeProgress: number;
durationSeconds: number | null;
};
type BunnyApiVideo = {
guid: string;
title: string;
status: number;
encodeProgress?: number;
length?: number;
};
export class BunnyConfigurationError extends Error {}
export class BunnyRequestError extends Error {}
function getBunnyConfiguration() {
if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) {
throw new BunnyConfigurationError('O Bunny Stream não está configurado. Peça a um administrador para configurar as variáveis de ambiente do Bunny.');
}
return {
libraryId: config.BUNNY_STREAM_LIBRARY_ID,
apiKey: config.BUNNY_STREAM_API_KEY,
embedTokenKey: config.BUNNY_EMBED_TOKEN_KEY,
};
}
export function isBunnyConfigured() {
return Boolean(config.BUNNY_STREAM_LIBRARY_ID && config.BUNNY_STREAM_API_KEY && config.BUNNY_EMBED_TOKEN_KEY);
}
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 === 5 || status === 8) return 'failed';
return 'processing';
}
function toVideo(video: BunnyApiVideo): BunnyVideo {
return {
id: video.guid,
title: video.title,
status: bunnyMediaStatus(video.status),
providerStatus: video.status,
encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))),
durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.max(1, Math.round(video.length)) : null,
};
}
async function bunnyRequest(path: string, init: RequestInit = {}) {
const { libraryId, apiKey } = getBunnyConfiguration();
const response = await fetch(`${BUNNY_VIDEO_API}/library/${libraryId}${path}`, {
...init,
headers: {
AccessKey: apiKey,
...init.headers,
},
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new BunnyRequestError(`Bunny Stream request failed (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ''}`);
}
return response;
}
export async function createBunnyVideo(title: string) {
const response = await bunnyRequest('/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
return toVideo(await response.json() as BunnyApiVideo);
}
export async function uploadBunnyVideo(videoId: string, body: ReadableStream, contentType: string | undefined) {
await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`, {
method: 'PUT',
headers: { 'Content-Type': contentType || 'application/octet-stream' },
body,
// Required by Node's fetch implementation when a request body is streamed.
duplex: 'half',
} as RequestInit);
return getBunnyVideo(videoId);
}
export async function getBunnyVideo(videoId: string) {
const response = await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`);
return toVideo(await response.json() as BunnyApiVideo);
}
export async function deleteBunnyVideo(videoId: string) {
await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`, { method: 'DELETE' });
}
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) {
const { libraryId, embedTokenKey } = getBunnyConfiguration();
const expires = Math.floor(Date.now() / 1000) + validitySeconds;
const token = createHash('sha256').update(`${embedTokenKey}${videoId}${expires}`).digest('hex');
const url = new URL(`https://iframe.mediadelivery.net/embed/${libraryId}/${encodeURIComponent(videoId)}`);
url.searchParams.set('token', token);
url.searchParams.set('expires', String(expires));
url.searchParams.set('autoplay', 'true');
url.searchParams.set('responsive', 'true');
return { embedUrl: url.toString(), expiresAt: new Date(expires * 1000).toISOString() };
}

View File

@@ -1,17 +1,120 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import type { PoolClient } from 'pg';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { createRawToken, hashToken } from '../auth/account-tokens.js';
import { config } from '../config.js';
import { recordAudit } from '../audit.js';
const userParamsSchema = z.object({ const userParamsSchema = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
}); });
const updateUserSchema = z.object({ const updateUserSchema = z.object({
name: z.string().trim().min(2).max(120).optional(),
email: z.string().email().transform((email) => email.toLowerCase()).optional(),
role: z.enum(['student', 'instructor', 'admin']).optional(), role: z.enum(['student', 'instructor', 'admin']).optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
}).refine((input) => input.role !== undefined || input.isActive !== undefined, { }).refine((input) => input.name !== undefined || input.email !== undefined || input.role !== undefined || input.isActive !== undefined, {
message: 'Provide at least one field to update', message: 'Informe pelo menos um campo para atualizar.',
}); });
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.literal('instructor').default('instructor') });
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(),
});
const homeBannersSchema = z.object({
banners: z.array(z.object({
kind: z.enum(['course', 'custom']),
courseId: z.string().uuid().nullable(),
imageUrl: z.string().url().nullable(),
title: z.string().trim().max(180).default(''),
description: z.string().trim().max(500).default(''),
}).superRefine((banner, context) => {
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners de curso precisam de um curso.' });
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners personalizados precisam de uma imagem.' });
})).max(10),
});
const selectHomeBanners = async () => {
const result = await pool.query(
`select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position,
c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl"
from home_banners b
left join courses c on c.id = b.course_id
order by b.position`,
);
return result.rows;
};
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
const learningPathSchema = z.object({
title: z.string().trim().min(1).max(160),
description: z.string().trim().max(2_000).default(''),
coverImageUrl: z.string().url().nullable().default(null),
coverImageZoom: z.number().min(1).max(2.5).default(1),
coverImagePositionX: z.number().min(0).max(100).default(50),
coverImagePositionY: z.number().min(0).max(100).default(50),
status: z.enum(['draft', 'published']).default('draft'),
courseIds: z.array(z.string().uuid()).max(100),
}).superRefine((input, context) => {
if (input.status === 'published' && input.courseIds.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['courseIds'],
message: 'Adicione pelo menos um curso antes de publicar a trilha.',
});
}
});
type LearningPathInput = z.infer<typeof learningPathSchema>;
const getLearningPaths = async () => {
const result = await pool.query(
`select
p.id, p.title, p.description, p.cover_image_url as "coverImageUrl",
p.cover_image_zoom as "coverImageZoom",
p.cover_image_position_x as "coverImagePositionX",
p.cover_image_position_y as "coverImagePositionY",
p.status, p.published_at as "publishedAt", p.created_at as "createdAt",
coalesce(jsonb_agg(jsonb_build_object(
'id', c.id, 'title', c.title, 'status', c.status,
'coverImageUrl', c.cover_image_url, 'position', pc.position
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
from learning_paths p
left join learning_path_courses pc on pc.learning_path_id = p.id
left join courses c on c.id = pc.course_id
group by p.id
order by p.created_at desc`,
);
return result.rows;
};
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
const courseIds = [...new Set(input.courseIds)];
if (courseIds.length !== input.courseIds.length) throw new Error('Um curso só pode aparecer uma vez em uma trilha.');
const courses = await client.query<{ id: string; status: string }>(
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
[courseIds],
);
if (courses.rowCount !== courseIds.length) throw new Error('Todos os cursos da trilha devem existir e não podem estar arquivados.');
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
throw new Error('Publique todos os cursos desta trilha antes de publicá-la.');
}
return courseIds;
};
const writePathCourses = async (client: PoolClient, pathId: string, courseIds: string[]) => {
await client.query('delete from learning_path_courses where learning_path_id = $1', [pathId]);
for (const [index, courseId] of courseIds.entries()) {
await client.query(
`insert into learning_path_courses (learning_path_id, course_id, position) values ($1, $2, $3)`,
[pathId, courseId, index + 1],
);
}
};
export const adminRoutes: FastifyPluginAsync = async (app) => { export const adminRoutes: FastifyPluginAsync = async (app) => {
const adminAccess = { preHandler: app.requireRoles(['admin']) }; const adminAccess = { preHandler: app.requireRoles(['admin']) };
@@ -26,24 +129,326 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
return { data: result.rows }; return { data: result.rows };
}); });
app.get('/dashboard', adminAccess, async () => {
const result = await pool.query(
`select
(select count(*)::int from users) as "totalUsers",
(select count(*)::int from users where is_active) as "activeUsers",
(select count(*)::int from courses where status = 'published') as "publishedCourses",
(select count(*)::int from lesson_progress where completed_at is not null) as "completedLessons",
(select count(*)::int from comments) as "comments"`,
);
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.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
app.get('/path-courses', adminAccess, async () => {
const result = await pool.query<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }>(
`select id, title, status, category,
coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl"
from courses
where status <> 'archived'
order by category, title`,
);
return { data: result.rows };
});
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
app.put('/home-banners', adminAccess, async (request) => {
const input = homeBannersSchema.parse(request.body);
const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!);
if (courseIds.length) {
const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]);
if (courses.rowCount !== courseIds.length) return { error: 'Banners de curso devem referenciar cursos publicados.' };
}
const client = await pool.connect();
try {
await client.query('begin');
await client.query('delete from home_banners');
for (const [index, banner] of input.banners.entries()) {
await client.query(
`insert into home_banners (kind, course_id, image_url, title, description, position)
values ($1, $2, $3, $4, $5, $6)`,
[banner.kind, banner.courseId, banner.imageUrl, banner.title, banner.description, index + 1],
);
}
await client.query('commit');
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
await recordAudit({ actorId: request.user.id, action: 'home.banners_updated', subjectType: 'platform', metadata: { count: input.banners.length }, ipAddress: request.ip });
return { data: await selectHomeBanners() };
});
app.post('/paths', adminAccess, async (request, reply) => {
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>(
`insert into learning_paths (
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at
) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end)
returning id`,
[input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
);
await writePathCourses(client, path.rows[0].id, courseIds);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'path.created', subjectType: 'learning_path', subjectId: path.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
const paths = await getLearningPaths();
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
} catch (error) {
await client.query('rollback');
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
}
});
app.patch('/paths/:pathId', adminAccess, async (request, reply) => {
const { pathId } = learningPathParamsSchema.parse(request.params);
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>(
`update learning_paths set
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5,
cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status,
published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
where id = $1
returning id`,
[pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
);
if (!path.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Trilha não encontrada.' });
}
await writePathCourses(client, pathId, courseIds);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'path.updated', subjectType: 'learning_path', subjectId: pathId, metadata: { title: input.title }, ipAddress: request.ip });
const paths = await getLearningPaths();
return { data: paths.find((item) => item.id === pathId) };
} catch (error) {
await client.query('rollback');
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
}
});
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
const { pathId } = learningPathParamsSchema.parse(request.params);
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
return reply.code(204).send();
});
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: 'Trilha não encontrada.' });
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; category: string; coverImageUrl: string | null }>(
`select id, title, status, category,
coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl"
from courses where status <> 'archived' order by category, 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: 'Os cursos em destaque devem existir e estar publicados.' });
}
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 () => {
const result = await pool.query(
`select a.id, a.action, a.subject_type as "subjectType", a.subject_id as "subjectId",
a.metadata, a.created_at as "createdAt", coalesce(u.display_name, 'Sistema') as "actorName"
from audit_logs a
left join users u on u.id = a.actor_id
order by a.created_at desc
limit $1`, [config.AUDIT_LOG_PAGE_SIZE],
);
return { data: result.rows };
});
app.get('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
const result = await pool.query(
`select u.id, u.email, u.display_name as name, u.role, u.is_active as "isActive", u.created_at as "createdAt",
(select count(*)::int from lesson_progress lp where lp.user_id = u.id and lp.completed_at is not null) as "completedLessons",
(select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt"
from users u where u.id = $1`, [userId],
);
if (!result.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
return { data: result.rows[0] };
});
app.post('/invitations', adminAccess, async (request, reply) => {
const input = invitationSchema.parse(request.body);
const existing = await pool.query('select 1 from users where email = $1', [input.email]);
if (existing.rowCount) return reply.code(409).send({ error: 'Este e-mail já possui uma conta.' });
const rawToken = createRawToken();
await pool.query(
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
values ($1, $2::user_role, 'invitation', $3, now() + ($4::int * interval '1 hour'), $5)`,
[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 });
return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } });
});
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]);
if (!account.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
const rawToken = createRawToken();
await pool.query(
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
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), 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 });
return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } };
});
app.patch('/users/:userId', adminAccess, async (request, reply) => { app.patch('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params); const { userId } = userParamsSchema.parse(request.params);
const input = updateUserSchema.parse(request.body); const input = updateUserSchema.parse(request.body);
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) { if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
return reply.code(400).send({ error: 'You cannot remove your own superadmin access' }); return reply.code(400).send({ error: 'Você não pode remover seu próprio acesso de superadmin.' });
}
if (input.email) {
const duplicate = await pool.query('select 1 from users where email = $1 and id <> $2', [input.email, userId]);
if (duplicate.rowCount) return reply.code(409).send({ error: 'Este e-mail já é usado por outra conta.' });
} }
const result = await pool.query( const result = await pool.query(
`update users `update users
set role = coalesce($2::user_role, role), set display_name = coalesce($2, display_name),
is_active = coalesce($3, is_active) email = coalesce($3, email),
role = coalesce($4::user_role, role),
is_active = coalesce($5, is_active)
where id = $1 where id = $1
returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`, returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`,
[userId, input.role ?? null, input.isActive ?? null], [userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
); );
const account = result.rows[0]; const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' }); if (!account) return reply.code(404).send({ error: 'Usuário não encontrado.' });
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
return { data: account }; return { data: account };
}); });
app.delete('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
if (userId === request.user.id) return reply.code(400).send({ error: 'Você não pode excluir sua própria conta de superadmin.' });
const client = await pool.connect();
try {
await client.query('begin');
const account = await client.query<{ id: string; email: string; role: 'student' | 'instructor' | 'admin' }>('select id, email, role from users where id = $1 for update', [userId]);
if (!account.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Usuário não encontrado.' });
}
if (account.rows[0].role === 'admin') {
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
if (Number(admins.rows[0].count) <= 1) {
await client.query('rollback');
return reply.code(400).send({ error: 'O último superadmin ativo não pode ser excluído.' });
}
}
// Courses remain available. Their ownership is transferred to the admin
// performing the deletion instead of deleting lesson and Bunny media.
await client.query('update courses set instructor_id = $2 where instructor_id = $1', [userId, request.user.id]);
await client.query('delete from comment_replies where author_id = $1', [userId]);
await client.query('delete from comments where author_id = $1', [userId]);
await client.query('delete from account_access_tokens where created_by = $1 or email = $2', [userId, account.rows[0].email]);
await client.query('delete from users where id = $1', [userId]);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'user.deleted', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email, role: account.rows[0].role }, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
}; };

View File

@@ -1,17 +1,21 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js'; import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { hashToken } from '../auth/account-tokens.js';
import type { AuthUser } from '../auth/plugin.js'; import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { config } from '../config.js';
import { sendWelcomeEmail } from '../services/email.js';
const credentialsSchema = z.object({ const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()), email: z.string().email().transform((email) => email.toLowerCase()),
password: z.string().min(12).max(200), password: z.string().min(8).max(200),
}); });
const registerSchema = credentialsSchema.extend({ const registerSchema = credentialsSchema.extend({
name: z.string().trim().min(2).max(120), name: z.string().trim().min(2).max(120),
}); });
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(8).max(200), name: z.string().trim().min(2).max(120).optional() });
type UserRow = { type UserRow = {
id: string; id: string;
@@ -30,7 +34,91 @@ const serializeUser = (user: UserRow): AuthUser => ({
}); });
export const authRoutes: FastifyPluginAsync = async (app) => { export const authRoutes: FastifyPluginAsync = async (app) => {
// This is stored in PostgreSQL rather than process memory so the limit keeps
// working if the API is restarted or later scaled to more than one replica.
const allowPublicAuthAttempt = async (ip: string) => {
const result = await pool.query<{ attempts: number }>(
`insert into auth_rate_limits (key, window_started_at, attempts)
values ($1, now(), 1)
on conflict (key) do update set
window_started_at = case
when auth_rate_limits.window_started_at <= now() - ($2::int * interval '1 second') then now()
else auth_rate_limits.window_started_at
end,
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 = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
if (await allowPublicAuthAttempt(ip)) return false;
reply.code(429).send({ error: 'Muitas tentativas. Tente novamente mais tarde.' });
return true;
};
app.post('/accept-invitation', async (request, reply) => {
if (await rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const token = await client.query<{ email: string; role: AuthUser['role'] }>(
`update account_access_tokens set used_at = now()
where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now()
returning email, role`, [hashToken(input.token)],
);
if (!token.rows[0]) {
await client.query('rollback');
return reply.code(400).send({ error: 'Este convite é inválido ou expirou.' });
}
const result = await client.query<UserRow>(
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
returning id, email, display_name, role, password_hash, is_active`,
[token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role],
);
await client.query('commit');
const user = serializeUser(result.rows[0]);
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
} catch {
await client.query('rollback');
return reply.code(409).send({ error: 'Este e-mail de convite já possui uma conta.' });
} finally {
client.release();
}
});
app.post('/reset-password', async (request, reply) => {
if (await rejectIfRateLimited(request.ip, reply)) return;
const input = tokenPasswordSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const token = await client.query<{ email: string }>(
`update account_access_tokens set used_at = now()
where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now()
returning email`, [hashToken(input.token)],
);
if (!token.rows[0]) {
await client.query('rollback');
return reply.code(400).send({ error: 'Este link de redefinição é inválido ou expirou.' });
}
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
await client.query('commit');
return reply.code(204).send();
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.post('/register', async (request, reply) => { app.post('/register', async (request, reply) => {
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);
@@ -42,17 +130,24 @@ 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 });
try {
const sent = await sendWelcomeEmail({ name: user.name, email: user.email });
if (!sent && config.SMTP_HOST) request.log.warn({ email: user.email }, 'Welcome email was rejected by the SMTP server');
} catch (error) {
request.log.error(error, 'Welcome email could not be sent');
}
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') {
return reply.code(409).send({ error: 'An account with this email already exists' }); return reply.code(409).send({ error: 'Já existe uma conta com este e-mail.' });
} }
throw error; throw error;
} }
}); });
app.post('/login', async (request, reply) => { app.post('/login', async (request, reply) => {
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`,
@@ -61,11 +156,11 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const account = result.rows[0]; const account = result.rows[0];
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) { if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' }); return reply.code(401).send({ error: 'E-mail ou senha inválidos.' });
} }
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 };
}); });

View 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: 'Os webhooks do Bunny não estão configurados.' });
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: 'Assinatura do webhook do Bunny inválida.' });
}
const input = webhookSchema.parse(request.body);
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
return reply.code(400).send({ error: 'Biblioteca de vídeos Bunny inesperada.' });
}
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();
});
};

View File

@@ -1,19 +1,34 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
import { BunnyStorageConfigurationError, BunnyStorageRequestError, bunnyAssetKeyFromUrl, downloadBunnyAsset, downloadBunnyCover } from '../providers/bunny-storage.js';
const paramsSchema = z.object({ const paramsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
}); });
const playbackParamsSchema = z.object({
courseId: z.string().uuid(),
lessonId: z.string().uuid(),
});
const coverParamsSchema = z.object({
filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i),
});
const assetParamsSchema = z.object({ assetId: z.string().uuid() });
export const courseSelect = (publicOnly = false) => ` export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select select
c.id, c.id,
c.slug, c.slug,
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.cover_image_zoom as "coverImageZoom",
c.cover_image_position_x as "coverImagePositionX",
c.cover_image_position_y as "coverImagePositionY",
c.status, c.status,
c.published_at as "publishedAt", c.published_at as "publishedAt",
jsonb_build_object( jsonb_build_object(
@@ -35,6 +50,8 @@ export const courseSelect = (publicOnly = false) => `
'name', a.name, 'name', a.name,
'kind', a.kind, 'kind', a.kind,
'url', a.url, 'url', a.url,
'sizeBytes', a.size_bytes,
'createdAt', a.created_at,
'description', a.description, 'description', a.description,
'accessLevel', a.access_level 'accessLevel', a.access_level
) order by a.created_at) ) order by a.created_at)
@@ -46,6 +63,7 @@ export const courseSelect = (publicOnly = false) => `
jsonb_build_object( jsonb_build_object(
'id', lm.id, 'id', lm.id,
'provider', lm.provider, 'provider', lm.provider,
'externalId', lm.external_id,
'status', lm.status, 'status', lm.status,
'embedUrl', lm.embed_url, 'embedUrl', lm.embed_url,
'playbackUrl', lm.playback_url, 'playbackUrl', lm.playback_url,
@@ -53,7 +71,7 @@ export const courseSelect = (publicOnly = false) => `
) order by lm.created_at ) order by lm.created_at
) )
from lesson_media lm from lesson_media lm
where lm.lesson_id = l.id and lm.status = 'ready' where lm.lesson_id = l.id ${includeUnreadyMedia ? '' : "and lm.status = 'ready'"}
), '[]'::jsonb) ), '[]'::jsonb)
) order by l.position ) order by l.position
) )
@@ -66,6 +84,8 @@ export const courseSelect = (publicOnly = false) => `
'name', a.name, 'name', a.name,
'kind', a.kind, 'kind', a.kind,
'url', a.url, 'url', a.url,
'sizeBytes', a.size_bytes,
'createdAt', a.created_at,
'description', a.description, 'description', a.description,
'accessLevel', a.access_level 'accessLevel', a.access_level
) order by a.created_at) ) order by a.created_at)
@@ -94,12 +114,139 @@ 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('/covers/:filename', async (request, reply) => {
const { filename } = coverParamsSchema.parse(request.params);
try {
const cover = await downloadBunnyCover(filename);
reply.header('Content-Type', cover.contentType);
reply.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800');
return reply.send(cover.body);
} catch (error) {
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
return reply.code(404).send({ error: 'Imagem de capa não encontrada.' });
}
throw error;
}
});
app.get('/assets/:assetId/download', { preHandler: app.authenticate }, async (request, reply) => {
const { assetId } = assetParamsSchema.parse(request.params);
const result = await pool.query<{ name: string; url: string; status: string; instructor_id: string }>(
`select a.name, a.url, c.status, c.instructor_id
from assets a
join courses c on c.id = coalesce(a.course_id, (select lesson.course_id from lessons lesson where lesson.id = a.lesson_id))
where a.id = $1`,
[assetId],
);
const asset = result.rows[0];
if (!asset || (asset.status !== 'published' && request.user.role !== 'admin' && asset.instructor_id !== request.user.id)) {
return reply.code(404).send({ error: 'Material não encontrado.' });
}
const key = bunnyAssetKeyFromUrl(asset.url);
if (!key) return reply.redirect(asset.url);
try {
const file = await downloadBunnyAsset(key);
const filename = asset.name.replace(/[\\/\r\n"]/g, '_');
reply.header('Content-Type', file.contentType);
reply.header('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
reply.header('Cache-Control', 'private, no-store');
return reply.send(file.body);
} catch (error) {
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
return reply.code(404).send({ error: 'Material não encontrado.' });
}
throw error;
}
});
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) };
}); });
// Paths intentionally reference existing courses instead of copying them.
// Only a path and its published courses are visible to visitors.
app.get('/paths', async () => {
const result = await pool.query(
`select
p.id, p.title, p.description,
coalesce(p.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl",
p.cover_image_zoom as "coverImageZoom",
p.cover_image_position_x as "coverImagePositionX",
p.cover_image_position_y as "coverImagePositionY",
coalesce(jsonb_agg(jsonb_build_object(
'id', c.id, 'title', c.title, 'category', c.category,
'coverImageUrl', coalesce(c.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)), 'position', pc.position
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
from learning_paths p
join learning_path_courses pc on pc.learning_path_id = p.id
join courses c on c.id = pc.course_id and c.status = 'published'
where p.status = 'published'
group by p.id
order by p.published_at desc`,
);
return { data: result.rows };
});
app.get('/home-banners', async () => {
const result = await pool.query(
`select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position,
c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl",
c.cover_image_zoom as "coverImageZoom", c.cover_image_position_x as "coverImagePositionX", c.cover_image_position_y as "coverImagePositionY"
from home_banners b
left join courses c on c.id = b.course_id and c.status = 'published'
where b.kind = 'custom' or c.id is not null
order by b.position`,
);
return { data: result.rows };
});
app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query(
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"
from (
${courseSelect()}
where c.status = 'published'
) base
left join lateral (
select
case when count(l.id) = 0 then 0
else floor(100.0 * count(l.id) filter (where lp.completed_at is not null) / count(l.id))::int end as progress,
max(lp.updated_at) as "lastActivity"
from lessons l
left join lesson_progress lp on lp.lesson_id = l.id and lp.user_id = $1
where l.course_id = base.id
) progress on true
where progress."lastActivity" is not null
order by progress."lastActivity" desc`,
[request.user.id],
);
return { data: result.rows };
});
app.get('/:courseId', async (request, reply) => { app.get('/:courseId', async (request, reply) => {
const authenticated = await hasSession(request); const authenticated = await hasSession(request);
const { courseId } = paramsSchema.parse(request.params); const { courseId } = paramsSchema.parse(request.params);
@@ -107,9 +254,47 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
const course = result.rows[0]; const course = result.rows[0];
if (!course) { if (!course) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
return { data: authenticated ? course : withoutProtectedMedia(course) }; return { data: authenticated ? course : withoutProtectedMedia(course) };
}); });
app.get('/:courseId/lessons/:lessonId/playback', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId, lessonId } = playbackParamsSchema.parse(request.params);
const result = await pool.query<{
provider: string;
external_id: string;
playback_url: string | null;
embed_url: string | null;
status: string;
}>(
`select lm.provider, lm.external_id, lm.playback_url, lm.embed_url, lm.status
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where c.id = $1 and l.id = $2 and c.status = 'published'
order by lm.created_at
limit 1`,
[courseId, lessonId],
);
const media = result.rows[0];
if (!media) return reply.code(404).send({ error: 'Mídia da aula não encontrada.' });
if (media.status === 'processing') return reply.code(409).send({ error: 'Este vídeo ainda está sendo processado pelo Bunny Stream.' });
if (media.status === 'failed') return reply.code(409).send({ error: 'Este vídeo não pôde ser processado. Entre em contato com o instrutor.' });
if (media.provider === 'bunny') {
try {
const playback = signedBunnyEmbedUrl(media.external_id);
return { data: { provider: 'bunny', kind: 'embed', ...playback } };
} catch (error) {
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'A reprodução do Bunny ainda não está configurada.' });
throw error;
}
}
const source = media.playback_url || media.embed_url;
if (!source) return reply.code(409).send({ error: 'Esta aula não possui um endereço de vídeo reproduzível.' });
return { data: { provider: media.provider, kind: 'video', source } };
});
}; };

View File

@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js';
const courseParamsSchema = z.object({ const courseParamsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
@@ -11,14 +12,16 @@ const lessonParamsSchema = z.object({
}); });
const completionSchema = z.object({ const completionSchema = z.object({
completed: z.boolean(), completed: z.boolean().optional(),
watchedSeconds: z.coerce.number().int().min(0).optional(), watchedSeconds: z.coerce.number().int().min(0).optional(),
}); }).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Informe o progresso ou o status de conclusão.' });
const commentSchema = z.object({ const commentSchema = z.object({
lessonId: z.string().uuid().nullable().optional(), lessonId: z.string().uuid().nullable().optional(),
text: z.string().trim().min(1).max(4000), text: z.string().trim().min(1).max(4000),
}); });
const commentParamsSchema = z.object({ commentId: z.string().uuid() });
const replySchema = z.object({ text: z.string().trim().min(1).max(4000) });
const ensurePublishedCourse = async (courseId: string) => { const ensurePublishedCourse = async (courseId: string) => {
const result = await pool.query( const result = await pool.query(
@@ -28,11 +31,27 @@ const ensurePublishedCourse = async (courseId: string) => {
return result.rowCount === 1; return result.rowCount === 1;
}; };
const canViewCourseComments = async (courseId: string, user: { id: string; role: string }) => {
const result = await pool.query<{ status: string; instructor_id: string }>('select status, instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0];
return Boolean(course && (course.status === 'published' || user.role === 'admin' || course.instructor_id === user.id));
};
export const learningRoutes: FastifyPluginAsync = async (app) => { export const learningRoutes: FastifyPluginAsync = async (app) => {
const canModerateComment = async (commentId: string, user: { id: string; role: string }) => {
const result = await pool.query<{ instructor_id: string }>(
`select c.instructor_id
from comments cm join courses c on c.id = cm.course_id
where cm.id = $1`,
[commentId],
);
const comment = result.rows[0];
return Boolean(comment && (user.role === 'admin' || comment.instructor_id === user.id));
};
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => { app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
if (!(await ensurePublishedCourse(courseId))) { if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
const result = await pool.query( const result = await pool.query(
@@ -59,26 +78,28 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
); );
if (lesson.rowCount !== 1) { if (lesson.rowCount !== 1) {
return reply.code(404).send({ error: 'Lesson not found' }); return reply.code(404).send({ error: 'Aula não encontrada.' });
} }
const result = await pool.query( const result = await pool.query(
`insert into lesson_progress (lesson_id, user_id, watched_seconds, completed_at) `insert into lesson_progress (lesson_id, user_id, watched_seconds, completed_at)
values ($1, $2, $3, case when $4 then now() else null end) values ($1, $2, $3, case when $4 is true then now() else null end)
on conflict (lesson_id, user_id) do update set on conflict (lesson_id, user_id) do update set
watched_seconds = greatest(lesson_progress.watched_seconds, excluded.watched_seconds), watched_seconds = greatest(lesson_progress.watched_seconds, excluded.watched_seconds),
completed_at = case when $4 then coalesce(lesson_progress.completed_at, now()) else null end completed_at = case when $4 is true then coalesce(lesson_progress.completed_at, now())
when $4 is false then null
else lesson_progress.completed_at end
returning lesson_id as "lessonId", watched_seconds as "watchedSeconds", returning lesson_id as "lessonId", watched_seconds as "watchedSeconds",
completed_at as "completedAt", updated_at as "updatedAt"`, completed_at as "completedAt", updated_at as "updatedAt"`,
[lessonId, request.user.id, input.watchedSeconds || 0, input.completed], [lessonId, request.user.id, input.watchedSeconds || 0, input.completed ?? null],
); );
return { data: result.rows[0] }; return { data: result.rows[0] };
}); });
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => { app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
if (!(await ensurePublishedCourse(courseId))) { if (!(await canViewCourseComments(courseId, request.user))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
const result = await pool.query( const result = await pool.query(
@@ -99,13 +120,13 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
const input = commentSchema.parse(request.body); const input = commentSchema.parse(request.body);
if (!(await ensurePublishedCourse(courseId))) { if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
if (input.lessonId) { if (input.lessonId) {
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]); const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
if (lesson.rowCount !== 1) { if (lesson.rowCount !== 1) {
return reply.code(400).send({ error: 'Lesson does not belong to this course' }); return reply.code(400).send({ error: 'Esta aula não pertence ao curso.' });
} }
} }
@@ -123,4 +144,26 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
); );
return reply.code(201).send({ data: result.rows[0] }); return reply.code(201).send({ data: result.rows[0] });
}); });
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
const { commentId } = commentParamsSchema.parse(request.params);
const input = replySchema.parse(request.body);
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode responder a este comentário.' });
await pool.query(
`insert into comment_replies (comment_id, author_id, body)
values ($1, $2, $3)
on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`,
[commentId, request.user.id, input.text],
);
await recordAudit({ actorId: request.user.id, action: 'comment.replied', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
return { data: { id: commentId } };
});
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
const { commentId } = commentParamsSchema.parse(request.params);
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode moderar este comentário.' });
await pool.query('delete from comments where id = $1', [commentId]);
await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
return reply.code(204).send();
});
}; };

View File

@@ -4,6 +4,8 @@ import type { PoolClient } from 'pg';
import { z } from 'zod'; import { z } from 'zod';
import { courseSelect } from './courses.js'; import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js';
import { config } from '../config.js';
const mediaSchema = z.object({ const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80), provider: z.string().trim().min(1).max(80),
@@ -18,11 +20,13 @@ const assetSchema = z.object({
name: z.string().trim().min(1).max(255), name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']), kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(), url: z.string().url(),
sizeBytes: z.number().int().nonnegative().nullable().optional(),
description: z.string().max(2000).default(''), description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'), accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
}); });
const lessonSchema = z.object({ const lessonSchema = z.object({
id: z.string().uuid().optional(),
title: z.string().trim().min(1).max(255), title: z.string().trim().min(1).max(255),
description: z.string().max(5000).default(''), description: z.string().max(5000).default(''),
durationSeconds: z.number().int().nonnegative().nullable().optional(), durationSeconds: z.number().int().nonnegative().nullable().optional(),
@@ -31,12 +35,20 @@ const lessonSchema = z.object({
assets: z.array(assetSchema).default([]), assets: z.array(assetSchema).default([]),
}); });
const coverImageUrlSchema = z.string().url().refine((value) => {
const protocol = new URL(value).protocol;
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
}, { message: 'A URL da imagem de capa deve usar HTTPS.' });
const courseSchema = z.object({ const courseSchema = z.object({
title: z.string().trim().min(1).max(255), title: z.string().trim().min(1).max(255),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
description: z.string().max(10000).default(''), description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120), category: z.string().trim().min(1).max(120),
coverImageUrl: z.string().url().nullable().optional(), coverImageUrl: coverImageUrlSchema.nullable().optional(),
coverImageZoom: z.number().min(1).max(2.5).default(1),
coverImagePositionX: z.number().min(0).max(100).default(50),
coverImagePositionY: z.number().min(0).max(100).default(50),
status: z.enum(['draft', 'published']).default('draft'), status: z.enum(['draft', 'published']).default('draft'),
lessons: z.array(lessonSchema).min(1), lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]), assets: z.array(assetSchema).default([]),
@@ -45,6 +57,9 @@ const courseSchema = z.object({
const paramsSchema = z.object({ courseId: z.string().uuid() }); const paramsSchema = z.object({ courseId: z.string().uuid() });
type CourseInput = z.infer<typeof courseSchema>; type CourseInput = z.infer<typeof courseSchema>;
class CourseContentConflict extends Error {}
class CoursePublicationBlocked extends Error {}
function slugify(value: string) { function slugify(value: string) {
return value return value
.normalize('NFD') .normalize('NFD')
@@ -71,28 +86,75 @@ async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined,
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) { async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) { for (const asset of assets) {
await client.query( await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level) `insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7)`, values ($1, $2, $3, $4, $5, $6, $7, $8)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel], [parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.sizeBytes ?? null, asset.description, asset.accessLevel],
); );
} }
} }
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('Selecione uma trilha ativa criada pelo 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(`O curso não pode ser publicado enquanto “${unplayableLesson.title}” não tiver um vídeo pronto.`);
}
}
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) { async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
// This replacement strategy is safe before student progress exists. The next const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]);
// iteration will switch to per-lesson updates to preserve historical progress. const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
await client.query('delete from lessons where course_id = $1', [courseId]); const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
if (unknownLessonId) throw new Error('Uma aula em edição não pertence a este curso.');
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
if (removedLessonIds.length > 0) {
const usage = await client.query<{ id: string }>(
`select l.id
from lessons l
where l.id = any($1::uuid[])
and (exists (select 1 from lesson_progress lp where lp.lesson_id = l.id)
or exists (select 1 from comments c where c.lesson_id = l.id))`,
[removedLessonIds],
);
if (usage.rowCount) {
throw new CourseContentConflict('Uma aula com progresso ou comentários de alunos não pode ser removida. Mantenha-a ou arquive o curso.');
}
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
}
// Move existing positions away first so drag/reordering cannot violate the
// unique (course_id, position) constraint while updates are applied.
await client.query('update lessons set position = position + 10000 where course_id = $1', [courseId]);
await client.query('delete from assets where course_id = $1', [courseId]); await client.query('delete from assets where course_id = $1', [courseId]);
await insertAssets(client, { courseId }, input.assets); await insertAssets(client, { courseId }, input.assets);
for (const [index, lesson] of input.lessons.entries()) { for (const [index, lesson] of input.lessons.entries()) {
const lessonResult = await client.query<{ id: string }>( const lessonId = lesson.id ?? (await client.query<{ id: string }>(
`insert into lessons (course_id, title, description, position, duration_seconds, access_level) `insert into lessons (course_id, title, description, position, duration_seconds, access_level)
values ($1, $2, $3, $4, $5, $6) values ($1, $2, $3, $4, $5, $6)
returning id`, returning id`,
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel], [courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
)).rows[0].id;
if (lesson.id) {
await client.query(
`update lessons
set title = $2, description = $3, position = $4, duration_seconds = $5, access_level = $6
where id = $1`,
[lessonId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
); );
const lessonId = lessonResult.rows[0].id; await client.query('delete from lesson_media where lesson_id = $1', [lessonId]);
await client.query('delete from assets where lesson_id = $1', [lessonId]);
}
for (const media of lesson.media) { for (const media of lesson.media) {
await client.query( await client.query(
@@ -109,9 +171,9 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) { 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 result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0]; const course = result.rows[0];
if (!course) return { error: 'Course not found', statusCode: 404 as const }; if (!course) return { error: 'Curso não encontrado.', statusCode: 404 as const };
if (user.role !== 'admin' && course.instructor_id !== user.id) { if (user.role !== 'admin' && course.instructor_id !== user.id) {
return { error: 'You can only manage your own courses', statusCode: 403 as const }; return { error: 'Você só pode gerenciar seus próprios cursos.', statusCode: 403 as const };
} }
return null; return null;
} }
@@ -123,27 +185,50 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const filters = request.user.role === 'admin' const filters = request.user.role === 'admin'
? { sql: "where c.status <> 'archived' order by c.created_at desc", values: [] as string[] } ? { 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] }; : { 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); const result = await pool.query(`${courseSelect(false, true)} ${filters.sql}`, filters.values);
return { data: result.rows }; return { data: result.rows };
}); });
app.get('/analytics', manageAccess, async (request) => {
const isAdmin = request.user.role === 'admin';
const result = await pool.query(
`select
count(distinct c.id)::int as "courses",
count(distinct l.id)::int as "lessons",
count(distinct lp.user_id)::int as "learners",
count(distinct lp.lesson_id) filter (where lp.completed_at is not null)::int as "completedLessons",
count(distinct cm.id)::int as "comments"
from courses c
left join lessons l on l.course_id = c.id
left join lesson_progress lp on lp.lesson_id = l.id
left join comments cm on cm.course_id = c.id
where c.status <> 'archived' and ($1::boolean or c.instructor_id = $2)`,
[isAdmin, request.user.id],
);
return { data: result.rows[0] };
});
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');
const slug = await uniqueSlug(client, input.slug, input.title); const slug = await uniqueSlug(client, input.slug, input.title);
const course = await client.query<{ id: string }>( const course = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at) `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, 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) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::course_status, $10, case when $9::course_status = 'published'::course_status then now() else null end)
returning id`, returning id`,
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status, request.user.id], [slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status, request.user.id],
); );
await replaceCourseContents(client, course.rows[0].id, input); await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit'); await client.query('commit');
await recordAudit({ actorId: request.user.id, action: input.status === 'published' ? 'course.published' : 'course.drafted', subjectType: 'course', subjectId: course.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
return reply.code(201).send({ data: { id: course.rows[0].id } }); return reply.code(201).send({ data: { id: course.rows[0].id } });
} catch (error) { } catch (error) {
await client.query('rollback'); await client.query('rollback');
if (error instanceof CourseContentConflict || error instanceof CoursePublicationBlocked) return reply.code(409).send({ error: error.message });
throw error; throw error;
} finally { } finally {
client.release(); client.release();
@@ -155,6 +240,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 {
@@ -162,16 +249,18 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const slug = await uniqueSlug(client, input.slug, input.title, courseId); const slug = await uniqueSlug(client, input.slug, input.title, courseId);
await client.query( await client.query(
`update courses `update courses
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, cover_image_zoom = $7, cover_image_position_x = $8, cover_image_position_y = $9,
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end status = $10::course_status, published_at = case when $10::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
where id = $1`, where id = $1`,
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status], [courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
); );
await replaceCourseContents(client, courseId, input); await replaceCourseContents(client, courseId, input);
await client.query('commit'); await client.query('commit');
await recordAudit({ actorId: request.user.id, action: input.status === 'published' ? 'course.published' : 'course.drafted', subjectType: 'course', subjectId: courseId, metadata: { title: input.title }, ipAddress: request.ip });
return { data: { id: courseId } }; return { data: { id: courseId } };
} catch (error) { } catch (error) {
await client.query('rollback'); await client.query('rollback');
if (error instanceof CourseContentConflict || error instanceof CoursePublicationBlocked) return reply.code(409).send({ error: error.message });
throw error; throw error;
} finally { } finally {
client.release(); client.release();
@@ -182,7 +271,80 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const { courseId } = paramsSchema.parse(request.params); const { courseId } = paramsSchema.parse(request.params);
const accessError = await assertCanManageCourse(courseId, request.user); const accessError = await assertCanManageCourse(courseId, request.user);
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error }); if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]); const client = await pool.connect();
try {
await client.query('begin');
await client.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
await client.query('delete from learning_path_courses where course_id = $1', [courseId]);
await client.query('commit');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
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; cover_image_zoom: number; cover_image_position_x: number; cover_image_position_y: number; instructor_id: string }>(
`select title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, instructor_id from courses where id = $1`, [courseId],
);
if (!original.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Curso não encontrado.' });
}
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, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, instructor_id)
values ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9) returning id`,
[slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.cover_image_position_x, source.cover_image_position_y, 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();
}
});
}; };

192
server/src/routes/media.ts Normal file
View File

@@ -0,0 +1,192 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js';
import {
BunnyConfigurationError,
BunnyRequestError,
createBunnyVideo,
deleteBunnyVideo,
getBunnyVideo,
isBunnyConfigured,
uploadBunnyVideo,
} from '../providers/bunny.js';
import { config } from '../config.js';
import { VideoUploadValidationError, validateVideoUpload } from '../uploads/video.js';
import {
BunnyStorageConfigurationError,
BunnyStorageRequestError,
isBunnyStorageConfigured,
uploadBunnyAsset,
uploadBunnyCover,
} from '../providers/bunny-storage.js';
import { AssetUploadValidationError, validateAssetUpload } from '../uploads/asset.js';
const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'O Bunny Stream não conseguiu concluir esta solicitação. Tente novamente.' });
throw error;
}
function storageError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
if (error instanceof AssetUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
throw error;
}
async function persistBunnyStatus(video: { id: string; status: string; durationSeconds: number | null }) {
await pool.query(
`update lesson_media
set status = $2::media_status, duration_seconds = coalesce($3, duration_seconds)
where provider = 'bunny' and external_id = $1`,
[video.id, video.status, video.durationSeconds],
);
if (video.durationSeconds !== null) {
await pool.query(
`update lessons
set duration_seconds = $2
where id = (select lesson_id from lesson_media where provider = 'bunny' and external_id = $1)`,
[video.id, video.durationSeconds],
);
}
}
async function assertVideoAccess(videoId: string, user: { id: string; role: string }) {
if (user.role === 'admin') return;
const result = await pool.query<{ owner_id: string }>('select owner_id from bunny_video_uploads where video_id = $1', [videoId]);
if (result.rows[0]?.owner_id === user.id) return;
const legacy = await pool.query<{ instructor_id: string }>(
`select c.instructor_id
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where lm.provider = 'bunny' and lm.external_id = $1
limit 1`,
[videoId],
);
if (legacy.rows[0]?.instructor_id === user.id) return;
throw new VideoUploadValidationError('Você não tem acesso a este vídeo.', 403);
}
export const mediaRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
const coverAccess = { preHandler: app.authenticate };
app.get('/bunny/config', manageAccess, async () => ({
data: {
configured: isBunnyConfigured(),
maxUploadBytes: config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
app.get('/covers/config', coverAccess, async () => ({
data: {
configured: isBunnyStorageConfigured(),
maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
app.get('/assets/config', manageAccess, async () => ({
data: {
configured: isBunnyStorageConfigured(),
maxUploadBytes: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
app.post('/covers', coverAccess, async (request, reply) => {
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
const body = request.body;
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de imagem.' });
if (body.length === 0) return reply.code(400).send({ error: 'Escolha uma imagem para enviar.' });
if (body.length > config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024) {
return reply.code(413).send({ error: `Cover image is larger than the ${config.BUNNY_COVER_MAX_UPLOAD_MB} MB upload limit.` });
}
try {
const cover = await uploadBunnyCover({ body, contentType });
await recordAudit({ actorId: request.user.id, action: 'media.cover.uploaded', subjectType: 'cover', metadata: { key: cover.key }, ipAddress: request.ip });
return reply.code(201).send({ data: cover });
} catch (error) {
return storageError(reply, error);
}
});
app.post('/assets', manageAccess, async (request, reply) => {
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
const body = request.body;
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de material.' });
try {
const asset = validateAssetUpload(body, contentType, config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024);
const stored = await uploadBunnyAsset({ body, contentType: asset.contentType, extension: asset.extension });
await recordAudit({ actorId: request.user.id, action: 'media.asset.uploaded', subjectType: 'asset', metadata: { key: stored.key, kind: asset.kind, sizeBytes: body.length }, ipAddress: request.ip });
return reply.code(201).send({ data: { ...stored, kind: asset.kind, sizeBytes: body.length } });
} catch (error) {
return storageError(reply, error);
}
});
app.post('/bunny/videos', manageAccess, async (request, reply) => {
const input = createVideoSchema.parse(request.body);
try {
const video = await createBunnyVideo(input.title);
await pool.query('insert into bunny_video_uploads (video_id, owner_id) values ($1, $2)', [video.id, request.user.id]);
await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip });
return reply.code(201).send({ data: video });
} catch (error) {
return providerError(reply, error);
}
});
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
try {
await assertVideoAccess(videoId, request.user);
} catch (error) {
return providerError(reply, error);
}
const contentLength = Number(request.headers['content-length'] || 0);
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
if (contentLength > maxBytes) {
return reply.code(413).send({ error: `Video is larger than the ${config.BUNNY_MAX_UPLOAD_MB} MB upload limit.` });
}
try {
const body = validateVideoUpload(request.body as import('node:stream').Readable, request.headers['content-type'], maxBytes);
const video = await uploadBunnyVideo(videoId, body as unknown as ReadableStream, request.headers['content-type']);
await persistBunnyStatus(video);
await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
return { data: video };
} catch (error) {
return providerError(reply, error);
}
});
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
try {
await assertVideoAccess(videoId, request.user);
const video = await getBunnyVideo(videoId);
await persistBunnyStatus(video);
return { data: video };
} catch (error) {
return providerError(reply, error);
}
});
app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
try {
await assertVideoAccess(videoId, request.user);
await deleteBunnyVideo(videoId);
await pool.query('delete from bunny_video_uploads where video_id = $1', [videoId]);
await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
return providerError(reply, error);
}
});
};

View File

@@ -0,0 +1,131 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js';
import { courseSelect } from './courses.js';
const profileParamsSchema = z.object({ profileId: z.string().uuid() });
const instructorParamsSchema = z.object({ instructorId: z.string().uuid() });
const optionalUrl = z.union([z.string().url().max(500), z.literal(''), z.null()]).optional()
.transform((value) => value || null);
const profileUpdateSchema = z.object({
name: z.string().trim().min(2).max(120),
avatarImageUrl: optionalUrl,
headline: z.string().trim().max(180),
bio: z.string().trim().max(3_000),
websiteUrl: optionalUrl,
linkedinUrl: optionalUrl,
instagramUrl: optionalUrl,
youtubeUrl: optionalUrl,
isPublic: z.boolean().optional(),
});
type ProfileRow = {
id: string;
email?: string;
name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
isPublic: boolean;
};
const profileColumns = `
id,
display_name as name,
role,
avatar_image_url as "avatarImageUrl",
profile_headline as headline,
profile_bio as bio,
website_url as "websiteUrl",
linkedin_url as "linkedinUrl",
instagram_url as "instagramUrl",
youtube_url as "youtubeUrl",
profile_is_public as "isPublic"`;
const withoutProtectedMedia = (course: Record<string, unknown>) => ({
...course,
assets: [],
lessons: Array.isArray(course.lessons)
? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] }))
: [],
});
export const profileRoutes: FastifyPluginAsync = async (app) => {
app.get('/me', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query<ProfileRow>(
`select ${profileColumns}, email from users where id = $1`,
[request.user.id],
);
return { data: result.rows[0] };
});
app.patch('/me', { preHandler: app.authenticate }, async (request, reply) => {
const input = profileUpdateSchema.parse(request.body);
const result = await pool.query<ProfileRow>(
`update users set
display_name = $2,
avatar_image_url = $3,
profile_headline = $4,
profile_bio = $5,
website_url = $6,
linkedin_url = $7,
instagram_url = $8,
youtube_url = $9,
profile_is_public = coalesce($10, profile_is_public),
updated_at = now()
where id = $1
returning ${profileColumns}, email`,
[
request.user.id,
input.name,
input.avatarImageUrl,
input.headline,
input.bio,
input.websiteUrl,
input.linkedinUrl,
input.instagramUrl,
input.youtubeUrl,
input.isPublic,
],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Perfil não encontrado.' });
await recordAudit({ actorId: request.user.id, action: 'profile.updated', subjectType: 'user', subjectId: request.user.id, metadata: { public: profile.isPublic }, ipAddress: request.ip });
return { data: profile };
});
const getPublicProfile = async (profileId: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }, instructorOnly = false) => {
const result = await pool.query<ProfileRow>(
`select ${profileColumns}
from users
where id = $1 and profile_is_public${instructorOnly ? " and role in ('instructor', 'admin')" : ''}`,
[profileId],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: instructorOnly ? 'Perfil de instrutor não encontrado.' : 'Perfil não encontrado ou não está público.' });
const canPublishCourses = profile.role === 'instructor' || profile.role === 'admin';
const courses = canPublishCourses
? await pool.query(`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`, [profileId])
: { rows: [] };
return { data: { ...profile, courses: courses.rows.map(withoutProtectedMedia) } };
};
app.get('/:profileId', async (request, reply) => {
const { profileId } = profileParamsSchema.parse(request.params);
return getPublicProfile(profileId, reply);
});
app.get('/instructors/:instructorId', async (request, reply) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
return getPublicProfile(instructorId, reply, true);
});
};

View File

@@ -0,0 +1,23 @@
import { config } from '../config.js';
import nodemailer from 'nodemailer';
type WelcomeRecipient = { name: string; email: string };
export async function sendWelcomeEmail(recipient: WelcomeRecipient) {
if (!config.SMTP_HOST || !config.SMTP_PORT || !config.SMTP_USER || !config.SMTP_PASS || !config.MAIL_FROM) return false;
const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[character] || character));
const transporter = nodemailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_PORT === 465,
auth: { user: config.SMTP_USER, pass: config.SMTP_PASS },
});
await transporter.sendMail({
from: config.MAIL_FROM,
to: recipient.email,
subject: 'Bem-vindo(a) ao Compor HUB',
html: `<main style="font-family:Arial,sans-serif;color:#18181b;line-height:1.55"><h1>Bem-vindo(a), ${escapedName}!</h1><p>Sua conta no <strong>Compor HUB</strong> já está pronta.</p><p>Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.</p><p><a href="${config.FRONTEND_ORIGIN}" style="display:inline-block;background:#f97316;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;font-weight:700">Acessar Compor HUB</a></p></main>`,
});
return true;
}

View File

@@ -0,0 +1,52 @@
type AssetKind = 'document' | 'spreadsheet' | 'archive';
type AssetDefinition = {
kind: AssetKind;
extension: string;
needsZipSignature?: boolean;
needsOleSignature?: boolean;
needsPdfSignature?: boolean;
needsTextContent?: boolean;
};
const allowedAssetTypes = new Map<string, AssetDefinition>([
['application/pdf', { kind: 'document', extension: 'pdf', needsPdfSignature: true }],
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', { kind: 'document', extension: 'docx', needsZipSignature: true }],
['application/msword', { kind: 'document', extension: 'doc', needsOleSignature: true }],
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', { kind: 'spreadsheet', extension: 'xlsx', needsZipSignature: true }],
['application/vnd.ms-excel', { kind: 'spreadsheet', extension: 'xls', needsOleSignature: true }],
['text/csv', { kind: 'spreadsheet', extension: 'csv', needsTextContent: true }],
['application/zip', { kind: 'archive', extension: 'zip', needsZipSignature: true }],
]);
export class AssetUploadValidationError extends Error {
constructor(message: string, public readonly statusCode: 400 | 413 | 415) {
super(message);
}
}
const zipSignature = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
const oleSignature = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
export function validateAssetUpload(body: Buffer, contentType: string | undefined, maxBytes: number) {
const normalizedType = contentType?.split(';')[0]?.trim().toLowerCase();
const definition = normalizedType ? allowedAssetTypes.get(normalizedType) : undefined;
if (!definition) {
throw new AssetUploadValidationError('Envie apenas arquivos PDF, DOCX, XLSX, CSV ou ZIP.', 415);
}
if (body.length === 0) throw new AssetUploadValidationError('Escolha um arquivo para enviar.', 400);
if (body.length > maxBytes) throw new AssetUploadValidationError(`O arquivo excede o limite de ${Math.floor(maxBytes / 1024 / 1024)} MB.`, 413);
if (definition.needsPdfSignature && !body.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
throw new AssetUploadValidationError('O arquivo enviado não é um PDF válido.', 415);
}
if (definition.needsZipSignature && !body.subarray(0, 4).equals(zipSignature)) {
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
}
if (definition.needsOleSignature && !body.subarray(0, 8).equals(oleSignature)) {
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
}
if (definition.needsTextContent && body.subarray(0, Math.min(body.length, 4096)).includes(0)) {
throw new AssetUploadValidationError('O arquivo CSV deve conter texto.', 415);
}
return { kind: definition.kind, extension: definition.extension, contentType: normalizedType as string };
}

View File

@@ -0,0 +1,62 @@
import { Transform, type Readable } from 'node:stream';
const allowedContentTypes = new Set(['video/mp4', 'video/webm', 'video/quicktime']);
export class VideoUploadValidationError extends Error {
constructor(message: string, public readonly statusCode: 403 | 413 | 415) {
super(message);
}
}
const isRecognizedVideo = (header: Buffer) => {
// ISO Base Media (MP4/MOV): bytes 4–7 identify the file type box.
const isMp4Family = header.length >= 12 && header.subarray(4, 8).toString('ascii') === 'ftyp';
// WebM/Matroska EBML signature.
const isWebm = header.length >= 4 && header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
return isMp4Family || isWebm;
};
class ValidatedVideoStream extends Transform {
private totalBytes = 0;
private header = Buffer.alloc(0);
private validated = false;
constructor(private readonly maxBytes: number) {
super();
}
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
this.totalBytes += chunk.length;
if (this.totalBytes > this.maxBytes) {
callback(new VideoUploadValidationError('Video is larger than the configured upload limit.', 413));
return;
}
if (!this.validated) {
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
if (this.header.length >= 12) {
if (!isRecognizedVideo(this.header)) {
callback(new VideoUploadValidationError('Envie apenas arquivos de vídeo MP4, WebM ou MOV válidos.', 415));
return;
}
this.validated = true;
}
}
callback(null, chunk);
}
override _flush(callback: (error?: Error | null) => void) {
if (!this.validated) {
callback(new VideoUploadValidationError('O arquivo enviado não é um vídeo válido.', 415));
return;
}
callback();
}
}
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
throw new VideoUploadValidationError('Envie apenas vídeos MP4, WebM ou MOV.', 415);
}
return input.pipe(new ValidatedVideoStream(maxBytes));
}

View File

@@ -10,6 +10,23 @@ export interface ApiUser {
role: ApiRole; role: ApiRole;
} }
export interface Profile {
id: string;
email?: string;
name: string;
role: ApiRole;
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
isPublic: boolean;
}
export type ProfileInput = Omit<Profile, 'id' | 'email' | 'role'>;
export interface ManagedUser { export interface ManagedUser {
id: string; id: string;
email: string; email: string;
@@ -18,6 +35,66 @@ export interface ManagedUser {
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
} }
export interface ManagedUserDetail extends ManagedUser {
completedLessons: number;
lastLearningAt: string | null;
}
export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; }
export interface InstructorAnalytics { courses: number; lessons: number; learners: number; completedLessons: number; comments: number; }
export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; }
export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; }
export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; }
export interface LearningPathCourse {
id: string;
title: string;
status: 'draft' | 'published' | 'archived';
coverImageUrl: string | null;
position: number;
}
export interface LearningPath {
id: string;
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published' | 'archived';
publishedAt: string | null;
createdAt: string;
courses: LearningPathCourse[];
}
export interface LearningPathInput {
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published';
courseIds: string[];
}
export interface TrailCourseOption {
id: string;
title: string;
status: 'draft' | 'published';
category: string;
coverImageUrl: string | null;
}
export interface HomeBannerInput {
kind: 'course' | 'custom';
courseId: string | null;
imageUrl: string | null;
title: string;
description: string;
}
export interface HomeBanner extends HomeBannerInput {
id: string;
position: number;
courseTitle: string | null;
courseDescription: string | null;
courseImageUrl: string | null;
}
interface Session { interface Session {
token: string; token: string;
@@ -49,6 +126,26 @@ export function clearSession() {
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
} }
export async function downloadCourseAsset(assetId: string, filename: string) {
const session = getSession();
const headers = new Headers();
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}/courses/assets/${assetId}/download`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error || 'Não foi possível baixar este material.', response.status);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> { export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const session = getSession(); const session = getSession();
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
@@ -60,11 +157,31 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new ApiError(body.error || 'The request could not be completed', response.status); throw new ApiError(body.error || 'Não foi possível concluir a solicitação.', response.status);
} }
return body as T; return body as T;
} }
async function uploadRequest<T>(path: string, file: File): Promise<T> {
const session = getSession();
const headers = new Headers({ 'Content-Type': file.type || 'application/octet-stream' });
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'Não foi possível concluir o envio do vídeo.', response.status);
return body as T;
}
async function uploadImageRequest<T>(path: string, file: File): Promise<T> {
const session = getSession();
const headers = new Headers({ 'Content-Type': file.type || 'application/octet-stream' });
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'POST', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'Não foi possível enviar a imagem de capa.', response.status);
return body as T;
}
export const authApi = { export const authApi = {
async login(email: string, password: string) { async login(email: string, password: string) {
return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); return apiRequest<Session>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) });
@@ -75,16 +192,79 @@ export const authApi = {
async me() { async me() {
return apiRequest<{ user: ApiUser }>('/auth/me'); return apiRequest<{ user: ApiUser }>('/auth/me');
}, },
async acceptInvitation(token: string, name: string, password: string) {
return apiRequest<Session>('/auth/accept-invitation', { method: 'POST', body: JSON.stringify({ token, name, password }) });
},
async resetPassword(token: string, password: string) {
return apiRequest<void>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
},
}; };
export const adminApi = { export const adminApi = {
async dashboard() { return apiRequest<{ data: PlatformAnalytics }>('/admin/dashboard'); },
async listUsers() { async listUsers() {
return apiRequest<{ data: ManagedUser[] }>('/admin/users'); return apiRequest<{ data: ManagedUser[] }>('/admin/users');
}, },
async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) { async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) {
return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, { return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(update), body: JSON.stringify(update),
}); });
}, },
async invite(email: string, role: 'instructor' = 'instructor') {
return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) });
},
async passwordReset(userId: string) {
return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' });
},
async deleteUser(userId: string) { return apiRequest<void>(`/admin/users/${userId}`, { method: 'DELETE' }); },
async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); },
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 listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
async listPathCourses() { return apiRequest<{ data: TrailCourseOption[] }>('/admin/path-courses'); },
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
async homeBanners() { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners'); },
async updateHomeBanners(banners: HomeBannerInput[]) { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners', { method: 'PUT', body: JSON.stringify({ banners }) }); },
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }> } }>('/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 profileApi = {
async me() { return apiRequest<{ data: Profile }>('/profiles/me'); },
async update(input: ProfileInput) { return apiRequest<{ data: Profile }>('/profiles/me', { method: 'PATCH', body: JSON.stringify(input) }); },
async public(profileId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/${profileId}`); },
async instructor(instructorId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/instructors/${instructorId}`); },
};
export const instructorApi = {
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); },
async createBunnyVideo(title: string) {
return apiRequest<{ data: BunnyVideo }>('/manage/media/bunny/videos', { method: 'POST', body: JSON.stringify({ title }) });
},
async uploadBunnyVideo(videoId: string, file: File) {
return uploadRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}/upload`, file);
},
async bunnyVideoStatus(videoId: string) {
return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`);
},
async deleteBunnyVideo(videoId: string) {
return apiRequest<void>(`/manage/media/bunny/videos/${videoId}`, { method: 'DELETE' });
},
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
async assetConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/assets/config'); },
async uploadAsset(file: File) { return uploadImageRequest<{ data: { key: string; assetUrl: string; kind: 'document' | 'spreadsheet' | 'archive'; sizeBytes: number } }>('/manage/media/assets', file); },
};
export const courseApi = {
async categories() { return apiRequest<{ data: string[] }>('/courses/categories'); },
async playback(courseId: string, lessonId: string) {
return apiRequest<{ data: LessonPlayback }>(`/courses/${courseId}/lessons/${lessonId}/playback`);
},
}; };

View File

@@ -1,4 +1,4 @@
import { Attachment, AttachmentType, Comment, Course, Lesson } from '../types'; import { Attachment, AttachmentType, Comment, Course, Lesson, LessonMedia } from '../types';
import { apiRequest } from './api'; import { apiRequest } from './api';
export const initDB = async (): Promise<void> => undefined; export const initDB = async (): Promise<void> => undefined;
@@ -8,28 +8,77 @@ type ApiAsset = {
name: string; name: string;
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link'; kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
url: string; url: string;
sizeBytes: number | null;
createdAt: string;
description: string; description: string;
}; };
type ApiCourse = { export type ApiCourse = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
category: string; category: string;
coverImageUrl: string | null; coverImageUrl: string | null;
instructor: { name: string }; coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published' | 'archived';
progress?: number;
lastActivity?: string | null;
instructor: { id: string; name: string };
lessons: Array<{ lessons: Array<{
id: string; id: string;
title: string; title: string;
description: string; description: string;
durationSeconds: number | null; durationSeconds: number | null;
accessLevel: 'public' | 'enrolled'; accessLevel: 'public' | 'enrolled';
media: Array<{ provider: string; playbackUrl: string | null; embedUrl: string | null }>; media: Array<{
id: string;
provider: string;
externalId: string;
playbackUrl: string | null;
embedUrl: string | null;
status: 'processing' | 'ready' | 'failed';
durationSeconds: number | null;
}>;
assets: ApiAsset[]; assets: ApiAsset[];
}>; }>;
assets: ApiAsset[]; assets: ApiAsset[];
}; };
export type PublicLearningPath = {
id: string;
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
courses: Array<{
id: string;
title: string;
category: string;
coverImageUrl: string | null;
position: number;
}>;
};
export type PublicHomeBanner = {
id: string;
kind: 'course' | 'custom';
courseId: string | null;
imageUrl: string | null;
title: string;
description: string;
position: number;
courseTitle: string | null;
courseDescription: string | null;
courseImageUrl: string | null;
coverImageZoom: number | null;
coverImagePositionX: number | null;
coverImagePositionY: number | null;
};
const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => { const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => {
if (kind === 'document') return 'doc'; if (kind === 'document') return 'doc';
if (kind === 'archive') return 'zip'; if (kind === 'archive') return 'zip';
@@ -41,33 +90,73 @@ const toAttachment = (asset: ApiAsset): Attachment => ({
name: asset.name, name: asset.name,
type: toAttachmentType(asset.kind), type: toAttachmentType(asset.kind),
url: asset.url, url: asset.url,
size: asset.sizeBytes === null ? undefined : formatBytes(asset.sizeBytes),
sizeBytes: asset.sizeBytes ?? undefined,
createdAt: asset.createdAt,
description: asset.description, description: asset.description,
}); });
const toDuration = (seconds: number | null) => { const formatBytes = (bytes: number) => {
if (!seconds) return '—'; if (bytes < 1024) return `${bytes} B`;
const minutes = Math.round(seconds / 60); const units = ['KB', 'MB', 'GB'];
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`; const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)) - 1, units.length - 1);
return `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(bytes / 1024 ** (index + 1))} ${units[index]}`;
}; };
const toCourse = (course: ApiCourse): Course => ({ const toDuration = (seconds: number | null) => {
if (!seconds || seconds < 1) return '—';
const totalSeconds = Math.round(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
if (minutes) return `${minutes}min`;
return `${remainingSeconds}s`;
};
const toCourseDuration = (lessons: ApiCourse['lessons']) => {
const seconds = lessons.reduce((total, lesson) => total + (lesson.media[0]?.durationSeconds ?? lesson.durationSeconds ?? 0), 0);
if (seconds) return toDuration(seconds);
return lessons.length ? 'Processando' : '—';
};
export const toCourse = (course: ApiCourse): Course => ({
id: course.id, id: course.id,
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',
coverImageZoom: course.coverImageZoom || 1,
coverImagePositionX: course.coverImagePositionX ?? 50,
coverImagePositionY: course.coverImagePositionY ?? 50,
instructor: course.instructor.name, instructor: course.instructor.name,
duration: `${course.lessons.length} Aulas`, instructorId: course.instructor.id,
lessons: course.lessons.map((lesson): Lesson => ({ status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress,
lastActivity: course.lastActivity || undefined,
duration: toCourseDuration(course.lessons),
lessons: course.lessons.map((lesson): Lesson => {
const media: LessonMedia[] = lesson.media.map((item) => ({
id: item.id,
provider: item.provider,
externalId: item.externalId,
playbackUrl: item.playbackUrl,
embedUrl: item.embedUrl,
status: item.status,
durationSeconds: item.durationSeconds,
}));
return {
id: lesson.id, id: lesson.id,
title: lesson.title, title: lesson.title,
description: lesson.description, description: lesson.description,
duration: toDuration(lesson.durationSeconds), duration: toDuration(media[0]?.durationSeconds ?? lesson.durationSeconds),
isFree: lesson.accessLevel === 'public', isFree: lesson.accessLevel === 'public',
videoUrl: lesson.media[0]?.playbackUrl || lesson.media[0]?.embedUrl || '', videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
mediaProvider: lesson.media[0]?.provider, mediaProvider: media[0]?.provider,
media,
attachments: lesson.assets.map(toAttachment), attachments: lesson.assets.map(toAttachment),
})), };
}),
attachments: course.assets.map(toAttachment), attachments: course.assets.map(toAttachment),
}); });
@@ -89,23 +178,45 @@ const toAssetPayload = (attachment: Attachment) => ({
name: attachment.name, name: attachment.name,
kind: toAssetKind(attachment.type), kind: toAssetKind(attachment.type),
url: attachment.url, url: attachment.url,
sizeBytes: attachment.sizeBytes ?? parseSizeBytes(attachment.size),
description: attachment.description || '', description: attachment.description || '',
accessLevel: 'enrolled' as const, accessLevel: 'enrolled' as const,
}); });
const parseSizeBytes = (value?: string) => {
if (!value) return null;
const match = value.trim().replace(',', '.').match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i);
if (!match) return null;
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
return Math.round(Number(match[1]) * units[match[2].toUpperCase()]);
};
const toCoursePayload = (course: Course) => ({ const toCoursePayload = (course: Course) => ({
title: course.title, title: course.title,
description: course.description, description: course.description,
category: course.category, category: course.category,
coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null, coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
status: 'published' as const, coverImageZoom: course.coverImageZoom || 1,
coverImagePositionX: course.coverImagePositionX ?? 50,
coverImagePositionY: course.coverImagePositionY ?? 50,
status: course.status || 'published',
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({ lessons: course.lessons.map((lesson) => ({
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
title: lesson.title, title: lesson.title,
description: lesson.description || '', description: lesson.description || '',
durationSeconds: toSeconds(lesson.duration), durationSeconds: lesson.media?.[0]?.durationSeconds ?? toSeconds(lesson.duration),
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const, accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
media: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:') media: lesson.media?.length
? lesson.media.map((media) => ({
provider: media.provider,
externalId: media.externalId,
playbackUrl: media.playbackUrl || null,
embedUrl: media.embedUrl || null,
status: media.status,
durationSeconds: media.durationSeconds || null,
}))
: lesson.videoUrl && !lesson.videoUrl.startsWith('blob:')
? [{ provider: lesson.mediaProvider || 'external', externalId: lesson.videoUrl, playbackUrl: lesson.videoUrl, status: 'ready' as const }] ? [{ 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), assets: (lesson.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
@@ -117,11 +228,50 @@ export const getCourses = async (): Promise<Course[]> => {
return response.data.map(toCourse); return response.data.map(toCourse);
}; };
export type InstructorProfileData = {
id: string;
name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
courses: Course[];
};
export const getInstructorProfile = async (instructorId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/instructors/${instructorId}`);
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getPublicProfile = async (profileId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/${profileId}`);
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getLearningPaths = async (): Promise<PublicLearningPath[]> => {
const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths');
return response.data;
};
export const getHomeBanners = async (): Promise<PublicHomeBanner[]> => {
const response = await apiRequest<{ data: PublicHomeBanner[] }>('/courses/home-banners');
return response.data;
};
export const getManagedCourses = async (): Promise<Course[]> => { export const getManagedCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses'); const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
return response.data.map(toCourse); return response.data.map(toCourse);
}; };
export const getMyLearningCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/courses/me/learning');
return response.data.map(toCourse);
};
export const getCourseById = async (id: string): Promise<Course | null> => { export const getCourseById = async (id: string): Promise<Course | null> => {
try { try {
const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`); const response = await apiRequest<{ data: ApiCourse }>(`/courses/${id}`);
@@ -145,6 +295,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;
@@ -182,16 +336,35 @@ export const saveComment = async (comment: Pick<Comment, 'courseId' | 'lessonId'
return toComment(response.data); return toComment(response.data);
}; };
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => { export const replyToComment = async (commentId: string, text: string): Promise<void> => {
const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`); await apiRequest(`/comments/${commentId}/reply`, { method: 'PUT', body: JSON.stringify({ text }) });
return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId);
}; };
export const saveLessonCompletion = async (lessonId: string, completed: boolean): Promise<void> => { export const moderateComment = async (commentId: string): Promise<void> => {
await apiRequest(`/comments/${commentId}`, { method: 'DELETE' });
};
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => {
const progress = await getLessonProgress(courseId);
return progress.filter((item) => item.completedAt).map((item) => item.lessonId);
};
export type LessonProgress = { lessonId: string; watchedSeconds: number; completedAt: string | null };
export const getLessonProgress = async (courseId: string): Promise<LessonProgress[]> => {
const response = await apiRequest<{ data: LessonProgress[] }>(`/courses/${courseId}/progress`);
return response.data;
};
export const saveLessonCompletion = async (lessonId: string, completed: boolean, watchedSeconds?: number): Promise<void> => {
await apiRequest(`/lessons/${lessonId}/progress`, { await apiRequest(`/lessons/${lessonId}/progress`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ completed }), body: JSON.stringify({ completed, watchedSeconds }),
}); });
}; };
export const saveLessonProgress = async (lessonId: string, watchedSeconds: number): Promise<void> => {
await apiRequest(`/lessons/${lessonId}/progress`, { method: 'PUT', body: JSON.stringify({ watchedSeconds }) });
};
export const incrementViews = async (_courseId: string): Promise<void> => undefined; export const incrementViews = async (_courseId: string): Promise<void> => undefined;

View File

@@ -8,17 +8,32 @@ export interface Attachment {
type: AttachmentType; type: AttachmentType;
url: string; url: string;
size?: string; // e.g. "4.2 MB" size?: string; // e.g. "4.2 MB"
sizeBytes?: number;
createdAt?: string;
description?: string; description?: string;
lessonId?: string; // Associated lesson or global course asset lessonId?: string; // Associated lesson or global course asset
downloadCount?: number; downloadCount?: number;
} }
export type LessonMediaStatus = 'processing' | 'ready' | 'failed';
export interface LessonMedia {
id?: string;
provider: string;
externalId: string;
playbackUrl?: string | null;
embedUrl?: string | null;
status: LessonMediaStatus;
durationSeconds?: number | null;
}
export interface Lesson { export interface Lesson {
id: string; id: string;
title: string; title: string;
duration: string; duration: string;
videoUrl: string; videoUrl: string;
mediaProvider?: string; mediaProvider?: string;
media?: LessonMedia[];
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
@@ -29,15 +44,21 @@ export interface Course {
title: string; title: string;
category: string; category: string;
thumbnail: string; thumbnail: string;
coverImageZoom?: number;
coverImagePositionX?: number;
coverImagePositionY?: number;
lessons: Lesson[]; lessons: Lesson[];
progress?: number; // 0 to 100 progress?: number; // 0 to 100
views?: number; views?: number;
duration: string; // Total duration or lesson count duration: string; // Total duration or lesson count
description: string; description: string;
instructor?: string; instructor?: string;
instructorId?: string;
instructorRole?: string; instructorRole?: string;
tips?: string[]; // Key takeaways / instructor tips tips?: string[]; // Key takeaways / instructor tips
attachments?: Attachment[]; // Downloadable materials across entire course attachments?: Attachment[]; // Downloadable materials across entire course
status?: 'draft' | 'published';
lastActivity?: string;
} }
export interface Section { export interface Section {