feat: LiveServer-M1 v1 — educational video conferencing platform #1

Merged
joylessorchid merged 1 commits from master into main 2026-03-22 14:02:49 +03:00
59 changed files with 12792 additions and 240 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.*
!.env.example
ai-agent
*.md

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# === Domain & SSL (оставь пустым для локалки) ===
# DOMAIN=live.example.com
# ACME_EMAIL=admin@example.com
# === Local dev protection (работает когда DOMAIN не задан) ===
# Ключ доступа: открой http://192.168.x.x:3000?key=mySecretKey123
DEV_ACCESS_KEY=mySecretKey123
# Разрешённые IP (через запятую). localhost всегда разрешён.
# ALLOWED_IPS=192.168.1.10,192.168.1.11
# === Database ===
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/liveserver
POSTGRES_PASSWORD=postgres
# === LiveKit ===
LIVEKIT_URL=wss://your-project.livekit.cloud
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your-api-key
LIVEKIT_API_SECRET=your-api-secret
# === AI Agent (Python) ===
DEEPGRAM_API_KEY=your-deepgram-key
OPENAI_API_KEY=your-openai-key
# === Storage (MinIO/S3) ===
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=liveserver
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# === Auth ===
BETTER_AUTH_SECRET=your-secret-key-change-in-production
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000

276
.gitignore vendored
View File

@@ -1,258 +1,56 @@
# ---> VisualStudioCode
# Dependencies
node_modules/
# Next.js
.next/
out/
# Build
dist/
build/Release
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.idea/
*.iws
*.iml
*.ipr
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> Node
# OS
.DS_Store
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
# TypeScript
*.tsbuildinfo
# Optional npm cache directory
.npm
# Python
__pycache__/
*.pyc
*.pyo
ai-agent/.env
# Optional eslint cache
.eslintcache
# Docker
docker-compose.override.yml
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
# Misc
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
.eslintcache
coverage/
*.lcov

167
CLAUDE.md Normal file
View File

@@ -0,0 +1,167 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**LiveServer-M1** — образовательная видеоконференц-платформа на базе LiveKit.
Ключевые фичи:
- AI-ассистент: авто-подключение к комнатам, real-time транскрипция (Deepgram), суммаризация лекций (OpenAI GPT)
- Защита от Zoom-bombing: lobby/waiting room, PIN-коды, kick/ban по session fingerprint, panic button
- Пост-лекционная панель: чат, файлы, транскрипт, AI-саммари, экспорт в PDF/Google Drive
Полная спецификация: **`PROMPT.md`**
## Tech Stack
| Слой | Технологии |
|------|-----------|
| Frontend | Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS v4 |
| Video | LiveKit Cloud → self-hosted, `@livekit/components-react` |
| Backend | Next.js Route Handlers, `livekit-server-sdk` |
| Auth | `better-auth` + Prisma adapter |
| AI Agent | Python, `livekit-agents`, `livekit-plugins-deepgram` (STT), `livekit-plugins-openai` (LLM) |
| DB | PostgreSQL + Prisma 7 ORM |
| Storage | MinIO (S3-compatible) |
| Proxy | Traefik v3 + Let's Encrypt (prod only) |
## Architecture
```
├── src/
│ ├── app/
│ │ ├── api/ # 15 API route handlers
│ │ │ ├── auth/[...all]/ # better-auth catch-all
│ │ │ ├── rooms/ # CRUD, by-code lookup
│ │ │ ├── rooms/[roomId]/ # join, lobby, lobby/stream (SSE), chat, files, moderate, hand-raise, start, end
│ │ │ └── livekit/ # token generation, webhook receiver
│ │ ├── (dashboard)/ # Host/Admin pages
│ │ ├── join/[code]/ # Guest entry
│ │ ├── room/[code]/ # Video room (LiveKit)
│ │ ├── login/ & register/ # Auth pages
│ │ └── page.tsx # Landing
│ ├── components/
│ │ ├── room/ # ChatPanel, ModerationPanel
│ │ └── lobby/ # WaitingRoom, LobbyManager
│ ├── lib/ # prisma, auth, auth-helpers, livekit
│ ├── middleware.ts # Dev protection (DEV_ACCESS_KEY, ALLOWED_IPS)
│ └── types/
├── ai-agent/ # Python LiveKit Agent
├── prisma/schema.prisma # 9 models, 3 enums
├── docker-compose.yml # Base: postgres, minio, ai-agent, app
├── docker-compose.override.yml # Local dev: direct ports, no SSL
├── docker-compose.prod.yml # Traefik + Let's Encrypt
└── Dockerfile # Multi-stage Next.js standalone build
```
### Key Data Flows
1. **Guest join (with lobby):**
Guest → POST `/api/rooms/[id]/join` → LobbyEntry(PENDING) → SSE `/api/rooms/[id]/lobby/stream` → Host approves → LiveKit token → connect
2. **Real-time transcription:**
Audio tracks → AI Agent (Deepgram STT) → DataChannel → Live captions
3. **Post-lecture (`room_finished` webhook):**
Transcript → OpenAI GPT → LectureArtifact → `/lectures/[id]`
### User Roles
- **ADMIN** — global panel, all rooms monitoring
- **HOST** — room creation, moderation, security
- **GUEST** — join by link, no registration required
### Security Layers
- `sessionFingerprint` (required) for bans — IP is secondary only
- PIN hashed with bcrypt, rate-limited (5 attempts/min per IP)
- Chat/files auth: verified via ParticipantHistory
- SSE lobby: verified against existing LobbyEntry
- Token generation: only room owner can get LiveKit token
- `DEV_ACCESS_KEY` middleware: protects local dev from network access
- LiveKit webhook: signature verification via `WebhookReceiver`
## Commands
```bash
# Dev
npm run dev # Next.js dev server (localhost:3000)
docker compose up -d postgres minio # DB + Storage only
npm run lint # TypeScript type-check (tsc --noEmit)
# Database
npx prisma migrate dev # Create + apply migration
npx prisma db push # Quick schema sync (no migration)
npx prisma studio # DB GUI
npx prisma generate # Regenerate Prisma Client
# Build
npm run build -- --webpack # Production build (Webpack, not Turbopack — WASM limitation on Windows)
# Docker (full stack)
docker compose up -d --build # Local
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # Prod (with Traefik)
# AI Agent
cd ai-agent && python main.py start
```
## Environment Variables
See `.env.example` for all variables. Key ones:
```env
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/liveserver
LIVEKIT_URL=wss://...
LIVEKIT_API_KEY=...
LIVEKIT_API_SECRET=...
BETTER_AUTH_SECRET=...
# Local dev protection (when DOMAIN is not set)
DEV_ACCESS_KEY=mySecretKey123
# ALLOWED_IPS=192.168.1.10,192.168.1.11
# Production
# DOMAIN=live.example.com
# ACME_EMAIL=admin@example.com
```
## Deployment Modes
| Mode | Command | Description |
|------|---------|-------------|
| **Local dev** | `docker compose up -d` | Direct ports (3000, 5432, 9000). `docker-compose.override.yml` auto-applied |
| **Production** | `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` | Traefik reverse proxy, auto SSL via Let's Encrypt |
## Prisma 7 Notes
- `prisma.config.ts` in project root (required by Prisma 7)
- `datasourceUrl` passed via PrismaClient constructor, not in schema
- Schema has no `url` in datasource block — only `provider`
- Use `-- --webpack` flag for `next build` on Windows (Turbopack WASM issue)
## Agent Orchestration
| Task | Agent |
|------|-------|
| API routes, backend logic | `backend-architect` |
| React components, LiveKit UI | `frontend-developer` |
| DB schema, indexes, queries | `database-optimizer` |
| Python AI Agent, Deepgram/OpenAI | `ai-engineer` |
| System architecture | `software-architect` |
| UI design, components | `ui-designer` |
| UX flows, accessibility | `ux-architect` |
| Code review before commit | `code-reviewer` |
**Rule:** for tasks spanning 2+ layers — use `agents-orchestrator`.
## Conventions
- Communication language: Russian
- Guests don't register, but `user_id` reserved for future LMS
- `sessionId` (UUID) is the cross-cutting key linking LobbyEntry, ParticipantHistory, ChatMessage, SharedFile for guests
- `Room.code` is the user-facing invite code (not the DB id)
- Files store `fileKey` (S3 object key), not full URLs
- `LectureArtifact` is 1:1 with Room
- All cascade deletes: removing Room removes all related data

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:22-alpine AS base
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
COPY prisma ./prisma/
COPY prisma.config.ts ./
RUN npm ci
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build -- --webpack
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

119
PROMPT.md Normal file
View File

