feat: LiveServer-M1 v1 — educational video conferencing platform #1
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
ai-agent
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
+37
-239
@@ -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/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/*.code-snippets
|
.idea/
|
||||||
|
*.iws
|
||||||
# Local History for Visual Studio Code
|
*.iml
|
||||||
|
*.ipr
|
||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
# ---> Node
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# TypeScript
|
||||||
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
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Python
|
||||||
.npm
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
ai-agent/.env
|
||||||
|
|
||||||
# Optional eslint cache
|
# Docker
|
||||||
.eslintcache
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Optional stylelint cache
|
# Misc
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
*.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
|
.cache
|
||||||
.parcel-cache
|
.eslintcache
|
||||||
|
coverage/
|
||||||
# Next.js build output
|
*.lcov
|
||||||
.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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -1,3 +1,228 @@
|
|||||||
# LiveServer-M1
|
# 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** | Вход по ссылке, участие в лекции (без регистрации) |
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [".next/", "node_modules/", "ai-agent/"],
|
||||||
|
},
|
||||||
|
];
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["@prisma/client", "bcryptjs"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8323
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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);
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
← Назад к комнатам
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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: [] });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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).*)"],
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user