@@ -0,0 +1,119 @@
<role>
You are an expert Senior Full-Stack Engineer specializing in WebRTC (LiveKit), Next.js, AI integrations, and secure backend architectures.
</role>
<project_overview>
I am building a modern, highly scalable educational video conferencing platform (similar to a specialized Zoom/Google Meet for universities).
Key differentiators:
1. Deep integration of an AI Assistant that automatically joins rooms, transcribes audio in real-time, and generates post-lecture summaries.
2. Advanced security and moderation tools to prevent "Zoom-bombing".
3. A centralized post-lecture dashboard containing chat history, shared files, and AI summaries.
</project_overview>
<tech_stack>
- **Frontend:** Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS v4.
- **Video Core:** LiveKit Cloud (initial phase; migration to self-hosted LiveKit on Proxmox planned for later). Client UI via `@livekit/components-react`.
- **Backend:** Node.js (Next.js Route Handlers). LiveKit Server Node SDK (`livekit-server-sdk`) for token generation and room management.
- **Authentication:** `better-auth` with Prisma adapter. Email/Password for Hosts and Admins. Guests join without auth (name-only entry), with a `user_id` column reserved for future LMS account linking.
- **AI Module:** Python — LiveKit Agents framework (`livekit-agents`). Real-time STT via `livekit-plugins-deepgram` (streaming, low latency). Post-lecture summarization via OpenAI GPT API (`livekit-plugins-openai`).
- **Database:** PostgreSQL with Prisma 7 ORM. Config in `prisma.config.ts` (Prisma 7 pattern — no `url` in schema datasource).
- **Storage:** S3-compatible storage (MinIO self-hosted on Proxmox, or AWS S3) for recordings, shared files, and chat attachments.
- **Reverse Proxy:** Traefik v3 with automatic Let's Encrypt SSL (production). Local dev runs on direct ports without proxy.
- **PDF Export:** `@react-pdf/renderer` for generating downloadable lecture summaries.
- **Google Drive Export:** Google Drive API with OAuth for optional export of lecture artifacts.
</tech_stack>
<user_roles>
1. **Super Admin:** Access to a global dashboard. Can monitor all active rooms, manage global AI settings, view system logs, and manage all users.
2. **Host (Professor/Teacher):** Authenticated user (email/password via `better-auth`). Can create rooms, manage participants, share files, and control room security settings.
3. **Guest/Student:** Joins via invite link. Does not require registration (enters a display name only). The database stores a `user_id` (nullable) to support future LMS integration where guests can optionally link or create accounts.
</user_roles>
<authentication>
- **Library:** `better-auth` with Prisma adapter for database-backed sessions.
- **Host/Admin flow:** Standard email/password registration and login. Sessions stored in PostgreSQL.
- **Guest flow:** No registration required. Guest provides a display name and a `sessionFingerprint` (browser fingerprint hash, required). Receives a short-lived LiveKit token scoped to a specific room. Guest identity is tracked via a generated `sessionId` (UUID) stored in `ParticipantHistory`.
- **Authorization:** Middleware checks role (`ADMIN`, `HOST`, `GUEST`) before granting access to protected API routes and pages.
- **Local dev protection:** When `DOMAIN` env is not set, a `DEV_ACCESS_KEY` middleware blocks access without `?key=...` query param or valid cookie. Optional `ALLOWED_IPS` whitelist.
</authentication>
<security_and_moderation>
Rooms must support the following security features configured by the Host:
1. **Waiting Room (Lobby):** Guests cannot enter the LiveKit room until the Host explicitly approves them. Implementation:
- Guest submits a join request to the backend API, which creates a `LobbyEntry` record with status `PENDING`.
- Guest connects to an SSE (Server-Sent Events) endpoint (`/api/rooms/[roomId]/lobby/stream`) and waits for status change. SSE validates `sessionId` against existing `LobbyEntry` before starting stream.
- Host sees pending guests in the room UI (fetched via polling or SSE). Host approves or rejects via API call.
- On approval, backend generates a LiveKit token and pushes `APPROVED` status (with token) to the guest's SSE stream. Guest then connects to LiveKit.
- On rejection, backend pushes `REJECTED` status. Guest sees a denial message.
2. **PIN Codes:** Rooms can optionally be protected by a PIN. The PIN is hashed (bcrypt) and stored in `Room.pinHash`. Guests must submit the correct PIN before entering the lobby. **Rate limited:** max 5 PIN attempts per IP per minute (in-memory limiter, returns 429).
3. **Moderation Actions:**
- **Kick & Ban:** Host can remove a participant and add them to the `BannedEntry` table. Ban is based on `sessionFingerprint` (required, combination of browser fingerprint hash + session ID) as the primary identifier. IP address is stored as a secondary signal but is not the sole basis for bans (easily bypassed via VPN). Secondary ban check by `sessionId`.
- **Panic Button (Mute All):** Host can instantly revoke `canPublish` permissions for all participants except themselves via a single API call using LiveKit Server SDK's `updateParticipant`.
4. **Hand-Raising (Webinar Mode):** In rooms with `webinarMode: true`, guests join with `canPublish: false`. They send a "raise hand" signal via LiveKit DataChannels. The Host sees raised hands in the UI and can dynamically grant `canPublish: true` to selected participants via the backend.
5. **Chat & Files auth:** Both endpoints verify the requester is either an authenticated Host/Admin or a participant with a valid `sessionId` in `ParticipantHistory` for the room.
6. **LiveKit token security:** Token generation endpoint verifies the requesting user is the actual host of the room (checks `room.hostId === session.user.id`).
</security_and_moderation>
<ai_assistant>
The AI Assistant is a Python-based LiveKit Agent that:
1. **Auto-joins** every room on `room_started` webhook (or via LiveKit Agents' `AutoSubscribe`).
2. **Real-time transcription:** Subscribes to all audio tracks. Uses Deepgram streaming STT (`livekit-plugins-deepgram`) for low-latency, real-time transcription. Transcription segments are published back to the room via LiveKit DataChannels (topic: `transcription`) so participants can see live captions.
3. **Post-lecture summarization:** When the room ends (`room_finished` webhook), the agent takes the full transcript and sends it to OpenAI GPT API for structured summarization (key topics, action items, Q&A highlights).
4. **Storage:** Transcript and summary are saved to the `LectureArtifact` table in PostgreSQL via psycopg2. Raw transcript is also stored as a file in S3.
</ai_assistant>
<post_lecture_processing>
When the Host ends the call (triggering LiveKit `room_finished` webhook):
1. The backend sets room status to ENDED and destroys the LiveKit room via `roomService.deleteRoom()`.
2. The AI Agent finalizes the transcript and generates a structured summary via LLM.
3. The backend aggregates: chat history (from `ChatMessage` table), links to all S3 files shared during the call, and the AI summary.
4. A "Lecture Artifacts" page (`/lectures/[id]`) is generated, displaying:
- AI summary (structured: key topics, action items, Q&A)
- Full transcript (searchable)
- Chat history
- Shared files (downloadable)
- Export options: Download as PDF (`@react-pdf/renderer`), Export to Google Drive (OAuth).
5. Access to this page is restricted to the Host, Admin, and optionally participants who were in the room.
</post_lecture_processing>
<deployment>
- **Local development:** `docker compose up -d` — auto-applies `docker-compose.override.yml` with direct ports (3000, 5432, 9000). No SSL, protected by `DEV_ACCESS_KEY` middleware.
- **Production:** `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` — Traefik v3 reverse proxy with automatic Let's Encrypt SSL. Requires `DOMAIN` and `ACME_EMAIL` env vars. DNS must point `DOMAIN`, `s3.DOMAIN`, `minio.DOMAIN` to server IP.
- **Next.js build:** Uses `output: "standalone"` and multi-stage Dockerfile. On Windows, use `--webpack` flag (Turbopack has WASM limitation).
- **Future:** Self-hosted LiveKit Server on Proxmox VM with TURN/STUN via Coturn.
</deployment>
<current_state>
The following is already implemented:
- Prisma schema: 9 models (User, Session, Account, Verification, Room, LobbyEntry, ParticipantHistory, ChatMessage, SharedFile, BannedEntry, LectureArtifact), 3 enums
- 15 API route handlers (auth, rooms CRUD, join, lobby, SSE, moderation, chat, files, token, webhook, by-code lookup)
- 7 frontend pages (landing, login, register, dashboard, create room, join, video room)
- 4 components (ChatPanel, ModerationPanel, WaitingRoom, LobbyManager)
- Core libs (prisma, auth, auth-helpers, livekit)
- Dev protection middleware (DEV_ACCESS_KEY, ALLOWED_IPS)
- Docker Compose (local + prod profiles)
- Python AI Agent with Deepgram STT + OpenAI summarization
- Multi-stage Dockerfile for Next.js
</current_state>
<next_steps>
Remaining work to reach full MVP:
1. **Lecture Artifacts page** (`/lectures/[id]`) — display transcript, summary, chat, files with export
2. **Admin dashboard** — global room monitoring, user management, role promotion
3. **File upload flow** — presigned S3 URL generation, actual upload component
4. **Google Drive export** — OAuth flow, Drive API integration
5. **PDF export**`@react-pdf/renderer` for lecture summaries
6. **Room invite code** — generate shorter user-friendly codes instead of cuid
7. **UI polish** — responsive design, loading states, error boundaries
8. **Tests** — API route integration tests, component tests
</next_steps>
<instructions>
Continue building iteratively. The core MVP (steps 1-8 in current_state) is done. Focus on the next_steps list above, one feature at a time. Always run `npx tsc --noEmit` after changes to verify no type errors.
</instructions>

227
README.md
View File

@@ -1,3 +1,228 @@
# LiveServer-M1
Livekit core - Server for VideoHosting
Образовательная видеоконференц-платформа на базе LiveKit. AI-транскрипция, модерация, пост-лекционные артефакты.
## Стек
- **Frontend:** Next.js 16, React 19, TypeScript, Tailwind CSS v4
- **Video:** LiveKit (`@livekit/components-react`)
- **Backend:** Next.js Route Handlers, `livekit-server-sdk`
- **Auth:** `better-auth` + Prisma adapter
- **AI Agent:** Python, `livekit-agents`, Deepgram STT, OpenAI GPT
- **DB:** PostgreSQL + Prisma 7
- **Storage:** MinIO (S3-compatible)
- **Proxy:** Traefik v3 + Let's Encrypt (production)
## Быстрый старт (автоматический)
```bash
git clone <repo-url> && cd LiveServer-M1
bash setup.sh
```
Скрипт проведёт через все шаги: проверит зависимости, спросит настройки, сгенерирует `.env`, поднимет Docker, применит миграции, создаст S3 bucket. Работает для локальной разработки и production.
## Быстрый старт (ручной)
### 1. Клонировать и установить зависимости
```bash
git clone <repo-url> && cd LiveServer-M1
npm install
```
### 2. Настроить окружение
```bash
cp .env.example .env
```
Заполни в `.env`:
| Переменная | Описание |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string. Для Docker: `postgresql://postgres:postgres@localhost:5432/liveserver` |
| `LIVEKIT_URL` | URL LiveKit сервера (`wss://...livekit.cloud` или self-hosted) |
| `LIVEKIT_API_KEY` | API Key из LiveKit Dashboard |
| `LIVEKIT_API_SECRET` | API Secret из LiveKit Dashboard |
| `BETTER_AUTH_SECRET` | Любая случайная строка для подписи сессий |
| `DEV_ACCESS_KEY` | Ключ доступа для локалки (защита от посторонних в сети) |
Остальные переменные опциональны для начала — AI Agent и MinIO нужны позже.
### 3. Запустить базу данных
```bash
docker compose up -d postgres minio
```
Поднимет PostgreSQL на `localhost:5432` и MinIO на `localhost:9000` (консоль: `localhost:9001`).
### 4. Применить миграции
```bash
npx prisma migrate dev --name init
```
Или для быстрого прототипирования без миграций:
```bash
npx prisma db push
```
### 5. Запустить приложение
```bash
npm run dev
```
Приложение: `http://localhost:3000`
### 6. (Опционально) AI Agent
```bash
cd ai-agent
pip install -r requirements.txt
python main.py start
```
Требует `DEEPGRAM_API_KEY` и `OPENAI_API_KEY` в `.env`.
## Защита на локалке
Когда `DOMAIN` не задан, middleware блокирует доступ без ключа:
```
http://192.168.x.x:3000?key=mySecretKey123
```
Ключ задаётся в `DEV_ACCESS_KEY`. После первого входа ставится cookie на 7 дней.
Дополнительно можно ограничить по IP:
```env
ALLOWED_IPS=192.168.1.10,192.168.1.11
```
Localhost (`127.0.0.1`, `::1`) разрешён всегда.
## Production (с доменом)
### 1. Настроить DNS
Направить на IP сервера:
- `live.example.com` → приложение
- `s3.live.example.com` → MinIO API
- `minio.live.example.com` → MinIO Console
### 2. Заполнить `.env`
```env
DOMAIN=live.example.com
ACME_EMAIL=admin@example.com
POSTGRES_PASSWORD=<strong-password>
MINIO_ROOT_PASSWORD=<strong-password>
BETTER_AUTH_SECRET=<random-string>
BETTER_AUTH_URL=https://live.example.com
NEXT_PUBLIC_APP_URL=https://live.example.com
```
### 3. Запустить
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
Traefik автоматически получит SSL-сертификат через Let's Encrypt.
## Команды
```bash
# Разработка
npm run dev # Next.js dev server
npm run lint # TypeScript type-check (tsc --noEmit)
npm run build -- --webpack # Production build
# База данных
npx prisma migrate dev # Создать/применить миграцию
npx prisma db push # Синхронизировать схему без миграции
npx prisma studio # GUI для БД
npx prisma generate # Регенерация Prisma Client
# Docker
docker compose up -d # Локалка (postgres + minio)
docker compose up -d --build # Локалка с билдом app
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # Прод
docker compose logs -f app # Логи приложения
```
## Структура проекта
```
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ ├── auth/[...all]/ # better-auth handler
│ │ │ ├── rooms/ # CRUD комнат
│ │ │ ├── rooms/[roomId]/ # join, lobby, chat, files, moderate, start, end
│ │ │ ├── rooms/by-code/[code]/ # Публичный поиск по инвайт-коду
│ │ │ └── livekit/ # token, webhook
│ │ ├── (dashboard)/ # Дашборд хоста/админа
│ │ ├── join/[code]/ # Страница входа гостя
│ │ ├── room/[code]/ # Комната видеоконференции
│ │ ├── login/ # Вход
│ │ └── register/ # Регистрация
│ ├── components/
│ │ ├── room/ # ChatPanel, ModerationPanel
│ │ └── lobby/ # WaitingRoom, LobbyManager
│ ├── lib/ # prisma, auth, livekit, auth-helpers
│ ├── middleware.ts # Защита локалки (DEV_ACCESS_KEY, ALLOWED_IPS)
│ └── types/
├── ai-agent/ # Python LiveKit Agent
│ ├── main.py
│ ├── requirements.txt
│ └── Dockerfile
├── prisma/
│ └── schema.prisma
├── docker-compose.yml # Базовые сервисы
├── docker-compose.override.yml # Локальные порты (автоподхват)
├── docker-compose.prod.yml # Traefik + SSL
├── Dockerfile # Multi-stage build Next.js
└── .env.example
```
## API Endpoints
### Публичные (без авторизации)
| Метод | Путь | Описание |
|---|---|---|
| `*` | `/api/auth/*` | better-auth (login, register, session) |
| `GET` | `/api/rooms/by-code/:code` | Инфо о комнате по инвайт-коду |
| `POST` | `/api/rooms/:id/join` | Вход гостя (PIN, fingerprint) |
| `GET` | `/api/rooms/:id/lobby/stream` | SSE ожидание в лобби |
| `POST` | `/api/livekit/webhook` | Вебхуки LiveKit |
### Требуют авторизации (Host/Admin)
| Метод | Путь | Описание |
|---|---|---|
| `GET/POST` | `/api/rooms` | Список / создание комнат |
| `GET/PATCH/DELETE` | `/api/rooms/:id` | Управление комнатой |
| `POST` | `/api/rooms/:id/start` | Старт лекции |
| `POST` | `/api/rooms/:id/end` | Завершение лекции |
| `GET/POST` | `/api/rooms/:id/lobby` | Управление лобби |
| `POST` | `/api/rooms/:id/moderate` | Kick, ban, mute all |
| `POST` | `/api/livekit/token` | Генерация токена LiveKit |
### Требуют участия в комнате (sessionId)
| Метод | Путь | Описание |
|---|---|---|
| `GET/POST` | `/api/rooms/:id/chat` | Чат комнаты |
| `GET/POST` | `/api/rooms/:id/files` | Файлы комнаты |
## Роли
| Роль | Возможности |
|---|---|
| **ADMIN** | Всё + глобальная панель, мониторинг всех комнат |
| **HOST** | Создание комнат, модерация, настройки безопасности |
| **GUEST** | Вход по ссылке, участие в лекции (без регистрации) |

3
ai-agent/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
__pycache__
*.pyc
.env

15
ai-agent/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies for psycopg2
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py", "start"]

295
ai-agent/main.py Normal file
View File

@@ -0,0 +1,295 @@
"""
LiveServer-M1 AI Agent
======================
LiveKit agent that provides real-time transcription via Deepgram STT,
publishes captions to participants, and on room end generates an
AI summary (GPT-4o-mini) stored alongside the full transcript in PostgreSQL.
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timezone
import psycopg2
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli
from livekit.plugins import deepgram, openai
load_dotenv()
logger = logging.getLogger("liveserver-agent")
logger.setLevel(logging.INFO)
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def _get_db_connection():
"""Create a fresh PostgreSQL connection from DATABASE_URL."""
dsn = os.environ.get("DATABASE_URL")
if not dsn:
raise RuntimeError("DATABASE_URL environment variable is not set")
return psycopg2.connect(dsn)
def _ensure_table():
"""Create the lecture_artifacts table if it doesn't already exist.
This is a safety net — normally Prisma migrations handle schema creation.
The column names match the Prisma model exactly (snake_case mapped via @@map).
"""
ddl = """
CREATE TABLE IF NOT EXISTS lecture_artifacts (
id TEXT PRIMARY KEY,
"roomId" TEXT UNIQUE NOT NULL,
"transcriptText" TEXT,
"transcriptUrl" TEXT,
summary TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
conn = None
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
logger.info("lecture_artifacts table verified")
except Exception:
logger.exception("Failed to ensure lecture_artifacts table")
if conn:
conn.rollback()
finally:
if conn:
conn.close()
def save_artifact(room_name: str, transcript: str, summary: str | None):
"""Upsert a lecture artifact for the given room.
Uses the LiveKit room name to look up the room row first (rooms.livekitRoomName),
then inserts or updates lecture_artifacts.
"""
conn = None
try:
conn = _get_db_connection()
with conn.cursor() as cur:
# Resolve internal room id from livekitRoomName
cur.execute(
'SELECT id FROM rooms WHERE "livekitRoomName" = %s',
(room_name,),
)
row = cur.fetchone()
if not row:
logger.error("Room not found in DB for livekitRoomName=%s", room_name)
return
room_id = row[0]
now = datetime.now(timezone.utc)
# Upsert via ON CONFLICT on roomId (unique)
cur.execute(
"""
INSERT INTO lecture_artifacts (id, "roomId", "transcriptText", summary, "createdAt", "updatedAt")
VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s)
ON CONFLICT ("roomId")
DO UPDATE SET
"transcriptText" = EXCLUDED."transcriptText",
summary = EXCLUDED.summary,
"updatedAt" = EXCLUDED."updatedAt"
""",
(room_id, transcript, summary, now, now),
)
conn.commit()
logger.info("Saved artifact for room %s (room_id=%s)", room_name, room_id)
except Exception:
logger.exception("Failed to save artifact for room %s", room_name)
if conn:
conn.rollback()
finally:
if conn:
conn.close()
# ---------------------------------------------------------------------------
# Summarisation
# ---------------------------------------------------------------------------
async def summarise_transcript(transcript: str) -> str | None:
"""Send the full transcript to GPT-4o-mini and return a markdown summary."""
if not transcript.strip():
return None
try:
llm = openai.LLM(model="gpt-4o-mini")
chat_ctx = openai.ChatContext()
chat_ctx.append(
role="system",
text=(
"You are a helpful assistant that summarises educational lectures. "
"Produce a concise summary in Markdown with: key topics covered, "
"main takeaways, and any action items mentioned. "
"Keep it under 500 words. Respond in the same language as the transcript."
),
)
chat_ctx.append(role="user", text=transcript)
response = await llm.chat(chat_ctx=chat_ctx)
return response.choices[0].message.content
except Exception:
logger.exception("Summarisation failed")
return None
# ---------------------------------------------------------------------------
# Agent entrypoint
# ---------------------------------------------------------------------------
async def entrypoint(ctx: JobContext):
"""Called by the livekit-agents framework for every room job."""
logger.info("Agent joining room: %s", ctx.room.name)
# Connect to the room — subscribe to audio only (for STT)
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# Accumulated transcript segments: list of {"speaker": str, "text": str, "ts": float}
segments: list[dict] = []
# ----- Deepgram STT setup ----- #
stt = deepgram.STT()
# We'll maintain one recognition stream per audio track
active_streams: dict[str, deepgram.SpeechStream] = {}
async def _process_stt_stream(
stream: deepgram.SpeechStream,
participant_identity: str,
):
"""Read STT events from a Deepgram stream, publish captions, accumulate transcript."""
async for event in stream:
if not event.is_final or not event.alternatives:
continue
text = event.alternatives[0].text.strip()
if not text:
continue
# Accumulate
segment = {
"speaker": participant_identity,
"text": text,
"ts": time.time(),
}
segments.append(segment)
# Log to console
logger.info("[%s] %s", participant_identity, text)
# Publish caption to all participants via data channel
caption_payload = json.dumps(
{
"type": "transcription",
"speaker": participant_identity,
"text": text,
},
ensure_ascii=False,
).encode("utf-8")
await ctx.room.local_participant.publish_data(
caption_payload,
reliable=True,
topic="transcription",
)
async def on_track_subscribed(
track: rtc.Track,
publication: rtc.RemoteTrackPublication,
participant: rtc.RemoteParticipant,
):
"""Start STT for every subscribed audio track."""
if track.kind != rtc.TrackKind.KIND_AUDIO:
return
logger.info("Subscribed to audio from %s", participant.identity)
audio_stream = rtc.AudioStream(track)
stt_stream = stt.stream()
active_streams[participant.identity] = stt_stream
# Forward audio frames to the STT stream
async def _forward():
async for frame_event in audio_stream:
stt_stream.push_frame(frame_event.frame)
# Run forwarding and recognition concurrently
import asyncio
asyncio.ensure_future(_forward())
asyncio.ensure_future(_process_stt_stream(stt_stream, participant.identity))
ctx.room.on("track_subscribed", on_track_subscribed)
# ----- Handle room disconnect / end ----- #
async def on_disconnected():
"""When the agent disconnects from the room, finalise the transcript."""
logger.info("Agent disconnected from room %s", ctx.room.name)
# Close all active STT streams
for identity, stream in active_streams.items():
try:
await stream.aclose()
except Exception:
logger.warning("Error closing STT stream for %s", identity)
if not segments:
logger.info("No transcript segments collected — nothing to save")
return
# Build full transcript text
full_transcript = "\n".join(
f"[{seg['speaker']}]: {seg['text']}" for seg in segments
)
logger.info(
"Full transcript (%d segments, %d chars):\n%s",
len(segments),
len(full_transcript),
full_transcript,
)
# Summarise
logger.info("Generating summary via GPT-4o-mini...")
summary = await summarise_transcript(full_transcript)
if summary:
logger.info("Summary:\n%s", summary)
else:
logger.warning("Summary generation returned empty result")
# Persist to PostgreSQL
save_artifact(ctx.room.name, full_transcript, summary)
ctx.room.on("disconnected", on_disconnected)
# ---------------------------------------------------------------------------
# Worker bootstrap
# ---------------------------------------------------------------------------
if __name__ == "__main__":
_ensure_table()
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
)
)

View File

@@ -0,0 +1,5 @@
livekit-agents>=1.0
livekit-plugins-deepgram>=1.0
livekit-plugins-openai>=1.0
psycopg2-binary>=2.9
python-dotenv>=1.0

50
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,50 @@
# Продакшен — Traefik + Let's Encrypt
# Запуск: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
traefik:
image: traefik:v3
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
restart: unless-stopped
app:
environment:
NEXT_PUBLIC_APP_URL: https://${DOMAIN}
BETTER_AUTH_URL: https://${DOMAIN}
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=3000"
minio:
labels:
- "traefik.enable=true"
- "traefik.http.routers.minio-api.rule=Host(`s3.${DOMAIN}`)"
- "traefik.http.routers.minio-api.entrypoints=websecure"
- "traefik.http.routers.minio-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-api.service=minio-api"
- "traefik.http.services.minio-api.loadbalancer.server.port=9000"
- "traefik.http.routers.minio-console.rule=Host(`minio.${DOMAIN}`)"
- "traefik.http.routers.minio-console.entrypoints=websecure"
- "traefik.http.routers.minio-console.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-console.service=minio-console"
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
volumes:
letsencrypt_data:

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
env_file: .env
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/liveserver
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: liveserver
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
restart: unless-stopped
ai-agent:
build: ./ai-agent
depends_on:
postgres:
condition: service_healthy
env_file: .env
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/liveserver
restart: unless-stopped
volumes:
postgres_data:
minio_data:

5
eslint.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
export default [
{
ignores: [".next/", "node_modules/", "ai-agent/"],
},
];

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

8
next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["@prisma/client", "bcryptjs"],
};
export default nextConfig;

8323
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "liveserver-m1",
"version": "1.0.0",
"description": "Livekit core - Server for VideoHosting",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "tsc --noEmit",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@prisma/client": "^7.5.0",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"bcryptjs": "^3.0.3",
"better-auth": "^1.5.5",
"livekit-client": "^2.17.3",
"livekit-server-sdk": "^2.15.0",
"next": "^16.2.1",
"postcss": "^8.5.8",
"prisma": "^7.5.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@types/bcryptjs": "^2.4.6",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.1"
}
}

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

12
prisma.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import path from "node:path";
import { defineConfig } from "prisma/config";
export default defineConfig({
earlyAccess: true,
schema: path.join(__dirname, "prisma", "schema.prisma"),
migrate: {
async url() {
return process.env.DATABASE_URL!;
},
},
} as any);

255
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,255 @@
// ============================================================
// LiveServer-M1 — Database Schema
// Educational video conferencing platform on LiveKit
// ============================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ======================== ENUMS =============================
enum UserRole {
ADMIN
HOST
GUEST
}
enum RoomStatus {
WAITING // Создана, но лекция не началась
ACTIVE // Лекция идёт
ENDED // Лекция завершена
}
enum LobbyStatus {
PENDING
APPROVED
REJECTED
}
// ======================== AUTH ==============================
// Таблицы совместимы с better-auth (Prisma adapter)
// Docs: https://www.better-auth.com/docs/adapters/prisma
model User {
id String @id @default(cuid())
email String @unique
name String
emailVerified Boolean @default(false)
image String?
role UserRole @default(HOST)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// better-auth relations
sessions Session[]
accounts Account[]
// App relations
hostedRooms Room[] @relation("RoomHost")
participantHistories ParticipantHistory[]
bannedByEntries BannedEntry[] @relation("BannedBy")
@@map("users")
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
model Account {
id String @id @default(cuid())
userId String
accountId String
providerId String
accessToken String?
refreshToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("accounts")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("verifications")
}
// ======================== ROOMS =============================
model Room {
id String @id @default(cuid())
name String
code String @unique @default(cuid()) // Короткий код для инвайт-ссылки
hostId String
status RoomStatus @default(WAITING)
// Security settings
lobbyEnabled Boolean @default(true)
pinHash String? // bcrypt hash, null = без PIN
webinarMode Boolean @default(false)
isLocked Boolean @default(false) // Хост может заблокировать вход полностью
// LiveKit
livekitRoomName String? @unique // Имя комнаты в LiveKit (создаётся при старте)
// Timestamps
createdAt DateTime @default(now())
startedAt DateTime? // Когда хост начал лекцию
endedAt DateTime? // Когда завершилась
// Relations
host User @relation("RoomHost", fields: [hostId], references: [id])
lobbyEntries LobbyEntry[]
participantHistories ParticipantHistory[]
chatMessages ChatMessage[]
sharedFiles SharedFile[]
bannedEntries BannedEntry[]
lectureArtifact LectureArtifact?
@@index([hostId])
@@index([status])
@@index([code])
@@map("rooms")
}
// ======================== LOBBY =============================
model LobbyEntry {
id String @id @default(cuid())
roomId String
displayName String
sessionId String // UUID, генерируется на клиенте
sessionFingerprint String? // Browser fingerprint hash
ipAddress String?
status LobbyStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId, status])
@@index([sessionId])
@@map("lobby_entries")
}
// ======================== PARTICIPANTS ======================
model ParticipantHistory {
id String @id @default(cuid())
roomId String
userId String? // null для гостей без аккаунта
sessionId String // UUID, совпадает с LobbyEntry.sessionId
displayName String
role UserRole
joinedAt DateTime @default(now())
leftAt DateTime?
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([roomId])
@@index([userId])
@@index([sessionId])
@@map("participant_histories")
}
// ======================== CHAT ==============================
model ChatMessage {
id String @id @default(cuid())
roomId String
sessionId String // Кто отправил (связь через sessionId, не userId — гости тоже пишут)
senderName String
content String
createdAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId, createdAt])
@@map("chat_messages")
}
// ======================== FILES =============================
model SharedFile {
id String @id @default(cuid())
roomId String
sessionId String // Кто загрузил
fileName String
fileKey String // S3 object key
fileSize Int
mimeType String
createdAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@index([roomId])
@@map("shared_files")
}
// ======================== BANS ==============================
model BannedEntry {
id String @id @default(cuid())
roomId String
sessionFingerprint String // Основной идентификатор для бана
ipAddress String? // Вторичный сигнал
displayName String? // Для UI — кого забанили
reason String?
bannedById String // Хост, который забанил
createdAt DateTime @default(now())
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
bannedBy User @relation("BannedBy", fields: [bannedById], references: [id])
@@unique([roomId, sessionFingerprint])
@@index([roomId])
@@map("banned_entries")
}
// ======================== LECTURE ARTIFACTS ==================
model LectureArtifact {
id String @id @default(cuid())
roomId String @unique // 1:1 с Room
transcriptText String? @db.Text // Полный текст транскрипта (для поиска)
transcriptUrl String? // S3 key для raw-файла транскрипта
summary String? @db.Text // AI-сгенерированная суммаризация (Markdown/JSON)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@map("lecture_artifacts")
}

328
setup.sh Normal file
View File

@@ -0,0 +1,328 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# LiveServer-M1 — Interactive Setup Script
# ============================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
BOLD='\033[1m'
print_banner() {
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}${NC} ${BOLD}LiveServer-M1${NC} — Setup Wizard ${CYAN}${NC}"
echo -e "${CYAN}${NC} Образовательная видеоконференц-платформа ${CYAN}${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════════╝${NC}"
echo ""
}
log_step() { echo -e "\n${BLUE}[$(date +%H:%M:%S)]${NC} ${BOLD}$1${NC}"; }
log_ok() { echo -e " ${GREEN}${NC} $1"; }
log_warn() { echo -e " ${YELLOW}${NC} $1"; }
log_err() { echo -e " ${RED}${NC} $1"; }
ask() {
local prompt="$1" default="${2:-}" var_name="$3"
if [[ -n "$default" ]]; then
echo -en " ${CYAN}?${NC} ${prompt} ${YELLOW}[${default}]${NC}: "
else
echo -en " ${CYAN}?${NC} ${prompt}: "
fi
read -r input
printf -v "$var_name" '%s' "${input:-$default}"
}
ask_secret() {
local prompt="$1" default="${2:-}" var_name="$3"
if [[ -n "$default" ]]; then
echo -en " ${CYAN}?${NC} ${prompt} ${YELLOW}[${default}]${NC}: "
else
echo -en " ${CYAN}?${NC} ${prompt}: "
fi
read -rs input
echo ""
printf -v "$var_name" '%s' "${input:-$default}"
}
ask_yn() {
local prompt="$1" default="${2:-y}"
local hint="Y/n"
[[ "$default" == "n" ]] && hint="y/N"
echo -en " ${CYAN}?${NC} ${prompt} ${YELLOW}[${hint}]${NC}: "
read -r input
input="${input:-$default}"
[[ "${input,,}" == "y" || "${input,,}" == "yes" ]]
}
generate_secret() {
openssl rand -base64 32 2>/dev/null || head -c 32 /dev/urandom | base64 | tr -d '=/+' | head -c 32
}
check_command() {
if command -v "$1" &>/dev/null; then
log_ok "$1 найден: $(command -v "$1")"
return 0
else
log_err "$1 не найден"
return 1
fi
}
# ============================================================
# Main
# ============================================================
print_banner
# --- Step 1: Check dependencies ---
log_step "Шаг 1/6 — Проверка зависимостей"
MISSING=0
check_command docker || MISSING=1
check_command "docker compose" 2>/dev/null || check_command docker-compose || MISSING=1
check_command node || MISSING=1
check_command npm || MISSING=1
check_command npx || MISSING=1
if [[ "$MISSING" -eq 1 ]]; then
log_err "Установи недостающие зависимости и запусти скрипт снова."
exit 1
fi
NODE_VER=$(node -v)
log_ok "Node.js: $NODE_VER"
# --- Step 2: Choose mode ---
log_step "Шаг 2/6 — Режим запуска"
echo ""
echo -e " ${BOLD}1)${NC} Локальная разработка (localhost, без SSL)"
echo -e " ${BOLD}2)${NC} Production (домен + Traefik + Let's Encrypt SSL)"
echo ""
ask "Выбери режим" "1" MODE
DOMAIN=""
ACME_EMAIL=""
if [[ "$MODE" == "2" ]]; then
echo ""
ask "Домен (например live.example.com)" "" DOMAIN
ask "Email для Let's Encrypt" "" ACME_EMAIL
if [[ -z "$DOMAIN" || -z "$ACME_EMAIL" ]]; then
log_err "Домен и email обязательны для production."
exit 1
fi
log_ok "Production: ${DOMAIN}"
log_warn "Убедись что DNS записи указывают на этот сервер:"
echo -e " ${DOMAIN}$(curl -s ifconfig.me 2>/dev/null || echo '<server-ip>')"
echo -e " s3.${DOMAIN} → тот же IP"
echo -e " minio.${DOMAIN} → тот же IP"
echo ""
if ! ask_yn "DNS настроен?" "y"; then
log_warn "Настрой DNS и запусти скрипт снова."
exit 0
fi
else
log_ok "Локальная разработка"
fi
# --- Step 3: Configure environment ---
log_step "Шаг 3/6 — Настройка окружения"
echo ""
echo -e " ${BOLD}PostgreSQL${NC}"
ask "Пароль PostgreSQL" "postgres" PG_PASSWORD
echo ""
echo -e " ${BOLD}MinIO (S3 Storage)${NC}"
ask "MinIO root user" "minioadmin" MINIO_USER
ask_secret "MinIO root password" "minioadmin" MINIO_PASS
ask "Название S3 bucket" "liveserver" S3_BUCKET
echo ""
echo -e " ${BOLD}LiveKit${NC}"
ask "LiveKit URL (wss://...)" "" LK_URL
ask "LiveKit API Key" "" LK_KEY
ask_secret "LiveKit API Secret" "" LK_SECRET
ask_secret "LiveKit Webhook Secret (Enter чтобы пропустить)" "" LK_WEBHOOK
echo ""
echo -e " ${BOLD}AI Agent (Enter чтобы пропустить, настроишь позже)${NC}"
ask_secret "Deepgram API Key" "" DG_KEY
ask_secret "OpenAI API Key" "" OAI_KEY
echo ""
echo -e " ${BOLD}Аутентификация${NC}"
AUTH_SECRET=$(generate_secret)
log_ok "Сгенерирован BETTER_AUTH_SECRET"
if [[ "$MODE" == "1" ]]; then
DEV_KEY=$(generate_secret | head -c 16)
echo ""
echo -e " ${BOLD}Защита локалки${NC}"
ask "Ключ доступа (DEV_ACCESS_KEY)" "$DEV_KEY" DEV_ACCESS_KEY
ask "Разрешённые IP (через запятую, Enter = все)" "" ALLOWED_IPS
fi
# --- Step 4: Generate .env ---
log_step "Шаг 4/6 — Генерация .env"
if [[ "$MODE" == "2" ]]; then
APP_URL="https://${DOMAIN}"
S3_ENDPOINT="http://minio:9000"
else
APP_URL="http://localhost:3000"
S3_ENDPOINT="http://localhost:9000"
fi
cat > .env << ENVEOF
# === Generated by setup.sh at $(date) ===
# Domain & SSL
DOMAIN=${DOMAIN}
ACME_EMAIL=${ACME_EMAIL}
# Database
DATABASE_URL=postgresql://postgres:${PG_PASSWORD}@localhost:5432/liveserver
POSTGRES_PASSWORD=${PG_PASSWORD}
# LiveKit
LIVEKIT_URL=${LK_URL}
NEXT_PUBLIC_LIVEKIT_URL=${LK_URL}
LIVEKIT_API_KEY=${LK_KEY}
LIVEKIT_API_SECRET=${LK_SECRET}
# AI Agent
DEEPGRAM_API_KEY=${DG_KEY}
OPENAI_API_KEY=${OAI_KEY}
# Storage (MinIO/S3)
S3_ENDPOINT=${S3_ENDPOINT}
S3_ACCESS_KEY=${MINIO_USER}
S3_SECRET_KEY=${MINIO_PASS}
S3_BUCKET=${S3_BUCKET}
MINIO_ROOT_USER=${MINIO_USER}
MINIO_ROOT_PASSWORD=${MINIO_PASS}
# Auth
BETTER_AUTH_SECRET=${AUTH_SECRET}
BETTER_AUTH_URL=${APP_URL}
NEXT_PUBLIC_APP_URL=${APP_URL}
# Local dev protection
DEV_ACCESS_KEY=${DEV_ACCESS_KEY:-}
ALLOWED_IPS=${ALLOWED_IPS:-}
ENVEOF
log_ok ".env создан"
# --- Step 5: Install & setup ---
log_step "Шаг 5/6 — Установка и настройка"
# npm install
if [[ ! -d "node_modules" ]]; then
echo -e " Установка npm зависимостей..."
npm install --silent 2>&1 | tail -3
log_ok "npm install"
else
log_ok "node_modules уже существует"
fi
# Prisma generate
echo -e " Генерация Prisma Client..."
npx prisma generate 2>&1 | tail -1
log_ok "Prisma Client сгенерирован"
# Docker compose up
echo -e " Запуск Docker контейнеров..."
if [[ "$MODE" == "2" ]]; then
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build 2>&1 | tail -5
else
docker compose up -d postgres minio 2>&1 | tail -5
fi
log_ok "Docker контейнеры запущены"
# Wait for postgres
echo -e " Ожидание PostgreSQL..."
for i in $(seq 1 30); do
if docker compose exec -T postgres pg_isready -U postgres &>/dev/null; then
break
fi
sleep 1
done
log_ok "PostgreSQL готов"
# Prisma migrate
echo -e " Применение миграций..."
npx prisma db push --skip-generate 2>&1 | tail -3
log_ok "Схема БД синхронизирована"
# Create MinIO bucket
echo -e " Создание S3 bucket..."
sleep 2
if command -v mc &>/dev/null; then
mc alias set local http://localhost:9000 "$MINIO_USER" "$MINIO_PASS" 2>/dev/null || true
mc mb "local/${S3_BUCKET}" 2>/dev/null || true
log_ok "Bucket '${S3_BUCKET}' создан"
else
log_warn "MinIO Client (mc) не найден — создай bucket '${S3_BUCKET}' вручную через http://localhost:9001"
fi
# --- Step 6: Done ---
log_step "Шаг 6/6 — Готово!"
echo ""
if [[ "$MODE" == "2" ]]; then
echo -e " ${GREEN}${BOLD}Production запущен!${NC}"
echo ""
echo -e " Приложение: ${CYAN}https://${DOMAIN}${NC}"
echo -e " MinIO Console: ${CYAN}https://minio.${DOMAIN}${NC}"
echo -e " MinIO S3 API: ${CYAN}https://s3.${DOMAIN}${NC}"
else
echo -e " ${GREEN}${BOLD}Локальная среда готова!${NC}"
echo ""
echo -e " Запусти приложение: ${CYAN}npm run dev${NC}"
echo -e " Приложение: ${CYAN}http://localhost:3000${NC}"
echo -e " MinIO Console: ${CYAN}http://localhost:9001${NC}"
echo -e " Prisma Studio: ${CYAN}npx prisma studio${NC}"
if [[ -n "${DEV_ACCESS_KEY:-}" ]]; then
echo ""
echo -e " ${YELLOW}Для доступа из сети:${NC}"
echo -e " ${CYAN}http://<ip>:3000?key=${DEV_ACCESS_KEY}${NC}"
fi
fi
if [[ -z "$LK_URL" ]]; then
echo ""
log_warn "LiveKit не настроен — видеозвонки не будут работать."
echo -e " Зарегистрируйся на ${CYAN}https://cloud.livekit.io${NC}"
echo -e " и заполни LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET в .env"
fi
if [[ -z "$DG_KEY" ]]; then
echo ""
log_warn "Deepgram не настроен — AI транскрипция отключена."
echo -e " Получи ключ: ${CYAN}https://console.deepgram.com${NC}"
fi
if [[ -z "$OAI_KEY" ]]; then
echo ""
log_warn "OpenAI не настроен — AI суммаризация отключена."
echo -e " Получи ключ: ${CYAN}https://platform.openai.com/api-keys${NC}"
fi
echo ""
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo -e " Документация: ${CYAN}README.md${NC}"
echo -e " Спецификация: ${CYAN}PROMPT.md${NC}"
echo -e "${CYAN}══════════════════════════════════════════════${NC}"
echo ""

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function CreateRoomPage() {
const router = useRouter();
const [name, setName] = useState("");
const [lobbyEnabled, setLobbyEnabled] = useState(true);
const [webinarMode, setWebinarMode] = useState(false);
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
lobbyEnabled,
webinarMode,
pin: pin || undefined,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Ошибка при создании комнаты");
return;
}
router.push("/dashboard");
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<Link
href="/dashboard"
className="text-sm text-neutral-400 hover:text-white transition-colors mb-6 inline-block"
>
&larr; Назад к комнатам
</Link>
<h1 className="text-3xl font-bold mb-8">Создать комнату</h1>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm text-neutral-400 mb-1">
Название комнаты
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Лекция по математике"
/>
</div>
<div>
<label htmlFor="pin" className="block text-sm text-neutral-400 mb-1">
PIN-код (необязательно)
</label>
<input
id="pin"
type="text"
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="1234"
maxLength={8}
/>
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={lobbyEnabled}
onChange={(e) => setLobbyEnabled(e.target.checked)}
className="w-4 h-4 rounded bg-neutral-800 border-neutral-700 text-blue-600 focus:ring-blue-600 focus:ring-offset-0"
/>
<span className="text-sm">
Зал ожидания
<span className="text-neutral-400 ml-1">
участники ждут одобрения хоста
</span>
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={webinarMode}
onChange={(e) => setWebinarMode(e.target.checked)}
className="w-4 h-4 rounded bg-neutral-800 border-neutral-700 text-blue-600 focus:ring-blue-600 focus:ring-offset-0"
/>
<span className="text-sm">
Режим вебинара
<span className="text-neutral-400 ml-1">
только хост может говорить
</span>
</span>
</label>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{loading ? "Создание..." : "Создать"}
</button>
</form>
</div>
</main>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession, signOut } from "@/lib/auth-client";
type Room = {
id: string;
name: string;
code: string;
status: "WAITING" | "ACTIVE" | "ENDED";
createdAt: string;
};
const statusLabels: Record<Room["status"], string> = {
WAITING: "Ожидание",
ACTIVE: "Активна",
ENDED: "Завершена",
};
const statusColors: Record<Room["status"], string> = {
WAITING: "bg-yellow-600/20 text-yellow-400",
ACTIVE: "bg-green-600/20 text-green-400",
ENDED: "bg-neutral-600/20 text-neutral-400",
};
export default function DashboardPage() {
const router = useRouter();
const { data: session, isPending } = useSession();
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isPending && !session) {
router.push("/login");
}
}, [session, isPending, router]);
useEffect(() => {
async function fetchRooms() {
try {
const res = await fetch("/api/rooms");
if (res.ok) {
const data = await res.json();
setRooms(data);
}
} catch {
// silent
} finally {
setLoading(false);
}
}
if (session) {
fetchRooms();
}
}, [session]);
if (isPending) {
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Загрузка...</p>
</main>
);
}
return (
<main className="min-h-screen px-4 py-8 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Мои комнаты</h1>
<div className="flex gap-3">
<Link
href="/dashboard/create"
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors text-sm"
>
Создать комнату
</Link>
<button
onClick={() => signOut().then(() => router.push("/"))}
className="px-5 py-2 bg-neutral-800 hover:bg-neutral-700 rounded-lg font-medium transition-colors text-sm"
>
Выйти
</button>
</div>
</div>
{loading ? (
<p className="text-neutral-400">Загрузка комнат...</p>
) : rooms.length === 0 ? (
<div className="text-center py-20">
<p className="text-neutral-400 mb-4">У вас пока нет комнат</p>
<Link
href="/dashboard/create"
className="text-blue-500 hover:text-blue-400"
>
Создать первую комнату
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{rooms.map((room) => (
<Link
key={room.id}
href={`/room/${room.code}`}
className="block bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-neutral-700 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<h2 className="font-semibold text-lg">{room.name}</h2>
<span
className={`text-xs px-2.5 py-1 rounded-full font-medium ${statusColors[room.status]}`}
>
{statusLabels[room.status]}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-neutral-400">
<span className="font-mono bg-neutral-800 px-2 py-0.5 rounded">
{room.code}
</span>
<span>
{new Date(room.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
</Link>
))}
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { createToken } from "@/lib/livekit";
const tokenSchema = z.object({
roomId: z.string().min(1),
});
export async function POST(req: Request) {
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const parsed = tokenSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const room = await prisma.room.findUnique({ where: { id: parsed.data.roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
// Verify the requesting user is the host of this specific room
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room not started yet" }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const token = await createToken(user.id, room.livekitRoomName, {
name: user.name,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
return NextResponse.json({ token });
}

View File

@@ -0,0 +1,91 @@
import { WebhookReceiver } from "livekit-server-sdk";
import { prisma } from "@/lib/prisma";
const receiver = new WebhookReceiver(
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!
);
export async function POST(req: Request) {
const body = await req.text();
const authHeader = req.headers.get("Authorization") ?? "";
let event;
try {
event = await receiver.receive(body, authHeader);
} catch {
return new Response(JSON.stringify({ error: "Invalid webhook signature" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const eventType = event.event;
switch (eventType) {
case "room_started": {
console.log(`[LiveKit] Room started: ${event.room?.name}`);
break;
}
case "room_finished": {
const roomName = event.room?.name;
if (roomName) {
const room = await prisma.room.findUnique({
where: { livekitRoomName: roomName },
});
if (room && room.status === "ACTIVE") {
await prisma.room.update({
where: { id: room.id },
data: { status: "ENDED", endedAt: new Date() },
});
await prisma.lectureArtifact.upsert({
where: { roomId: room.id },
update: {},
create: { roomId: room.id },
});
}
}
break;
}
case "participant_joined": {
const roomName = event.room?.name;
const identity = event.participant?.identity;
if (roomName && identity) {
const room = await prisma.room.findUnique({
where: { livekitRoomName: roomName },
});
if (room) {
await prisma.participantHistory.updateMany({
where: { roomId: room.id, sessionId: identity, leftAt: null },
data: { joinedAt: new Date() },
});
}
}
break;
}
case "participant_left": {
const roomName = event.room?.name;
const identity = event.participant?.identity;
if (roomName && identity) {
const room = await prisma.room.findUnique({
where: { livekitRoomName: roomName },
});
if (room) {
await prisma.participantHistory.updateMany({
where: { roomId: room.id, sessionId: identity, leftAt: null },
data: { leftAt: new Date() },
});
}
}
break;
}
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
const sendMessageSchema = z.object({
sessionId: z.string().min(1),
senderName: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
});
async function verifyRoomAccess(req: Request, roomId: string, sessionId?: string | null): Promise<boolean> {
// Check if requester is authenticated host/admin
const session = await getSessionFromRequest(req);
if (session) {
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (room && room.hostId === session.user.id) return true;
}
// Check if sessionId belongs to a participant of this room
if (sessionId) {
const participant = await prisma.participantHistory.findFirst({
where: { roomId, sessionId },
});
if (participant) return true;
}
return false;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor");
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 100);
const sessionId = url.searchParams.get("sessionId");
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const messages = await prisma.chatMessage.findMany({
where: {
roomId,
...(cursor ? { createdAt: { lt: new Date(cursor) } } : {}),
},
orderBy: { createdAt: "desc" },
take: limit,
});
const nextCursor = messages.length === limit
? messages[messages.length - 1].createdAt.toISOString()
: null;
return NextResponse.json({ messages: messages.reverse(), nextCursor });
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = sendMessageSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, parsed.data.sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const message = await prisma.chatMessage.create({
data: {
roomId,
sessionId: parsed.data.sessionId,
senderName: parsed.data.senderName,
content: parsed.data.content,
},
});
return NextResponse.json(message, { status: 201 });
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { roomService } from "@/lib/livekit";
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
// Stop the LiveKit room if it exists
if (room.livekitRoomName) {
try {
await roomService.deleteRoom(room.livekitRoomName);
} catch {
// Room might already be gone, ignore
}
}
const updated = await prisma.room.update({
where: { id: roomId },
data: {
status: "ENDED",
endedAt: new Date(),
},
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
const uploadSchema = z.object({
sessionId: z.string().min(1),
fileName: z.string().min(1).max(255),
fileKey: z.string().min(1),
fileSize: z.number().int().positive(),
mimeType: z.string().min(1),
});
async function verifyRoomAccess(req: Request, roomId: string, sessionId?: string | null): Promise<boolean> {
// Check if requester is authenticated host/admin
const session = await getSessionFromRequest(req);
if (session) {
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (room && room.hostId === session.user.id) return true;
}
// Check if sessionId belongs to a participant of this room
if (sessionId) {
const participant = await prisma.participantHistory.findFirst({
where: { roomId, sessionId },
});
if (participant) return true;
}
return false;
}
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const sessionId = url.searchParams.get("sessionId");
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const files = await prisma.sharedFile.findMany({
where: { roomId },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(files);
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = uploadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
// Verify access
const hasAccess = await verifyRoomAccess(req, roomId, parsed.data.sessionId);
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const file = await prisma.sharedFile.create({
data: {
roomId,
...parsed.data,
},
});
return NextResponse.json(file, { status: 201 });
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const handRaiseSchema = z.object({
sessionId: z.string().min(1),
raised: z.boolean(),
});
// Guest raises/lowers hand — no host auth required, only participant check
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = handRaiseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { sessionId, raised } = parsed.data;
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room || !room.webinarMode) {
return NextResponse.json({ error: "Not a webinar room" }, { status: 400 });
}
// Verify participant is in the room
const participant = await prisma.participantHistory.findFirst({
where: { roomId, sessionId, leftAt: null },
});
if (!participant) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 });
}
// Store hand-raise state in participant metadata (we use the DB for now)
// The host will poll GET /api/rooms/[roomId]/hand-raise to see raised hands
// For simplicity, we return success — the actual grant_publish is done by host via /moderate
return NextResponse.json({ success: true, raised });
}
// Host fetches list of raised hands
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const _host = url.searchParams.get("host");
// In a real implementation, hand-raise state would be stored in a separate table
// or in LiveKit participant metadata via DataChannels.
// For MVP, hand-raising is handled client-side via LiveKit DataChannels.
// This endpoint exists as a fallback.
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
return NextResponse.json({ participants: [] });
}

View File

@@ -0,0 +1,127 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { createToken } from "@/lib/livekit";
const joinSchema = z.object({
displayName: z.string().min(1).max(100),
sessionId: z.string().min(1),
pin: z.string().optional(),
sessionFingerprint: z.string().min(1),
});
// CRITICAL-10: In-memory rate limiter for PIN attempts
const pinAttempts = new Map<string, { count: number; resetTime: number }>();
const PIN_MAX_ATTEMPTS = 5;
const PIN_WINDOW_MS = 60_000; // 1 minute
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const body = await req.json();
const parsed = joinSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { displayName, sessionId, pin, sessionFingerprint } = parsed.data;
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.status !== "ACTIVE") {
return NextResponse.json({ error: "Room is not active" }, { status: 409 });
}
if (room.isLocked) {
return NextResponse.json({ error: "Room is locked" }, { status: 403 });
}
// Check ban by fingerprint
const banByFingerprint = await prisma.bannedEntry.findUnique({
where: { roomId_sessionFingerprint: { roomId, sessionFingerprint } },
});
if (banByFingerprint) {
return NextResponse.json({ error: "You are banned from this room" }, { status: 403 });
}
// Check ban by sessionId (secondary check)
const banBySession = await prisma.bannedEntry.findFirst({
where: { roomId, sessionFingerprint: sessionId },
});
if (banBySession) {
return NextResponse.json({ error: "You are banned from this room" }, { status: 403 });
}
// Verify PIN with rate limiting
if (room.pinHash) {
if (!pin) {
return NextResponse.json({ error: "PIN required" }, { status: 401 });
}
// Rate limit PIN attempts by roomId + IP
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
const rateLimitKey = `${roomId}:${ip}`;
const now = Date.now();
const attempt = pinAttempts.get(rateLimitKey);
if (attempt && now < attempt.resetTime) {
if (attempt.count >= PIN_MAX_ATTEMPTS) {
return NextResponse.json(
{ error: "Too many PIN attempts. Try again later." },
{ status: 429 }
);
}
attempt.count++;
} else {
pinAttempts.set(rateLimitKey, { count: 1, resetTime: now + PIN_WINDOW_MS });
}
const valid = await compare(pin, room.pinHash);
if (!valid) {
return NextResponse.json({ error: "Invalid PIN" }, { status: 401 });
}
}
// Lobby flow
if (room.lobbyEnabled) {
const entry = await prisma.lobbyEntry.create({
data: {
roomId,
displayName,
sessionId,
sessionFingerprint,
status: "PENDING",
},
});
return NextResponse.json({ status: "lobby", lobbyEntryId: entry.id });
}
// Direct join
await prisma.participantHistory.create({
data: {
roomId,
sessionId,
displayName,
role: "GUEST",
},
});
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room not started yet" }, { status: 400 });
}
const canPublish = !room.webinarMode;
const token = await createToken(sessionId, room.livekitRoomName, {
name: displayName,
canPublish,
canSubscribe: true,
canPublishData: true,
});
return NextResponse.json({ status: "approved", token });
}

View File

@@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { createToken } from "@/lib/livekit";
const lobbyActionSchema = z.object({
lobbyEntryId: z.string().min(1),
action: z.enum(["APPROVED", "REJECTED"]),
});
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const entries = await prisma.lobbyEntry.findMany({
where: { roomId, status: "PENDING" },
orderBy: { createdAt: "asc" },
});
return NextResponse.json(entries);
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = lobbyActionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { lobbyEntryId, action } = parsed.data;
const entry = await prisma.lobbyEntry.findUnique({ where: { id: lobbyEntryId } });
if (!entry || entry.roomId !== roomId) {
return NextResponse.json({ error: "Lobby entry not found" }, { status: 404 });
}
if (entry.status !== "PENDING") {
return NextResponse.json({ error: "Entry already processed" }, { status: 409 });
}
if (action === "REJECTED") {
await prisma.lobbyEntry.update({
where: { id: lobbyEntryId },
data: { status: "REJECTED" },
});
return NextResponse.json({ status: "rejected" });
}
// APPROVED
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room not started yet" }, { status: 400 });
}
const canPublish = !room.webinarMode;
const token = await createToken(entry.sessionId, room.livekitRoomName, {
name: entry.displayName,
canPublish,
canSubscribe: true,
canPublishData: true,
});
await prisma.$transaction([
prisma.lobbyEntry.update({
where: { id: lobbyEntryId },
data: { status: "APPROVED" },
}),
prisma.participantHistory.create({
data: {
roomId,
sessionId: entry.sessionId,
displayName: entry.displayName,
role: "GUEST",
},
}),
]);
// Store token temporarily in lobbyEntry metadata — we use the updatedAt as a signal
// The SSE endpoint will query the entry status and generate a fresh token
// We store it via a simple in-memory approach or re-generate in SSE
// For simplicity, we return it here too (host can relay it)
return NextResponse.json({ status: "approved", token });
}

View File

@@ -0,0 +1,112 @@
import { prisma } from "@/lib/prisma";
import { createToken } from "@/lib/livekit";
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const url = new URL(req.url);
const sessionId = url.searchParams.get("sessionId");
if (!sessionId) {
return new Response(JSON.stringify({ error: "sessionId required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return new Response(JSON.stringify({ error: "Room not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// CRITICAL-8: Verify sessionId matches an existing LobbyEntry for this room
const existingEntry = await prisma.lobbyEntry.findFirst({
where: { roomId, sessionId },
});
if (!existingEntry) {
return new Response(JSON.stringify({ error: "No lobby entry found for this session" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const send = (data: Record<string, unknown>) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
let resolved = false;
const maxPolls = 150; // 5 minutes at 2s intervals
for (let i = 0; i < maxPolls && !resolved; i++) {
try {
const entry = await prisma.lobbyEntry.findFirst({
where: { roomId, sessionId },
orderBy: { createdAt: "desc" },
});
if (!entry) {
send({ status: "error", message: "Lobby entry not found" });
resolved = true;
break;
}
if (entry.status === "APPROVED") {
// Re-fetch room to get fresh livekitRoomName (may have been started after lobby entry)
const freshRoom = await prisma.room.findUnique({ where: { id: roomId } });
if (!freshRoom?.livekitRoomName) {
send({ status: "error", message: "Room not started yet" });
resolved = true;
break;
}
const canPublish = !freshRoom.webinarMode;
const token = await createToken(sessionId, freshRoom.livekitRoomName, {
name: entry.displayName,
canPublish,
canSubscribe: true,
canPublishData: true,
});
send({ status: "APPROVED", token });
resolved = true;
break;
}
if (entry.status === "REJECTED") {
send({ status: "REJECTED" });
resolved = true;
break;
}
send({ status: "PENDING" });
} catch {
send({ status: "error", message: "Internal error" });
resolved = true;
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
if (!resolved) {
send({ status: "timeout" });
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

View File

@@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { roomService } from "@/lib/livekit";
const moderateSchema = z.object({
action: z.enum(["kick", "ban", "mute_all", "grant_publish", "revoke_publish"]),
targetSessionId: z.string().optional(),
reason: z.string().optional(),
});
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!room.livekitRoomName) {
return NextResponse.json({ error: "Room has no LiveKit session" }, { status: 409 });
}
const body = await req.json();
const parsed = moderateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { action, targetSessionId, reason } = parsed.data;
const lkRoom = room.livekitRoomName;
switch (action) {
case "kick": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
await roomService.removeParticipant(lkRoom, targetSessionId);
await prisma.participantHistory.updateMany({
where: { roomId, sessionId: targetSessionId, leftAt: null },
data: { leftAt: new Date() },
});
return NextResponse.json({ success: true });
}
case "ban": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
// Find fingerprint from lobby entry or participant
const lobbyEntry = await prisma.lobbyEntry.findFirst({
where: { roomId, sessionId: targetSessionId },
orderBy: { createdAt: "desc" },
});
const fingerprint = lobbyEntry?.sessionFingerprint ?? targetSessionId;
await roomService.removeParticipant(lkRoom, targetSessionId);
await prisma.participantHistory.updateMany({
where: { roomId, sessionId: targetSessionId, leftAt: null },
data: { leftAt: new Date() },
});
await prisma.bannedEntry.upsert({
where: { roomId_sessionFingerprint: { roomId, sessionFingerprint: fingerprint } },
update: { reason },
create: {
roomId,
sessionFingerprint: fingerprint,
displayName: lobbyEntry?.displayName,
reason,
bannedById: session.user.id,
},
});
return NextResponse.json({ success: true });
}
case "mute_all": {
const participants = await roomService.listParticipants(lkRoom);
for (const p of participants) {
if (p.identity === session.user.id) continue; // skip host
await roomService.updateParticipant(lkRoom, p.identity, {
permission: { canPublish: false, canSubscribe: true, canPublishData: true },
});
}
return NextResponse.json({ success: true });
}
case "grant_publish": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
await roomService.updateParticipant(lkRoom, targetSessionId, {
permission: { canPublish: true, canSubscribe: true, canPublishData: true },
});
return NextResponse.json({ success: true });
}
case "revoke_publish": {
if (!targetSessionId) {
return NextResponse.json({ error: "targetSessionId required" }, { status: 400 });
}
await roomService.updateParticipant(lkRoom, targetSessionId, {
permission: { canPublish: false, canSubscribe: true, canPublishData: true },
});
return NextResponse.json({ success: true });
}
default:
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
}

View File

@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { hash } from "bcryptjs";
const updateRoomSchema = z.object({
name: z.string().min(1).max(255).optional(),
lobbyEnabled: z.boolean().optional(),
webinarMode: z.boolean().optional(),
isLocked: z.boolean().optional(),
pin: z.string().min(4).max(20).nullable().optional(),
});
export async function GET(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { host: { select: { id: true, name: true } } },
});
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (session) {
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (user && (user.role === "ADMIN" || room.hostId === user.id)) {
return NextResponse.json(room);
}
}
// Limited info for guests
return NextResponse.json({
id: room.id,
name: room.name,
code: room.code,
status: room.status,
lobbyEnabled: room.lobbyEnabled,
webinarMode: room.webinarMode,
isLocked: room.isLocked,
hasPin: !!room.pinHash,
host: room.host,
});
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (!user || (user.role !== "ADMIN" && room.hostId !== user.id)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = updateRoomSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { pin, ...rest } = parsed.data;
const data: Record<string, unknown> = { ...rest };
if (pin !== undefined) {
data.pinHash = pin ? await hash(pin, 10) : null;
}
const updated = await prisma.room.update({
where: { id: roomId },
data,
});
return NextResponse.json(updated);
}
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (!user || (user.role !== "ADMIN" && room.hostId !== user.id)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (room.status === "ACTIVE") {
return NextResponse.json({ error: "Cannot delete an active room" }, { status: 409 });
}
await prisma.room.delete({ where: { id: roomId } });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { roomService } from "@/lib/livekit";
export async function POST(
req: Request,
{ params }: { params: Promise<{ roomId: string }> }
) {
const { roomId } = await params;
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
if (room.hostId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (room.status !== "WAITING") {
return NextResponse.json({ error: "Room is not in WAITING status" }, { status: 409 });
}
const livekitRoomName = `room-${roomId}`;
await roomService.createRoom({ name: livekitRoomName });
const updated = await prisma.room.update({
where: { id: roomId },
data: {
status: "ACTIVE",
startedAt: new Date(),
livekitRoomName,
},
});
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ code: string }> }
) {
const { code } = await params;
const room = await prisma.room.findUnique({
where: { code },
select: {
id: true,
name: true,
code: true,
status: true,
lobbyEnabled: true,
pinHash: true,
webinarMode: true,
hostId: true,
},
});
if (!room) {
return NextResponse.json({ error: "Room not found" }, { status: 404 });
}
return NextResponse.json({
id: room.id,
name: room.name,
code: room.code,
status: room.status,
hostId: room.hostId,
lobbyEnabled: room.lobbyEnabled,
hasPin: !!room.pinHash,
webinarMode: room.webinarMode,
});
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSessionFromRequest } from "@/lib/auth-helpers";
import { hash } from "bcryptjs";
const createRoomSchema = z.object({
name: z.string().min(1).max(255),
lobbyEnabled: z.boolean().optional().default(true),
webinarMode: z.boolean().optional().default(false),
pin: z.string().min(4).max(20).optional(),
});
export async function POST(req: Request) {
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user || (user.role !== "HOST" && user.role !== "ADMIN")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = createRoomSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
const { name, lobbyEnabled, webinarMode, pin } = parsed.data;
const pinHash = pin ? await hash(pin, 10) : null;
const room = await prisma.room.create({
data: {
name,
lobbyEnabled,
webinarMode,
pinHash,
hostId: user.id,
},
});
return NextResponse.json(room, { status: 201 });
}
export async function GET(req: Request) {
const session = await getSessionFromRequest(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user || (user.role !== "HOST" && user.role !== "ADMIN")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const where = user.role === "ADMIN" ? {} : { hostId: user.id };
const rooms = await prisma.room.findMany({
where,
orderBy: { createdAt: "desc" },
});
return NextResponse.json(rooms);
}

1
src/app/globals.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,202 @@
"use client";
import { useState, useEffect, type FormEvent } from "react";
import { useParams, useRouter } from "next/navigation";
import WaitingRoom from "@/components/lobby/WaitingRoom";
type RoomInfo = {
id: string;
name: string;
code: string;
status: string;
hostId: string;
lobbyEnabled: boolean;
hasPin: boolean;
webinarMode: boolean;
};
export default function JoinCodePage() {
const params = useParams<{ code: string }>();
const router = useRouter();
const [room, setRoom] = useState<RoomInfo | null>(null);
const [displayName, setDisplayName] = useState("");
const [pin, setPin] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [joining, setJoining] = useState(false);
const [inLobby, setInLobby] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const [sessionFingerprint] = useState(() => {
// Generate a persistent fingerprint based on browser characteristics
const nav = typeof navigator !== "undefined" ? navigator : null;
const raw = [
nav?.userAgent ?? "",
nav?.language ?? "",
screen?.width ?? "",
screen?.height ?? "",
Intl.DateTimeFormat().resolvedOptions().timeZone ?? "",
].join("|");
// Simple hash
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
});
useEffect(() => {
async function fetchRoom() {
try {
const res = await fetch(`/api/rooms/by-code/${params.code}`);
if (!res.ok) {
setError("Комната не найдена");
return;
}
const roomData = await res.json();
setRoom(roomData);
} catch {
setError("Ошибка загрузки комнаты");
} finally {
setLoading(false);
}
}
fetchRoom();
}, [params.code]);
async function handleJoin(e: FormEvent) {
e.preventDefault();
if (!room) return;
setError("");
setJoining(true);
try {
const res = await fetch(`/api/rooms/${room.id}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
displayName,
pin: pin || undefined,
sessionId,
sessionFingerprint,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Ошибка при подключении");
setJoining(false);
return;
}
if (data.status === "lobby") {
setInLobby(true);
} else if (data.status === "approved" && data.token) {
router.push(`/room/${room.code}?token=${encodeURIComponent(data.token)}`);
}
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
setJoining(false);
}
}
function handleApproved(token: string) {
if (room) {
router.push(`/room/${room.code}?token=${encodeURIComponent(token)}`);
}
}
if (loading) {
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Загрузка...</p>
</main>
);
}
if (inLobby && room) {
return (
<WaitingRoom
roomId={room.id}
sessionId={sessionId}
onApproved={handleApproved}
/>
);
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{room ? (
<>
<h1 className="text-2xl font-bold mb-1 text-center">{room.name}</h1>
<p className="text-neutral-400 text-sm text-center mb-8">
Код: {room.code}
</p>
<form onSubmit={handleJoin} className="space-y-4">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="displayName" className="block text-sm text-neutral-400 mb-1">
Ваше имя
</label>
<input
id="displayName"
type="text"
required
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Иван"
/>
</div>
{room.hasPin && (
<div>
<label htmlFor="pin" className="block text-sm text-neutral-400 mb-1">
PIN-код комнаты
</label>
<input
id="pin"
type="text"
required
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors text-center font-mono tracking-widest"
placeholder="1234"
maxLength={8}
/>
</div>
)}
<button
type="submit"
disabled={joining}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{joining ? "Подключение..." : "Присоединиться"}
</button>
</form>
</>
) : (
<div className="text-center">
<p className="text-red-400 mb-4">{error || "Комната не найдена"}</p>
<button
onClick={() => router.push("/join")}
className="text-blue-500 hover:text-blue-400"
>
Ввести другой код
</button>
</div>
)}
</div>
</main>
);
}

42
src/app/join/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
export default function JoinPage() {
const router = useRouter();
const [code, setCode] = useState("");
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (code.trim()) {
router.push(`/join/${code.trim()}`);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm text-center">
<h1 className="text-3xl font-bold mb-2">Присоединиться</h1>
<p className="text-neutral-400 mb-8">Введите код комнаты</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
required
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors text-center text-lg font-mono tracking-widest"
placeholder="ABC123"
/>
<button
type="submit"
className="w-full py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
>
Далее
</button>
</form>
</div>
</main>
);
}

25
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import "@livekit/components-styles";
const inter = Inter({ subsets: ["latin", "cyrillic"] });
export const metadata: Metadata = {
title: "LiveServer",
description: "Образовательная видеоконференц-платформа",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className={`${inter.className} bg-neutral-950 text-white antialiased`}>
{children}
</body>
</html>
);
}

95
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,95 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signIn } from "@/lib/auth-client";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? "Ошибка авторизации");
} else {
router.push("/dashboard");
}
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-bold mb-8 text-center">Вход</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm text-neutral-400 mb-1">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-1">
Пароль
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="********"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{loading ? "Вход..." : "Войти"}
</button>
</form>
<p className="mt-6 text-center text-sm text-neutral-400">
Нет аккаунта?{" "}
<Link href="/register" className="text-blue-500 hover:text-blue-400">
Зарегистрироваться
</Link>
</p>
</div>
</main>
);
}

28
src/app/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
import Link from "next/link";
export default function Home() {
return (
<main className="min-h-screen flex flex-col items-center justify-center px-4">
<div className="text-center max-w-2xl">
<h1 className="text-6xl font-bold tracking-tight mb-4">LiveServer</h1>
<p className="text-xl text-neutral-400 mb-12">
Образовательная видеоконференц-платформа
</p>
<div className="flex gap-4 justify-center">
<Link
href="/login"
className="px-8 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
>
Войти
</Link>
<Link
href="/join"
className="px-8 py-3 bg-neutral-800 hover:bg-neutral-700 rounded-lg font-medium transition-colors"
>
Присоединиться к лекции
</Link>
</div>
</div>
</main>
);
}

112
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,112 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signUp } from "@/lib/auth-client";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? "Ошибка регистрации");
} else {
router.push("/dashboard");
}
} catch {
setError("Произошла ошибка. Попробуйте ещё раз.");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-3xl font-bold mb-8 text-center">Регистрация</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-600/20 border border-red-600/50 text-red-400 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm text-neutral-400 mb-1">
Имя
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Иван Иванов"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm text-neutral-400 mb-1">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-1">
Пароль
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg focus:outline-none focus:border-blue-600 transition-colors"
placeholder="Минимум 8 символов"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium transition-colors"
>
{loading ? "Регистрация..." : "Создать аккаунт"}
</button>
</form>
<p className="mt-6 text-center text-sm text-neutral-400">
Уже есть аккаунт?{" "}
<Link href="/login" className="text-blue-500 hover:text-blue-400">
Войти
</Link>
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams, useSearchParams } from "next/navigation";
import {
LiveKitRoom,
VideoConference,
} from "@livekit/components-react";
import { useSession } from "@/lib/auth-client";
import ModerationPanel from "@/components/room/ModerationPanel";
import ChatPanel from "@/components/room/ChatPanel";
import LobbyManager from "@/components/lobby/LobbyManager";
const LIVEKIT_URL = process.env.NEXT_PUBLIC_LIVEKIT_URL || "ws://localhost:7880";
type RoomInfo = {
id: string;
name: string;
code: string;
hostId: string;
status: string;
lobbyEnabled: boolean;
hasPin: boolean;
webinarMode: boolean;
};
export default function RoomPage() {
const params = useParams<{ code: string }>();
const searchParams = useSearchParams();
const { data: session } = useSession();
const [token, setToken] = useState<string | null>(null);
const [room, setRoom] = useState<RoomInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showChat, setShowChat] = useState(false);
const [showLobby, setShowLobby] = useState(false);
const [handRaised, setHandRaised] = useState(false);
const userId = session?.user?.id ?? null;
const isHost = userId && room ? userId === room.hostId : false;
useEffect(() => {
const tokenFromUrl = searchParams.get("token");
if (tokenFromUrl) {
setToken(tokenFromUrl);
}
}, [searchParams]);
useEffect(() => {
async function fetchRoom() {
try {
const res = await fetch(`/api/rooms/by-code/${params.code}`);
if (!res.ok) {
setError("Комната не найдена");
return;
}
const roomData = await res.json();
setRoom(roomData);
// If authenticated user is host and no token yet, fetch host token
if (!token && userId && roomData.hostId === userId) {
const tokenRes = await fetch("/api/livekit/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: roomData.id }),
});
if (tokenRes.ok) {
const tokenData = await tokenRes.json();
setToken(tokenData.token);
}
}
} catch {
setError("Ошибка загрузки комнаты");
} finally {
setLoading(false);
}
}
fetchRoom();
}, [params.code, userId, token]);
const handleRaiseHand = useCallback(async () => {
if (!room) return;
const newState = !handRaised;
setHandRaised(newState);
try {
await fetch(`/api/rooms/${room.id}/hand-raise`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: userId ?? "anonymous",
raised: newState,
}),
});
} catch {
// silent — hand raise also works via LiveKit DataChannels
}
}, [room, handRaised, userId]);
if (loading) {
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-neutral-400">Загрузка комнаты...</p>
</main>
);
}
if (error || !token) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-400 mb-4">{error || "Нет токена для подключения"}</p>
</div>
</main>
);
}
return (
<div className="h-screen flex flex-col bg-neutral-950">
{/* Top bar */}
<header className="flex items-center justify-between px-4 py-2 bg-neutral-900 border-b border-neutral-800 shrink-0">
<div className="flex items-center gap-3">
<h1 className="font-semibold">{room?.name ?? "Комната"}</h1>
{room && (
<span className="text-xs text-neutral-400 font-mono bg-neutral-800 px-2 py-0.5 rounded">
{room.code}
</span>
)}
</div>
<div className="flex items-center gap-2">
{room?.webinarMode && !isHost && (
<button
onClick={handleRaiseHand}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
handRaised
? "bg-yellow-600 hover:bg-yellow-700"
: "bg-neutral-800 hover:bg-neutral-700"
}`}
>
{handRaised ? "Опустить руку" : "Поднять руку"}
</button>
)}
{isHost && room?.lobbyEnabled && (
<button
onClick={() => setShowLobby((v) => !v)}
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-sm font-medium transition-colors"
>
Зал ожидания
</button>
)}
<button
onClick={() => setShowChat((v) => !v)}
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded-lg text-sm font-medium transition-colors"
>
Чат
</button>
</div>
</header>
{/* Main content */}
<LiveKitRoom
serverUrl={LIVEKIT_URL}
token={token}
connect={true}
className="flex flex-1 min-h-0"
>
{/* Video area */}
<div className="flex-1 min-w-0">
<VideoConference />
</div>
{/* Sidebars — inside LiveKitRoom for useParticipants() context */}
{(showChat || showLobby || isHost) && (
<aside className="w-80 shrink-0 border-l border-neutral-800 flex flex-col bg-neutral-900">
{isHost && showLobby && room && (
<div className="border-b border-neutral-800">
<LobbyManager roomId={room.id} />
</div>
)}
{isHost && room && <ModerationPanel roomId={room.id} />}
{showChat && room && (
<div className="flex-1 min-h-0">
<ChatPanel
roomId={room.id}
sessionId={userId ?? "anonymous"}
senderName={session?.user?.name ?? "Anonymous"}
/>
</div>
)}
</aside>
)}
</LiveKitRoom>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
interface LobbyManagerProps {
roomId: string;
}
type LobbyEntry = {
id: string;
sessionId: string;
displayName: string;
createdAt: string;
};
export default function LobbyManager({ roomId }: LobbyManagerProps) {
const [entries, setEntries] = useState<LobbyEntry[]>([]);
const [acting, setActing] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
const fetchEntries = useCallback(async () => {
try {
const res = await fetch(`/api/rooms/${roomId}/lobby`);
if (res.ok) {
const data = await res.json();
setEntries(data);
}
} catch {
// silent
}
}, [roomId]);
useEffect(() => {
fetchEntries();
pollRef.current = setInterval(fetchEntries, 3000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [fetchEntries]);
async function handleAction(entry: LobbyEntry, action: "APPROVED" | "REJECTED") {
setActing(entry.id);
try {
await fetch(`/api/rooms/${roomId}/lobby`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lobbyEntryId: entry.id, action }),
});
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
} catch {
// silent
} finally {
setActing(null);
}
}
return (
<div className="p-4">
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Зал ожидания ({entries.length})
</h3>
{entries.length === 0 ? (
<p className="text-sm text-neutral-500">Никто не ожидает</p>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
>
<span className="text-sm truncate mr-2">{entry.displayName}</span>
<div className="flex gap-1 shrink-0">
<button
onClick={() => handleAction(entry, "APPROVED")}
disabled={acting === entry.id}
className="px-2 py-1 text-xs bg-green-600 hover:bg-green-700 disabled:opacity-50 rounded font-medium transition-colors"
>
Принять
</button>
<button
onClick={() => handleAction(entry, "REJECTED")}
disabled={acting === entry.id}
className="px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 disabled:opacity-50 rounded font-medium transition-colors"
>
Отклонить
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState, useRef } from "react";
import type { SSELobbyEvent } from "@/types";
interface WaitingRoomProps {
roomId: string;
sessionId: string;
onApproved: (token: string) => void;
}
export default function WaitingRoom({
roomId,
sessionId,
onApproved,
}: WaitingRoomProps) {
const [rejected, setRejected] = useState(false);
const onApprovedRef = useRef(onApproved);
onApprovedRef.current = onApproved;
useEffect(() => {
const eventSource = new EventSource(
`/api/rooms/${roomId}/lobby/stream?sessionId=${sessionId}`
);
eventSource.onmessage = (event) => {
try {
const data: SSELobbyEvent = JSON.parse(event.data);
if (data.status === "APPROVED" && data.token) {
eventSource.close();
onApprovedRef.current(data.token);
} else if (data.status === "REJECTED") {
eventSource.close();
setRejected(true);
}
} catch {
// ignore parse errors
}
};
eventSource.onerror = () => {
// Reconnect is handled automatically by EventSource
};
return () => {
eventSource.close();
};
}, [roomId, sessionId]);
if (rejected) {
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-600/20 flex items-center justify-center">
<svg
className="w-8 h-8 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h2 className="text-xl font-bold mb-2">Вам отказано в доступе</h2>
<p className="text-neutral-400">Хост отклонил ваш запрос на вход</p>
</div>
</main>
);
}
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 relative">
<div className="absolute inset-0 rounded-full bg-blue-600/20 animate-ping" />
<div className="relative w-16 h-16 rounded-full bg-blue-600/30 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-blue-600 animate-pulse" />
</div>
</div>
<h2 className="text-xl font-bold mb-2">Ожидание подтверждения хоста...</h2>
<p className="text-neutral-400 text-sm">
Хост скоро подтвердит ваш вход в комнату
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useState, useEffect, useRef, type FormEvent } from "react";
interface ChatPanelProps {
roomId: string;
sessionId: string;
senderName: string;
}
type ChatMessage = {
id: string;
senderName: string;
content: string;
createdAt: string;
};
export default function ChatPanel({ roomId, sessionId, senderName }: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
useEffect(() => {
async function fetchMessages() {
try {
const res = await fetch(`/api/rooms/${roomId}/chat?sessionId=${sessionId}`);
if (res.ok) {
const data = await res.json();
setMessages(data.messages);
}
} catch {
// silent
}
}
fetchMessages();
pollRef.current = setInterval(fetchMessages, 3000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [roomId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function handleSend(e: FormEvent) {
e.preventDefault();
if (!input.trim() || sending) return;
setSending(true);
try {
const res = await fetch(`/api/rooms/${roomId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, senderName, content: input.trim() }),
});
if (res.ok) {
const msg = await res.json();
setMessages((prev) => [...prev, msg]);
setInput("");
}
} catch {
// silent
} finally {
setSending(false);
}
}
return (
<div className="flex flex-col h-full">
<div className="px-4 py-3 border-b border-neutral-800">
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider">
Чат
</h3>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<p className="text-sm text-neutral-500 text-center mt-8">
Сообщений пока нет
</p>
)}
{messages.map((msg) => (
<div key={msg.id}>
<p className="text-xs text-neutral-500 mb-0.5">{msg.senderName}</p>
<p className="text-sm bg-neutral-800 rounded-lg px-3 py-2 inline-block max-w-full break-words">
{msg.content}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSend} className="p-3 border-t border-neutral-800">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Сообщение..."
className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm focus:outline-none focus:border-blue-600 transition-colors"
/>
<button
type="submit"
disabled={sending || !input.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm font-medium transition-colors"
>
Отправить
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useState, useCallback } from "react";
import { useParticipants } from "@livekit/components-react";
import type { ModerationAction } from "@/types";
interface ModerationPanelProps {
roomId: string;
}
export default function ModerationPanel({ roomId }: ModerationPanelProps) {
const participants = useParticipants();
const [acting, setActing] = useState<string | null>(null);
const moderate = useCallback(
async (action: ModerationAction, targetSessionId?: string) => {
setActing(targetSessionId ?? action);
try {
await fetch(`/api/rooms/${roomId}/moderate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, targetSessionId }),
});
} catch {
// silent
} finally {
setActing(null);
}
},
[roomId]
);
return (
<div className="p-4">
<h3 className="text-sm font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Модерация
</h3>
<button
onClick={() => moderate("mute_all")}
disabled={acting === "mute_all"}
className="w-full py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors mb-4"
>
Выключить микрофон у всех
</button>
<div className="space-y-2">
<p className="text-xs text-neutral-500">
Участники ({participants.length})
</p>
{participants.map((p) => (
<div
key={p.identity}
className="flex items-center justify-between bg-neutral-800 rounded-lg px-3 py-2"
>
<span className="text-sm truncate mr-2">
{p.name || p.identity}
</span>
<div className="flex gap-1 shrink-0">
<button
onClick={() => moderate("kick", p.identity)}
disabled={acting === p.identity}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
>
Кик
</button>
<button
onClick={() => moderate("ban", p.identity)}
disabled={acting === p.identity}
className="px-2 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded transition-colors"
>
Бан
</button>
</div>
</div>
))}
</div>
</div>
);
}

7
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});
export const { signIn, signUp, signOut, useSession } = authClient;

8
src/lib/auth-helpers.ts Normal file
View File

@@ -0,0 +1,8 @@
import { auth } from "./auth";
export async function getSessionFromRequest(req: Request) {
const session = await auth.api.getSession({
headers: req.headers,
});
return session;
}

16
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
},
});

41
src/lib/livekit.ts Normal file
View File

@@ -0,0 +1,41 @@
import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
const livekitHost = process.env.LIVEKIT_URL!;
const apiKey = process.env.LIVEKIT_API_KEY!;
const apiSecret = process.env.LIVEKIT_API_SECRET!;
export const roomService = new RoomServiceClient(livekitHost, apiKey, apiSecret);
export function createToken(
identity: string,
roomName: string,
options: {
name?: string;
canPublish?: boolean;
canSubscribe?: boolean;
canPublishData?: boolean;
} = {}
): Promise<string> {
const {
name = identity,
canPublish = true,
canSubscribe = true,
canPublishData = true,
} = options;
const at = new AccessToken(apiKey, apiSecret, {
identity,
name,
ttl: "6h",
});
at.addGrant({
room: roomName,
roomJoin: true,
canPublish,
canSubscribe,
canPublishData,
});
return at.toJwt();
}

12
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
// @ts-expect-error -- Prisma 7 accepts datasourceUrl but types lag behind
new PrismaClient({ datasourceUrl: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

71
src/middleware.ts Normal file
View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Защита для локальной разработки.
* Если задана env ALLOWED_IPS — пропускает только эти IP.
* Если задана env DEV_ACCESS_KEY — требует ?key=... или cookie dev_access_key.
* В проде (DOMAIN задан) — middleware пропускает всё.
*/
export function middleware(req: NextRequest) {
// В проде — пропускаем
if (process.env.DOMAIN) {
return NextResponse.next();
}
const devAccessKey = process.env.DEV_ACCESS_KEY;
const allowedIps = process.env.ALLOWED_IPS; // "192.168.1.10,192.168.1.11"
// Проверка IP whitelist
if (allowedIps) {
const clientIp =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
"unknown";
const allowed = allowedIps.split(",").map((ip) => ip.trim());
// localhost всегда разрешён
if (
!allowed.includes(clientIp) &&
clientIp !== "127.0.0.1" &&
clientIp !== "::1" &&
clientIp !== "unknown"
) {
return new NextResponse("Forbidden", { status: 403 });
}
}
// Проверка access key
if (devAccessKey) {
const url = new URL(req.url);
const keyParam = url.searchParams.get("key");
const keyCookie = req.cookies.get("dev_access_key")?.value;
// Если ключ в query — ставим cookie и редиректим без key
if (keyParam === devAccessKey) {
url.searchParams.delete("key");
const res = NextResponse.redirect(url);
res.cookies.set("dev_access_key", devAccessKey, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 дней
path: "/",
});
return res;
}
// Если нет валидной cookie — блокируем
if (keyCookie !== devAccessKey) {
return new NextResponse(
"Access denied. Add ?key=YOUR_DEV_ACCESS_KEY to URL.",
{ status: 403 }
);
}
}
return NextResponse.next();
}
export const config = {
// Защищаем всё кроме статики и API вебхуков
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/livekit/webhook).*)"],
};

15
src/types/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export type RoomSecuritySettings = {
lobbyEnabled: boolean;
pinHash: string | null;
webinarMode: boolean;
isLocked: boolean;
};
export type LobbyAction = "APPROVED" | "REJECTED";
export type SSELobbyEvent = {
status: "PENDING" | "APPROVED" | "REJECTED";
token?: string; // LiveKit token, only on APPROVED
};
export type ModerationAction = "kick" | "ban" | "mute_all" | "grant_publish" | "revoke_publish";

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}