From 30894e7f271617f92e37390deeb5b7f3fc8bd5ae Mon Sep 17 00:00:00 2001 From: KevinB-T Date: Fri, 15 May 2026 15:19:49 +0530 Subject: [PATCH] Configure VM services and fix auth flow - point MySQL and Whisper settings to the VM - add VM MySQL bootstrap scripts and docs - allow LAN Vite origins for CORS - fix Express 5 validation assignment crash - allow login with username or email - prevent recursive auth refresh retries --- .env.example | 68 + .gitignore | 32 + .husky/pre-commit | 1 + .prettierignore | 9 + .prettierrc | 6 + README.md | 71 + backend/.env.example | 24 + backend/package.json | 32 + backend/src/app.js | 27 + backend/src/config/database.js | 46 + backend/src/config/env.js | 129 + backend/src/config/migrate.js | 99 + backend/src/config/schema.sql | 106 + backend/src/controllers/audioController.js | 13 + backend/src/controllers/authController.js | 60 + .../src/controllers/transcriptController.js | 116 + .../controllers/transcriptionController.js | 16 + backend/src/controllers/userController.js | 11 + backend/src/docs/openapi.js | 84 + backend/src/index.js | 20 + backend/src/jobs/transcriptionQueue.js | 37 + backend/src/middlewares/authenticate.js | 40 + backend/src/middlewares/errorHandler.js | 27 + backend/src/middlewares/requestLogger.js | 10 + backend/src/middlewares/security.js | 43 + backend/src/middlewares/upload.js | 28 + backend/src/middlewares/validate.js | 23 + backend/src/models/mappers.js | 68 + backend/src/repositories/tokenRepository.js | 46 + .../src/repositories/transcriptRepository.js | 310 + backend/src/repositories/userRepository.js | 69 + backend/src/routes/index.js | 32 + backend/src/routes/v1/audioRoutes.js | 14 + backend/src/routes/v1/authRoutes.js | 48 + backend/src/routes/v1/index.js | 14 + backend/src/routes/v1/transcriptRoutes.js | 42 + backend/src/routes/v1/transcriptionRoutes.js | 16 + backend/src/routes/v1/userRoutes.js | 10 + backend/src/services/authService.js | 84 + backend/src/services/storage/httpAdapter.js | 59 + backend/src/services/storage/localAdapter.js | 32 + backend/src/services/storage/s3Adapter.js | 66 + backend/src/services/storage/smbAdapter.js | 8 + .../src/services/storage/storageService.js | 68 + backend/src/services/tokenService.js | 86 + backend/src/services/transcriptionService.js | 122 + backend/src/transcription/whisperClient.js | 121 + backend/src/utils/AppError.js | 16 + backend/src/utils/apiResponse.js | 19 + backend/src/utils/asyncHandler.js | 5 + backend/src/utils/crypto.js | 19 + backend/src/utils/logger.js | 18 + backend/src/utils/safeFilename.js | 10 + backend/src/validators/authValidators.js | 57 + .../src/validators/transcriptValidators.js | 40 + backend/src/validators/userValidators.js | 8 + docs/API.md | 46 + docs/REMOTE_DB.md | 32 + docs/STORAGE.md | 46 + docs/WHISPER_VM.md | 32 + ecosystem.config.cjs | 14 + eslint.config.js | 49 + frontend/.env.example | 4 + frontend/components.json | 22 + frontend/index.html | 19 + frontend/package.json | 69 + frontend/src/components/ui/accordion.tsx | 51 + frontend/src/components/ui/alert-dialog.tsx | 115 + frontend/src/components/ui/alert.tsx | 49 + frontend/src/components/ui/aspect-ratio.tsx | 5 + frontend/src/components/ui/avatar.tsx | 47 + frontend/src/components/ui/badge.tsx | 32 + frontend/src/components/ui/breadcrumb.tsx | 101 + frontend/src/components/ui/button.tsx | 49 + frontend/src/components/ui/calendar.tsx | 177 + frontend/src/components/ui/card.tsx | 55 + frontend/src/components/ui/carousel.tsx | 240 + frontend/src/components/ui/chart.tsx | 331 + frontend/src/components/ui/checkbox.tsx | 26 + frontend/src/components/ui/collapsible.tsx | 11 + frontend/src/components/ui/command.tsx | 143 + frontend/src/components/ui/context-menu.tsx | 187 + frontend/src/components/ui/dialog.tsx | 104 + frontend/src/components/ui/drawer.tsx | 98 + frontend/src/components/ui/dropdown-menu.tsx | 188 + frontend/src/components/ui/form.tsx | 171 + frontend/src/components/ui/hover-card.tsx | 27 + frontend/src/components/ui/input-otp.tsx | 69 + frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 21 + frontend/src/components/ui/menubar.tsx | 229 + .../src/components/ui/navigation-menu.tsx | 120 + frontend/src/components/ui/pagination.tsx | 98 + frontend/src/components/ui/popover.tsx | 31 + frontend/src/components/ui/progress.tsx | 25 + frontend/src/components/ui/radio-group.tsx | 36 + frontend/src/components/ui/resizable.tsx | 37 + frontend/src/components/ui/scroll-area.tsx | 44 + frontend/src/components/ui/select.tsx | 152 + frontend/src/components/ui/separator.tsx | 24 + frontend/src/components/ui/sheet.tsx | 122 + frontend/src/components/ui/sidebar.tsx | 744 ++ frontend/src/components/ui/skeleton.tsx | 7 + frontend/src/components/ui/slider.tsx | 23 + frontend/src/components/ui/sonner.tsx | 23 + frontend/src/components/ui/switch.tsx | 27 + frontend/src/components/ui/table.tsx | 94 + frontend/src/components/ui/tabs.tsx | 53 + frontend/src/components/ui/textarea.tsx | 21 + frontend/src/components/ui/toggle-group.tsx | 57 + frontend/src/components/ui/toggle.tsx | 42 + frontend/src/components/ui/tooltip.tsx | 32 + frontend/src/context/auth-context.tsx | 51 + frontend/src/context/auth.ts | 21 + frontend/src/hooks/use-mobile.tsx | 19 + frontend/src/lib/orphion-logo.tsx | 13 + frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 22 + frontend/src/routeTree.gen.ts | 280 + frontend/src/router.tsx | 16 + frontend/src/routes/__root.tsx | 60 + frontend/src/routes/_authenticated.tsx | 156 + .../src/routes/_authenticated/dashboard.tsx | 250 + frontend/src/routes/_authenticated/inbox.tsx | 173 + frontend/src/routes/_authenticated/record.tsx | 517 + frontend/src/routes/_authenticated/sent.tsx | 130 + .../src/routes/_authenticated/settings.tsx | 176 + .../routes/_authenticated/transcripts.$id.tsx | 316 + .../_authenticated/transcripts.index.tsx | 94 + frontend/src/routes/index.tsx | 32 + frontend/src/routes/login.tsx | 140 + frontend/src/routes/register.tsx | 136 + frontend/src/services/api.ts | 99 + frontend/src/services/audio.ts | 31 + frontend/src/services/auth.ts | 55 + frontend/src/services/transcripts.ts | 72 + frontend/src/services/types.ts | 68 + frontend/src/services/users.ts | 14 + frontend/src/styles.css | 207 + frontend/tsconfig.json | 28 + frontend/vite.config.ts | 56 + package-lock.json | 10210 ++++++++++++++++ package.json | 45 + .../whisper_http_server.cpython-310.pyc | Bin 0 -> 5619 bytes scripts/bootstrap-vm-mysql.sh | 19 + scripts/healthcheck.sh | 5 + scripts/migrate.sh | 4 + scripts/orphion-whisper.service | 19 + scripts/setup-local-mysql.sql | 12 + scripts/setup-vm-mysql.sql | 15 + scripts/whisper_http_server.py | 183 + 151 files changed, 21128 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/package.json create mode 100644 backend/src/app.js create mode 100644 backend/src/config/database.js create mode 100644 backend/src/config/env.js create mode 100644 backend/src/config/migrate.js create mode 100644 backend/src/config/schema.sql create mode 100644 backend/src/controllers/audioController.js create mode 100644 backend/src/controllers/authController.js create mode 100644 backend/src/controllers/transcriptController.js create mode 100644 backend/src/controllers/transcriptionController.js create mode 100644 backend/src/controllers/userController.js create mode 100644 backend/src/docs/openapi.js create mode 100644 backend/src/index.js create mode 100644 backend/src/jobs/transcriptionQueue.js create mode 100644 backend/src/middlewares/authenticate.js create mode 100644 backend/src/middlewares/errorHandler.js create mode 100644 backend/src/middlewares/requestLogger.js create mode 100644 backend/src/middlewares/security.js create mode 100644 backend/src/middlewares/upload.js create mode 100644 backend/src/middlewares/validate.js create mode 100644 backend/src/models/mappers.js create mode 100644 backend/src/repositories/tokenRepository.js create mode 100644 backend/src/repositories/transcriptRepository.js create mode 100644 backend/src/repositories/userRepository.js create mode 100644 backend/src/routes/index.js create mode 100644 backend/src/routes/v1/audioRoutes.js create mode 100644 backend/src/routes/v1/authRoutes.js create mode 100644 backend/src/routes/v1/index.js create mode 100644 backend/src/routes/v1/transcriptRoutes.js create mode 100644 backend/src/routes/v1/transcriptionRoutes.js create mode 100644 backend/src/routes/v1/userRoutes.js create mode 100644 backend/src/services/authService.js create mode 100644 backend/src/services/storage/httpAdapter.js create mode 100644 backend/src/services/storage/localAdapter.js create mode 100644 backend/src/services/storage/s3Adapter.js create mode 100644 backend/src/services/storage/smbAdapter.js create mode 100644 backend/src/services/storage/storageService.js create mode 100644 backend/src/services/tokenService.js create mode 100644 backend/src/services/transcriptionService.js create mode 100644 backend/src/transcription/whisperClient.js create mode 100644 backend/src/utils/AppError.js create mode 100644 backend/src/utils/apiResponse.js create mode 100644 backend/src/utils/asyncHandler.js create mode 100644 backend/src/utils/crypto.js create mode 100644 backend/src/utils/logger.js create mode 100644 backend/src/utils/safeFilename.js create mode 100644 backend/src/validators/authValidators.js create mode 100644 backend/src/validators/transcriptValidators.js create mode 100644 backend/src/validators/userValidators.js create mode 100644 docs/API.md create mode 100644 docs/REMOTE_DB.md create mode 100644 docs/STORAGE.md create mode 100644 docs/WHISPER_VM.md create mode 100644 ecosystem.config.cjs create mode 100644 eslint.config.js create mode 100644 frontend/.env.example create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/components/ui/accordion.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/aspect-ratio.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/breadcrumb.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/carousel.tsx create mode 100644 frontend/src/components/ui/chart.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/command.tsx create mode 100644 frontend/src/components/ui/context-menu.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/drawer.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/components/ui/hover-card.tsx create mode 100644 frontend/src/components/ui/input-otp.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/menubar.tsx create mode 100644 frontend/src/components/ui/navigation-menu.tsx create mode 100644 frontend/src/components/ui/pagination.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/radio-group.tsx create mode 100644 frontend/src/components/ui/resizable.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/toggle-group.tsx create mode 100644 frontend/src/components/ui/toggle.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/context/auth-context.tsx create mode 100644 frontend/src/context/auth.ts create mode 100644 frontend/src/hooks/use-mobile.tsx create mode 100644 frontend/src/lib/orphion-logo.tsx create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/_authenticated.tsx create mode 100644 frontend/src/routes/_authenticated/dashboard.tsx create mode 100644 frontend/src/routes/_authenticated/inbox.tsx create mode 100644 frontend/src/routes/_authenticated/record.tsx create mode 100644 frontend/src/routes/_authenticated/sent.tsx create mode 100644 frontend/src/routes/_authenticated/settings.tsx create mode 100644 frontend/src/routes/_authenticated/transcripts.$id.tsx create mode 100644 frontend/src/routes/_authenticated/transcripts.index.tsx create mode 100644 frontend/src/routes/index.tsx create mode 100644 frontend/src/routes/login.tsx create mode 100644 frontend/src/routes/register.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/audio.ts create mode 100644 frontend/src/services/auth.ts create mode 100644 frontend/src/services/transcripts.ts create mode 100644 frontend/src/services/types.ts create mode 100644 frontend/src/services/users.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/__pycache__/whisper_http_server.cpython-310.pyc create mode 100644 scripts/bootstrap-vm-mysql.sh create mode 100755 scripts/healthcheck.sh create mode 100755 scripts/migrate.sh create mode 100644 scripts/orphion-whisper.service create mode 100644 scripts/setup-local-mysql.sql create mode 100644 scripts/setup-vm-mysql.sql create mode 100644 scripts/whisper_http_server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b2ea39 --- /dev/null +++ b/.env.example @@ -0,0 +1,68 @@ +# Orphion API +NODE_ENV=development +HOST=127.0.0.1 +PORT=4000 +CLIENT_ORIGIN=http://localhost:5173,http://127.0.0.1:5173,http://172.16.11.139:5173,http://172.19.0.1:5173 +ORPHION_SERVICE_HOST=127.0.0.1 +TRUST_PROXY=false + +# MySQL VM +DB_HOST=172.16.10.64 +DB_PORT=3306 +DB_NAME=orphion +DB_USER=orphion +DB_PASSWORD=NexaVault2026!Blue +DB_CONNECTION_LIMIT=10 +DB_AUTO_MIGRATE=true + +# JWT / sessions +JWT_ACCESS_SECRET=replace-with-a-long-random-access-secret +JWT_REFRESH_SECRET=replace-with-a-different-long-random-refresh-secret +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=30d +JWT_COOKIE_NAME=orphion_access_token +JWT_REFRESH_COOKIE_NAME=orphion_refresh_token +JWT_COOKIE_SECURE=false +JWT_COOKIE_SAME_SITE=lax + +# Faster-Whisper VM +WHISPER_VM_IP=172.16.10.64 +WHISPER_VM_PORT=8000 +WHISPER_API_URL=http://172.16.10.64:8000 +WHISPER_TRANSCRIBE_PATH=/transcribe +WHISPER_HEALTH_PATH=/health +WHISPER_FILE_FIELD=file +WHISPER_TIMEOUT_MS=900000 +WHISPER_RETRIES=2 +WHISPER_RETRY_DELAY_MS=1500 +WHISPER_MODEL_NAME=faster-whisper-large-v3 +WHISPER_ALLOW_MOCK=false +TRANSCRIPTION_QUEUE_CONCURRENCY=1 + +# Upload validation +UPLOAD_TEMP_DIR=.tmp/uploads +MAX_AUDIO_SIZE_MB=200 +AUDIO_ALLOWED_MIME_TYPES=audio/webm,audio/wav,audio/mpeg,audio/mp4,audio/ogg,audio/x-m4a,video/webm + +# Storage: local | smb | nfs | http | s3 +STORAGE_DRIVER=local +STORAGE_BASE_PATH=../storage/audio +STORAGE_PUBLIC_BASE_URL= + +# HTTP storage adapter +STORAGE_HTTP_BASE_URL=http://127.0.0.1:9000/audio +STORAGE_HTTP_TOKEN= + +# S3-compatible storage adapter +S3_ENDPOINT=http://127.0.0.1:9000 +S3_REGION=us-east-1 +S3_BUCKET=orphion-audio +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_FORCE_PATH_STYLE=true + +# Frontend +VITE_API_BASE_URL= +VITE_API_PREFIX=/api/v1 +VITE_API_PORT=4000 +VITE_ORPHION_SERVICE_HOST=127.0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20c1c91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +frontend/dist +backend/.tmp +/storage +dist-ssr +.output +.vinxi +.tanstack/** +.nitro +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..5c3e95f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm exec lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..eeb0e68 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +node_modules +dist +.output +.vinxi +pnpm-lock.yaml +package-lock.json +bun.lock +routeTree.gen.ts +/storage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b9362c --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Orphion MoM + +Orphion is a premium voice-to-text collaboration platform for Minutes of Meeting workflows. + +## Architecture + +```text +frontend/ React + Vite dashboard +backend/ Node.js + Express REST API +scripts/ Optional local/ops helpers +docs/ API, database, storage, and Whisper notes +``` + +The backend exposes versioned REST APIs at `/api/v1`, stores users/transcripts/jobs in remote +MySQL, writes audio through a storage adapter, and processes transcription with a queued +Faster-Whisper VM client. + +## Local Development + +1. Install dependencies: + +```sh +npm install +``` + +2. Copy env values: + +```sh +cp .env.example .env +``` + +3. Create the MySQL database/user on the VM: + +```sh +sh scripts/bootstrap-vm-mysql.sh @172.16.10.64 +``` + +The sample env uses MySQL and Whisper on `172.16.10.64`. For alternate MySQL, storage, or Whisper +settings, see the files in `docs/`. + +4. Run the API and frontend: + +```sh +npm run dev:api +npm run dev +``` + +Or start both from one terminal: + +```sh +npm run dev:all +``` + +Frontend: `http://localhost:5173` or the Vite network URL printed in your terminal +Backend health: `http://localhost:4000/health` +Swagger UI: `http://localhost:4000/api/docs` + +## Key Features + +- JWT access tokens and refresh-token sessions in secure cookies +- Role-ready auth model with protected routes +- Remote MySQL connection pooling and migration runner +- Storage adapter pattern for SMB/NFS mounts, HTTP storage, S3-compatible storage, and local dev +- Queued transcription jobs with status tracking +- Faster-Whisper VM retries, timeout handling, and health checks +- Standard API envelope: `{ success, message, data }` +- Premium dark React dashboard with recording, upload progress, transcript history, playback, sharing, and downloads + +See `docs/` for setup details. + +`DB_PASSWORD=NexaVault2026!Blue` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7177f94 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,24 @@ +NODE_ENV=development +HOST=127.0.0.1 +PORT=4000 +CLIENT_ORIGIN=http://localhost:5173 + +DB_HOST=192.168.X.X +DB_PORT=3306 +DB_NAME=orphion +DB_USER=orphion +DB_PASSWORD=change_me +DB_AUTO_MIGRATE=true + +JWT_ACCESS_SECRET=replace-with-a-long-random-access-secret +JWT_REFRESH_SECRET=replace-with-a-different-long-random-refresh-secret +JWT_COOKIE_SECURE=false + +WHISPER_VM_IP=192.168.X.X +WHISPER_VM_PORT=8000 +WHISPER_API_URL=http://192.168.X.X:8000 +WHISPER_ALLOW_MOCK=false + +STORAGE_DRIVER=smb +STORAGE_BASE_PATH=/mnt/orphion-audio +MAX_AUDIO_SIZE_MB=200 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..11bc5cf --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "@orphion/backend", + "private": true, + "type": "module", + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "migrate": "node src/config/migrate.js", + "lint": "eslint ." + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.940.0", + "axios": "^1.13.2", + "bcrypt": "^6.0.0", + "compression": "^1.8.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "form-data": "^4.0.5", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.1", + "multer": "^2.0.2", + "mysql2": "^3.15.3", + "nanoid": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "zod": "^4.4.3" + } +} diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..1f27fbf --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,27 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import swaggerUi from "swagger-ui-express"; +import { env } from "./config/env.js"; +import { errorHandler, notFound } from "./middlewares/errorHandler.js"; +import { requestLogger } from "./middlewares/requestLogger.js"; +import { securityMiddleware } from "./middlewares/security.js"; +import { openApiSpec } from "./docs/openapi.js"; +import { routes } from "./routes/index.js"; + +export function createApp() { + const app = express(); + + securityMiddleware(app); + app.use(requestLogger); + app.use(express.json({ limit: env.requestBodyLimit })); + app.use(express.urlencoded({ extended: true })); + app.use(cookieParser()); + + app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec)); + app.use(routes); + + app.use(notFound); + app.use(errorHandler); + + return app; +} diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..bf8e3f2 --- /dev/null +++ b/backend/src/config/database.js @@ -0,0 +1,46 @@ +import mysql from "mysql2/promise"; +import { env } from "./env.js"; + +export const pool = mysql.createPool({ + host: env.database.host, + port: env.database.port, + user: env.database.user, + password: env.database.password, + database: env.database.name, + waitForConnections: true, + connectionLimit: env.database.connectionLimit, + namedPlaceholders: true, + decimalNumbers: true, + enableKeepAlive: true, + keepAliveInitialDelay: 0, +}); + +export async function query(sql, params = {}) { + const [rows] = await pool.execute(sql, params); + return rows; +} + +export async function transaction(work) { + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const result = await work(connection); + await connection.commit(); + return result; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +export async function pingDatabase() { + const connection = await pool.getConnection(); + try { + await connection.ping(); + return true; + } finally { + connection.release(); + } +} diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..58b790a --- /dev/null +++ b/backend/src/config/env.js @@ -0,0 +1,129 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const backendRoot = path.resolve(__dirname, "../.."); +const repoRoot = path.resolve(backendRoot, ".."); + +dotenv.config({ path: path.join(repoRoot, ".env"), quiet: true }); +dotenv.config({ path: path.join(backendRoot, ".env"), override: true, quiet: true }); + +function stringEnv(name, fallback = "") { + const value = process.env[name]; + return value && value.trim().length > 0 ? value.trim() : fallback; +} + +function numberEnv(name, fallback) { + const value = Number(process.env[name]); + return Number.isFinite(value) ? value : fallback; +} + +function boolEnv(name, fallback = false) { + const value = process.env[name]; + if (value === undefined) return fallback; + return ["1", "true", "yes", "on"].includes(value.toLowerCase()); +} + +function firstString(names, fallback = "") { + for (const name of names) { + const value = stringEnv(name); + if (value) return value; + } + return fallback; +} + +function normalizeBaseUrl(value) { + return value.replace(/\/+$/, ""); +} + +const serviceHost = stringEnv("ORPHION_SERVICE_HOST", "127.0.0.1"); +const whisperVmIp = firstString(["WHISPER_VM_IP", "WHISPER_HOST"], serviceHost); +const whisperVmPort = numberEnv("WHISPER_VM_PORT", numberEnv("WHISPER_PORT", 8000)); +const whisperApiUrl = normalizeBaseUrl( + firstString(["WHISPER_API_URL"], `http://${whisperVmIp}:${whisperVmPort}`), +); + +export const env = { + appName: "Orphion", + nodeEnv: stringEnv("NODE_ENV", "development"), + isProduction: stringEnv("NODE_ENV") === "production", + host: stringEnv("HOST", "127.0.0.1"), + port: numberEnv("PORT", 4000), + backendRoot, + repoRoot, + apiPrefix: "/api/v1", + clientOrigin: stringEnv("CLIENT_ORIGIN", "http://localhost:5173"), + trustProxy: boolEnv("TRUST_PROXY", false), + requestBodyLimit: stringEnv("REQUEST_BODY_LIMIT", "2mb"), + database: { + host: firstString(["DB_HOST", "MYSQL_HOST"], serviceHost), + port: numberEnv("DB_PORT", numberEnv("MYSQL_PORT", 3306)), + name: firstString(["DB_NAME", "MYSQL_DATABASE"], "orphion"), + user: firstString(["DB_USER", "MYSQL_USER"], "root"), + password: firstString(["DB_PASSWORD", "MYSQL_PASSWORD"], ""), + connectionLimit: numberEnv("DB_CONNECTION_LIMIT", numberEnv("MYSQL_CONNECTION_LIMIT", 10)), + autoMigrate: boolEnv("DB_AUTO_MIGRATE", boolEnv("MYSQL_AUTO_MIGRATE", true)), + }, + auth: { + accessTokenSecret: firstString(["JWT_ACCESS_SECRET", "JWT_SECRET"], "replace-access-secret"), + refreshTokenSecret: firstString( + ["JWT_REFRESH_SECRET"], + "replace-refresh-secret-with-a-different-long-value", + ), + accessTokenTtl: stringEnv("JWT_ACCESS_EXPIRES_IN", stringEnv("JWT_EXPIRES_IN", "15m")), + refreshTokenTtl: stringEnv("JWT_REFRESH_EXPIRES_IN", "30d"), + accessCookieName: stringEnv("JWT_COOKIE_NAME", "orphion_access_token"), + refreshCookieName: stringEnv("JWT_REFRESH_COOKIE_NAME", "orphion_refresh_token"), + cookieSecure: boolEnv("JWT_COOKIE_SECURE", false), + cookieSameSite: stringEnv("JWT_COOKIE_SAME_SITE", "lax"), + }, + upload: { + tempDir: path.resolve(backendRoot, stringEnv("UPLOAD_TEMP_DIR", ".tmp/uploads")), + maxAudioSizeMb: numberEnv("MAX_AUDIO_SIZE_MB", 200), + allowedMimeTypes: stringEnv( + "AUDIO_ALLOWED_MIME_TYPES", + "audio/webm,audio/wav,audio/mpeg,audio/mp4,audio/ogg,audio/x-m4a,video/webm", + ) + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + }, + storage: { + driver: stringEnv("STORAGE_DRIVER", "smb"), + basePath: path.resolve( + backendRoot, + stringEnv("STORAGE_BASE_PATH", stringEnv("REMOTE_STORAGE_PATH", "../storage/audio")), + ), + publicBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_PUBLIC_BASE_URL", "")), + httpBaseUrl: normalizeBaseUrl(stringEnv("STORAGE_HTTP_BASE_URL", "")), + httpToken: stringEnv("STORAGE_HTTP_TOKEN", ""), + s3: { + endpoint: stringEnv("S3_ENDPOINT", ""), + region: stringEnv("S3_REGION", "us-east-1"), + bucket: stringEnv("S3_BUCKET", ""), + accessKeyId: stringEnv("S3_ACCESS_KEY_ID", ""), + secretAccessKey: stringEnv("S3_SECRET_ACCESS_KEY", ""), + forcePathStyle: boolEnv("S3_FORCE_PATH_STYLE", true), + }, + }, + whisper: { + vmIp: whisperVmIp, + vmPort: whisperVmPort, + apiUrl: whisperApiUrl, + transcribePath: stringEnv("WHISPER_TRANSCRIBE_PATH", "/transcribe"), + healthPath: stringEnv("WHISPER_HEALTH_PATH", "/health"), + fileField: stringEnv("WHISPER_FILE_FIELD", "file"), + timeoutMs: numberEnv("WHISPER_TIMEOUT_MS", 900000), + retries: numberEnv("WHISPER_RETRIES", 2), + retryDelayMs: numberEnv("WHISPER_RETRY_DELAY_MS", 1500), + modelName: stringEnv("WHISPER_MODEL_NAME", "faster-whisper-large-v3"), + allowMock: boolEnv("WHISPER_ALLOW_MOCK", false), + queueConcurrency: numberEnv("TRANSCRIPTION_QUEUE_CONCURRENCY", 1), + }, + rateLimit: { + windowMs: numberEnv("RATE_LIMIT_WINDOW_MS", 15 * 60 * 1000), + max: numberEnv("RATE_LIMIT_MAX", 300), + authMax: numberEnv("AUTH_RATE_LIMIT_MAX", 30), + }, +}; diff --git a/backend/src/config/migrate.js b/backend/src/config/migrate.js new file mode 100644 index 0000000..46cc707 --- /dev/null +++ b/backend/src/config/migrate.js @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import mysql from "mysql2/promise"; +import { env } from "./env.js"; +import { pool, query } from "./database.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function identifier(value) { + return `\`${String(value).replaceAll("`", "``")}\``; +} + +function splitSql(source) { + return source + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); +} + +export async function ensureDatabase() { + const connection = await mysql.createConnection({ + host: env.database.host, + port: env.database.port, + user: env.database.user, + password: env.database.password, + }); + + try { + await connection.query( + `CREATE DATABASE IF NOT EXISTS ${identifier( + env.database.name, + )} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, + ); + } finally { + await connection.end(); + } +} + +export async function ensureSchema() { + await ensureDatabase(); + const schema = await fs.readFile(path.join(__dirname, "schema.sql"), "utf8"); + for (const statement of splitSql(schema)) { + await pool.query(statement); + } + await runMigrations(); +} + +export async function runMigrations() { + await query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(190) NOT NULL UNIQUE, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + const migrationDir = path.join(__dirname, "migrations"); + const entries = await fs.readdir(migrationDir).catch(() => []); + const files = entries.filter((file) => file.endsWith(".sql")).sort(); + + for (const file of files) { + const existing = await query("SELECT id FROM schema_migrations WHERE name = :name LIMIT 1", { + name: file, + }); + if (existing.length > 0) continue; + + const source = await fs.readFile(path.join(migrationDir, file), "utf8"); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + for (const statement of splitSql(source)) { + await connection.query(statement); + } + await connection.execute("INSERT INTO schema_migrations (name) VALUES (:name)", { + name: file, + }); + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + ensureSchema() + .then(() => { + console.info("Database schema is ready."); + return pool.end(); + }) + .catch(async (error) => { + console.error(error); + await pool.end().catch(() => {}); + process.exit(1); + }); +} diff --git a/backend/src/config/schema.sql b/backend/src/config/schema.sql new file mode 100644 index 0000000..cb2c12e --- /dev/null +++ b/backend/src/config/schema.sql @@ -0,0 +1,106 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + full_name VARCHAR(140) NOT NULL, + username VARCHAR(80) NOT NULL, + email VARCHAR(180) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin', 'member') NOT NULL DEFAULT 'member', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY users_username_unique (username), + UNIQUE KEY users_email_unique (email), + INDEX users_role_idx (role) +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + user_agent VARCHAR(255) NULL, + ip_address VARCHAR(64) NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT refresh_tokens_user_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY refresh_tokens_hash_unique (token_hash), + INDEX refresh_tokens_user_idx (user_id), + INDEX refresh_tokens_expiry_idx (expires_at) +); + +CREATE TABLE IF NOT EXISTS audio_assets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + storage_driver VARCHAR(32) NOT NULL, + storage_key VARCHAR(700) NOT NULL, + public_url VARCHAR(900) NULL, + original_name VARCHAR(255) NULL, + mime_type VARCHAR(120) NULL, + file_size BIGINT UNSIGNED NULL, + checksum_sha256 CHAR(64) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY audio_assets_storage_unique (storage_driver, storage_key(255)), + INDEX audio_assets_created_idx (created_at) +); + +CREATE TABLE IF NOT EXISTS transcripts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + sender_id BIGINT UNSIGNED NOT NULL, + receiver_id BIGINT UNSIGNED NULL, + audio_asset_id BIGINT UNSIGNED NULL, + title VARCHAR(220) NULL, + transcript_text LONGTEXT NULL, + language VARCHAR(40) NULL, + timestamps JSON NULL, + status ENUM('queued', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'queued', + failure_reason TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT transcripts_sender_fk FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT transcripts_receiver_fk FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT transcripts_audio_asset_fk FOREIGN KEY (audio_asset_id) REFERENCES audio_assets(id) ON DELETE SET NULL, + INDEX transcripts_sender_idx (sender_id), + INDEX transcripts_receiver_idx (receiver_id), + INDEX transcripts_audio_asset_idx (audio_asset_id), + INDEX transcripts_status_idx (status), + INDEX transcripts_created_idx (created_at), + FULLTEXT KEY transcripts_search_idx (title, transcript_text) +); + +CREATE TABLE IF NOT EXISTS transcript_shares ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + transcript_id BIGINT UNSIGNED NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL, + receiver_id BIGINT UNSIGNED NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT transcript_shares_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE, + CONSTRAINT transcript_shares_sender_fk FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT transcript_shares_receiver_fk FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY transcript_shares_unique (transcript_id, receiver_id), + INDEX transcript_shares_sender_idx (sender_id), + INDEX transcript_shares_receiver_idx (receiver_id) +); + +CREATE TABLE IF NOT EXISTS audio_metadata ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + transcript_id BIGINT UNSIGNED NOT NULL, + file_size BIGINT UNSIGNED NULL, + duration DECIMAL(12, 3) NULL, + processing_time DECIMAL(12, 3) NULL, + model_name VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT audio_metadata_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE, + UNIQUE KEY audio_metadata_transcript_unique (transcript_id) +); + +CREATE TABLE IF NOT EXISTS transcription_jobs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + transcript_id BIGINT UNSIGNED NOT NULL, + status ENUM('queued', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'queued', + attempts INT UNSIGNED NOT NULL DEFAULT 0, + last_error TEXT NULL, + queued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + CONSTRAINT transcription_jobs_transcript_fk FOREIGN KEY (transcript_id) REFERENCES transcripts(id) ON DELETE CASCADE, + INDEX transcription_jobs_status_idx (status), + INDEX transcription_jobs_transcript_idx (transcript_id) +); diff --git a/backend/src/controllers/audioController.js b/backend/src/controllers/audioController.js new file mode 100644 index 0000000..4378c62 --- /dev/null +++ b/backend/src/controllers/audioController.js @@ -0,0 +1,13 @@ +import { createTranscriptionRequest } from "../services/transcriptionService.js"; +import { sendSuccess } from "../utils/apiResponse.js"; + +export async function transcribeAudio(req, res) { + const duration = Number(req.body.duration); + const result = await createTranscriptionRequest({ + userId: req.user.id, + file: req.file, + title: String(req.body.title ?? "").trim() || null, + duration: Number.isFinite(duration) ? duration : null, + }); + sendSuccess(res, "Audio accepted for transcription", result, 202); +} diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js new file mode 100644 index 0000000..8205625 --- /dev/null +++ b/backend/src/controllers/authController.js @@ -0,0 +1,60 @@ +import { + changePassword, + loginUser, + logoutSession, + refreshSession, + registerUser, + requestPasswordReset, + updateProfile, +} from "../services/authService.js"; +import { clearAuthCookies, setAuthCookies } from "../services/tokenService.js"; +import { env } from "../config/env.js"; +import { sendSuccess } from "../utils/apiResponse.js"; + +export async function register(req, res) { + const { user, tokens } = await registerUser(req.body, req); + setAuthCookies(res, tokens); + sendSuccess(res, "Account created", { user }, 201); +} + +export async function login(req, res) { + const { user, tokens } = await loginUser(req.body, req); + setAuthCookies(res, tokens); + sendSuccess(res, "Signed in", { user }); +} + +export async function refresh(req, res) { + const refreshToken = req.cookies?.[env.auth.refreshCookieName]; + const { + user, + accessToken, + refreshToken: nextRefreshToken, + } = await refreshSession(refreshToken, req); + setAuthCookies(res, { accessToken, refreshToken: nextRefreshToken }); + sendSuccess(res, "Session refreshed", { user }); +} + +export async function me(req, res) { + sendSuccess(res, "Authenticated user", { user: req.user }); +} + +export async function logout(req, res) { + await logoutSession(req.cookies?.[env.auth.refreshCookieName]); + clearAuthCookies(res); + sendSuccess(res, "Signed out"); +} + +export async function forgotPassword(req, res) { + await requestPasswordReset(req.body.email); + sendSuccess(res, "If that email exists, reset instructions will be sent"); +} + +export async function updateCurrentUser(req, res) { + const user = await updateProfile(req.user.id, req.body); + sendSuccess(res, "Profile updated", { user }); +} + +export async function changeCurrentPassword(req, res) { + await changePassword(req.user.id, req.body); + sendSuccess(res, "Password changed"); +} diff --git a/backend/src/controllers/transcriptController.js b/backend/src/controllers/transcriptController.js new file mode 100644 index 0000000..c26da5c --- /dev/null +++ b/backend/src/controllers/transcriptController.js @@ -0,0 +1,116 @@ +import { + deleteTranscript, + findTranscriptByIdForSender, + findTranscriptByIdForUser, + listInboxTranscripts, + listSentTranscripts, + listVisibleTranscripts, + sendTranscriptToUser, + updateTranscript, +} from "../repositories/transcriptRepository.js"; +import { findUserById } from "../repositories/userRepository.js"; +import { getTranscriptJob } from "../services/transcriptionService.js"; +import { openAudioStream, removeAudio } from "../services/storage/storageService.js"; +import { AppError } from "../utils/AppError.js"; +import { sendSuccess } from "../utils/apiResponse.js"; +import { safeFilename } from "../utils/safeFilename.js"; + +export async function listTranscripts(req, res) { + const transcripts = await listVisibleTranscripts(req.user.id, req.query); + sendSuccess(res, "Transcripts retrieved", { + transcripts, + stats: buildStats(transcripts, req.user.id), + }); +} + +export async function inbox(req, res) { + const transcripts = await listInboxTranscripts(req.user.id, req.query); + sendSuccess(res, "Inbox retrieved", { transcripts }); +} + +export async function sent(req, res) { + const transcripts = await listSentTranscripts(req.user.id, req.query); + sendSuccess(res, "Sent transcripts retrieved", { transcripts }); +} + +export async function getTranscript(req, res) { + const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id); + if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND"); + const job = await getTranscriptJob(transcript.id); + sendSuccess(res, "Transcript retrieved", { + transcript, + job: job + ? { + id: Number(job.id), + status: job.status, + attempts: Number(job.attempts), + lastError: job.last_error, + } + : null, + }); +} + +export async function updateTranscriptText(req, res) { + const existing = await findTranscriptByIdForSender(req.params.id, req.user.id); + if (!existing) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND"); + if (existing.status !== "completed") { + throw new AppError("Only completed transcripts can be edited", 409, "TRANSCRIPT_NOT_READY"); + } + + const transcript = await updateTranscript(req.params.id, req.user.id, { + title: req.body.title ?? null, + transcriptText: req.body.transcriptText, + }); + sendSuccess(res, "Transcript updated", { transcript }); +} + +export async function removeTranscript(req, res) { + const transcript = await deleteTranscript(req.params.id, req.user.id); + if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND"); + await removeAudio(transcript.audioPath); + sendSuccess(res, "Transcript deleted"); +} + +export async function sendTranscript(req, res) { + const { transcriptId, receiverId } = req.body; + if (receiverId === Number(req.user.id)) { + throw new AppError("Choose another user as the recipient", 400, "INVALID_RECIPIENT"); + } + if (!(await findUserById(receiverId))) { + throw new AppError("Recipient not found", 404, "RECIPIENT_NOT_FOUND"); + } + + const existing = await findTranscriptByIdForSender(transcriptId, req.user.id); + if (!existing) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND"); + const transcript = await sendTranscriptToUser(transcriptId, req.user.id, receiverId); + sendSuccess(res, "Transcript shared", { transcript }); +} + +export async function streamAudio(req, res) { + const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id); + if (!transcript?.audioPath) { + throw new AppError("Audio not found", 404, "AUDIO_NOT_FOUND"); + } + const stream = await openAudioStream(transcript.audioPath); + res.setHeader("Content-Type", transcript.metadata?.mimeType ?? "audio/webm"); + stream.pipe(res); +} + +export async function downloadTranscript(req, res) { + const transcript = await findTranscriptByIdForUser(req.params.id, req.user.id); + if (!transcript) throw new AppError("Transcript not found", 404, "TRANSCRIPT_NOT_FOUND"); + const filename = `${safeFilename(transcript.title || `transcript-${transcript.id}`)}.txt`; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(transcript.transcriptText); +} + +function buildStats(transcripts, userId) { + return { + totalRecordings: transcripts.filter((item) => item.senderId === Number(userId)).length, + totalTranscripts: transcripts.length, + sentFiles: transcripts.filter((item) => item.senderId === Number(userId) && item.receiverId) + .length, + receivedFiles: transcripts.filter((item) => item.receiverId === Number(userId)).length, + }; +} diff --git a/backend/src/controllers/transcriptionController.js b/backend/src/controllers/transcriptionController.js new file mode 100644 index 0000000..ff96e18 --- /dev/null +++ b/backend/src/controllers/transcriptionController.js @@ -0,0 +1,16 @@ +import { checkWhisperHealth, getTranscriptionStatus } from "../services/transcriptionService.js"; +import { transcriptionQueueState } from "../jobs/transcriptionQueue.js"; +import { sendSuccess } from "../utils/apiResponse.js"; + +export async function status(req, res) { + const data = await getTranscriptionStatus(req.params.id, req.user.id); + sendSuccess(res, "Transcription status retrieved", data); +} + +export async function whisperHealth(_req, res) { + const whisper = await checkWhisperHealth(); + sendSuccess(res, "Whisper health retrieved", { + whisper, + queue: transcriptionQueueState(), + }); +} diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js new file mode 100644 index 0000000..99ad46d --- /dev/null +++ b/backend/src/controllers/userController.js @@ -0,0 +1,11 @@ +import { listUsers } from "../repositories/userRepository.js"; +import { sendSuccess } from "../utils/apiResponse.js"; + +export async function searchUsers(req, res) { + const users = await listUsers({ + excludeId: req.user.id, + q: String(req.query.q ?? "").trim(), + limit: req.query.limit, + }); + sendSuccess(res, "Users retrieved", { users }); +} diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js new file mode 100644 index 0000000..438f47c --- /dev/null +++ b/backend/src/docs/openapi.js @@ -0,0 +1,84 @@ +import swaggerJSDoc from "swagger-jsdoc"; +import { env } from "../config/env.js"; + +export const openApiSpec = swaggerJSDoc({ + definition: { + openapi: "3.0.0", + info: { + title: "Orphion API", + version: "1.0.0", + description: "Production API for recording, transcribing, sharing, and playing MoM audio.", + }, + servers: [{ url: env.apiPrefix }], + components: { + securitySchemes: { + cookieAuth: { + type: "apiKey", + in: "cookie", + name: env.auth.accessCookieName, + }, + }, + }, + security: [{ cookieAuth: [] }], + paths: { + "/auth/register": { post: { tags: ["Auth"], summary: "Create an account" } }, + "/auth/login": { post: { tags: ["Auth"], summary: "Sign in" } }, + "/auth/refresh": { post: { tags: ["Auth"], summary: "Refresh session cookies" } }, + "/auth/me": { get: { tags: ["Auth"], summary: "Get current user" } }, + "/auth/logout": { post: { tags: ["Auth"], summary: "Sign out" } }, + "/audio/transcribe": { + post: { + tags: ["Audio"], + summary: "Upload audio and queue transcription", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + audio: { type: "string", format: "binary" }, + title: { type: "string" }, + duration: { type: "number" }, + }, + required: ["audio"], + }, + }, + }, + }, + }, + }, + "/transcriptions/{id}/status": { + get: { + tags: ["Transcription"], + summary: "Get transcription job status", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + }, + }, + "/transcriptions/whisper/health": { + get: { tags: ["Transcription"], summary: "Check Whisper VM health" }, + }, + "/transcripts": { get: { tags: ["Transcripts"], summary: "List visible transcripts" } }, + "/transcripts/inbox": { + get: { tags: ["Transcripts"], summary: "List received transcripts" }, + }, + "/transcripts/sent": { get: { tags: ["Transcripts"], summary: "List sent transcripts" } }, + "/transcripts/send": { post: { tags: ["Transcripts"], summary: "Share transcript" } }, + "/transcripts/{id}": { + get: { + tags: ["Transcripts"], + summary: "Get transcript", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], + }, + patch: { tags: ["Transcripts"], summary: "Update transcript" }, + delete: { tags: ["Transcripts"], summary: "Delete transcript" }, + }, + "/transcripts/{id}/audio": { get: { tags: ["Transcripts"], summary: "Stream audio" } }, + "/transcripts/{id}/download": { + get: { tags: ["Transcripts"], summary: "Download transcript text" }, + }, + "/users": { get: { tags: ["Users"], summary: "Search users" } }, + }, + }, + apis: [], +}); diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..62731af --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,20 @@ +import { createApp } from "./app.js"; +import { env } from "./config/env.js"; +import { ensureSchema } from "./config/migrate.js"; +import { logger } from "./utils/logger.js"; + +async function start() { + if (env.database.autoMigrate) { + await ensureSchema(); + } + + const app = createApp(); + app.listen(env.port, env.host, () => { + logger.info(`Orphion API listening on http://${env.host}:${env.port}`); + }); +} + +start().catch((error) => { + logger.error("Failed to start Orphion API", { error: error.stack ?? error.message }); + process.exit(1); +}); diff --git a/backend/src/jobs/transcriptionQueue.js b/backend/src/jobs/transcriptionQueue.js new file mode 100644 index 0000000..629c8d2 --- /dev/null +++ b/backend/src/jobs/transcriptionQueue.js @@ -0,0 +1,37 @@ +import { env } from "../config/env.js"; +import { logger } from "../utils/logger.js"; + +const queue = []; +const active = new Set(); +let handler = null; + +export function configureTranscriptionQueue(processor) { + handler = processor; +} + +export function enqueueTranscriptionJob(jobId) { + queue.push(Number(jobId)); + processQueue(); +} + +export function transcriptionQueueState() { + return { + queued: queue.length, + active: active.size, + concurrency: env.whisper.queueConcurrency, + }; +} + +function processQueue() { + if (!handler) return; + while (active.size < env.whisper.queueConcurrency && queue.length > 0) { + const jobId = queue.shift(); + active.add(jobId); + Promise.resolve(handler(jobId)) + .catch((error) => logger.error("Transcription job failed", { jobId, error: error.message })) + .finally(() => { + active.delete(jobId); + processQueue(); + }); + } +} diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js new file mode 100644 index 0000000..0b43b22 --- /dev/null +++ b/backend/src/middlewares/authenticate.js @@ -0,0 +1,40 @@ +import { env } from "../config/env.js"; +import { findUserById } from "../repositories/userRepository.js"; +import { verifyAccessToken } from "../services/tokenService.js"; +import { AppError } from "../utils/AppError.js"; + +export async function authenticate(req, _res, next) { + try { + const header = req.headers.authorization; + const bearer = header?.startsWith("Bearer ") ? header.slice(7) : null; + const token = req.cookies?.[env.auth.accessCookieName] ?? req.cookies?.orphion_token ?? bearer; + if (!token) { + throw new AppError("Authentication required", 401, "AUTH_REQUIRED"); + } + + const payload = verifyAccessToken(token); + const user = await findUserById(payload.sub); + if (!user) { + throw new AppError("Invalid session", 401, "INVALID_SESSION"); + } + + req.user = user; + next(); + } catch (error) { + next(error.status ? error : new AppError("Invalid or expired session", 401, "INVALID_SESSION")); + } +} + +export function authorize(...roles) { + return function roleGuard(req, _res, next) { + if (!req.user) { + next(new AppError("Authentication required", 401, "AUTH_REQUIRED")); + return; + } + if (roles.length > 0 && !roles.includes(req.user.role)) { + next(new AppError("Insufficient permissions", 403, "FORBIDDEN")); + return; + } + next(); + }; +} diff --git a/backend/src/middlewares/errorHandler.js b/backend/src/middlewares/errorHandler.js new file mode 100644 index 0000000..a69e187 --- /dev/null +++ b/backend/src/middlewares/errorHandler.js @@ -0,0 +1,27 @@ +import { sendError } from "../utils/apiResponse.js"; +import { AppError } from "../utils/AppError.js"; +import { logger } from "../utils/logger.js"; + +export function notFound(req, _res, next) { + next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404, "NOT_FOUND")); +} + +export function errorHandler(err, req, res, _next) { + let error = err; + + if (err?.code === "LIMIT_FILE_SIZE") { + error = new AppError("Audio file is too large", 413, "AUDIO_TOO_LARGE"); + } + if (err?.name === "ZodError") { + error = new AppError("Validation failed", 400, "VALIDATION_ERROR", err.flatten()); + } + if (!error.status || error.status >= 500) { + logger.error("Unhandled API error", { + method: req.method, + path: req.originalUrl, + error: err?.stack ?? err?.message ?? String(err), + }); + } + + sendError(res, error); +} diff --git a/backend/src/middlewares/requestLogger.js b/backend/src/middlewares/requestLogger.js new file mode 100644 index 0000000..20e9118 --- /dev/null +++ b/backend/src/middlewares/requestLogger.js @@ -0,0 +1,10 @@ +import morgan from "morgan"; +import { logger } from "../utils/logger.js"; + +export const requestLogger = morgan("combined", { + stream: { + write(message) { + logger.info(message.trim()); + }, + }, +}); diff --git a/backend/src/middlewares/security.js b/backend/src/middlewares/security.js new file mode 100644 index 0000000..3f03aa9 --- /dev/null +++ b/backend/src/middlewares/security.js @@ -0,0 +1,43 @@ +import compression from "compression"; +import cors from "cors"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { env } from "../config/env.js"; + +export function securityMiddleware(app) { + if (env.trustProxy) { + app.set("trust proxy", 1); + } + + const origins = env.clientOrigin.split(",").map((origin) => origin.trim()); + app.use( + helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), + ); + app.use(compression()); + app.use( + cors({ + origin(origin, cb) { + if (!origin || origins.includes(origin)) return cb(null, true); + return cb(new Error(`Origin ${origin} is not allowed`)); + }, + credentials: true, + }), + ); + app.use( + rateLimit({ + windowMs: env.rateLimit.windowMs, + limit: env.rateLimit.max, + standardHeaders: "draft-8", + legacyHeaders: false, + }), + ); +} + +export const authRateLimiter = rateLimit({ + windowMs: env.rateLimit.windowMs, + limit: env.rateLimit.authMax, + standardHeaders: "draft-8", + legacyHeaders: false, +}); diff --git a/backend/src/middlewares/upload.js b/backend/src/middlewares/upload.js new file mode 100644 index 0000000..68fc670 --- /dev/null +++ b/backend/src/middlewares/upload.js @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import multer from "multer"; +import { env } from "../config/env.js"; +import { randomStorageName } from "../utils/crypto.js"; +import { AppError } from "../utils/AppError.js"; + +fs.mkdirSync(env.upload.tempDir, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, env.upload.tempDir), + filename: (_req, file, cb) => cb(null, randomStorageName(file.originalname)), +}); + +export const audioUpload = multer({ + storage, + limits: { + fileSize: env.upload.maxAudioSizeMb * 1024 * 1024, + }, + fileFilter: (_req, file, cb) => { + const allowed = + env.upload.allowedMimeTypes.includes(file.mimetype) || file.mimetype.startsWith("audio/"); + if (!allowed) { + cb(new AppError("Unsupported audio type", 400, "UNSUPPORTED_AUDIO_TYPE")); + return; + } + cb(null, true); + }, +}); diff --git a/backend/src/middlewares/validate.js b/backend/src/middlewares/validate.js new file mode 100644 index 0000000..105be53 --- /dev/null +++ b/backend/src/middlewares/validate.js @@ -0,0 +1,23 @@ +function setRequestValue(req, key, value) { + if (value === undefined) return; + Object.defineProperty(req, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + +export function validate(schema) { + return function validationMiddleware(req, _res, next) { + const parsed = schema.parse({ + body: req.body, + query: req.query, + params: req.params, + }); + setRequestValue(req, "body", parsed.body); + setRequestValue(req, "query", parsed.query); + setRequestValue(req, "params", parsed.params); + next(); + }; +} diff --git a/backend/src/models/mappers.js b/backend/src/models/mappers.js new file mode 100644 index 0000000..3f934a0 --- /dev/null +++ b/backend/src/models/mappers.js @@ -0,0 +1,68 @@ +export function toUser(row) { + if (!row) return null; + return { + id: Number(row.id), + fullName: row.full_name, + username: row.username, + email: row.email, + role: row.role ?? "member", + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at, + }; +} + +export function toUserSummary(row, prefix = "") { + if (!row?.[`${prefix}id`]) return null; + return { + id: Number(row[`${prefix}id`]), + fullName: row[`${prefix}full_name`], + username: row[`${prefix}username`], + email: row[`${prefix}email`], + role: row[`${prefix}role`] ?? "member", + }; +} + +export function parseJsonArray(value) { + if (!value) return []; + if (Array.isArray(value)) return value; + try { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function toTranscript(row) { + if (!row) return null; + return { + id: Number(row.id), + senderId: Number(row.sender_id), + receiverId: row.receiver_id ? Number(row.receiver_id) : null, + audioAssetId: row.audio_asset_id ? Number(row.audio_asset_id) : null, + title: row.title, + audioPath: row.storage_key ?? row.audio_path ?? null, + audioUrl: row.public_url ?? null, + transcriptText: row.transcript_text ?? "", + language: row.language, + timestamps: parseJsonArray(row.timestamps), + status: row.status ?? "completed", + transcriptionStatus: row.status ?? "completed", + failureReason: row.failure_reason ?? null, + createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at, + updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at, + sender: toUserSummary(row, "sender_"), + receiver: toUserSummary(row, "receiver_"), + metadata: row.metadata_id + ? { + fileSize: row.file_size === null ? null : Number(row.file_size), + duration: row.duration === null ? null : Number(row.duration), + processingTime: row.processing_time === null ? null : Number(row.processing_time), + modelName: row.model_name, + originalName: row.original_name ?? null, + mimeType: row.mime_type ?? null, + storageDriver: row.storage_driver ?? null, + } + : null, + }; +} diff --git a/backend/src/repositories/tokenRepository.js b/backend/src/repositories/tokenRepository.js new file mode 100644 index 0000000..9bf47cd --- /dev/null +++ b/backend/src/repositories/tokenRepository.js @@ -0,0 +1,46 @@ +import { query } from "../config/database.js"; + +export async function createRefreshToken({ + userId, + tokenHash, + expiresAt, + userAgent = null, + ipAddress = null, +}) { + await query( + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip_address) + VALUES (:userId, :tokenHash, :expiresAt, :userAgent, :ipAddress)`, + { userId, tokenHash, expiresAt, userAgent, ipAddress }, + ); +} + +export async function findActiveRefreshToken(tokenHash) { + const rows = await query( + `SELECT * + FROM refresh_tokens + WHERE token_hash = :tokenHash + AND revoked_at IS NULL + AND expires_at > CURRENT_TIMESTAMP + LIMIT 1`, + { tokenHash }, + ); + return rows[0] ?? null; +} + +export async function revokeRefreshToken(tokenHash) { + await query( + `UPDATE refresh_tokens + SET revoked_at = CURRENT_TIMESTAMP + WHERE token_hash = :tokenHash AND revoked_at IS NULL`, + { tokenHash }, + ); +} + +export async function revokeAllUserRefreshTokens(userId) { + await query( + `UPDATE refresh_tokens + SET revoked_at = CURRENT_TIMESTAMP + WHERE user_id = :userId AND revoked_at IS NULL`, + { userId }, + ); +} diff --git a/backend/src/repositories/transcriptRepository.js b/backend/src/repositories/transcriptRepository.js new file mode 100644 index 0000000..fdcd363 --- /dev/null +++ b/backend/src/repositories/transcriptRepository.js @@ -0,0 +1,310 @@ +import { query, transaction } from "../config/database.js"; +import { toTranscript } from "../models/mappers.js"; + +const transcriptSelect = ` + SELECT + t.*, + aa.storage_driver, + aa.storage_key, + aa.public_url, + aa.original_name, + aa.mime_type, + am.id AS metadata_id, + COALESCE(am.file_size, aa.file_size) AS file_size, + am.duration, + am.processing_time, + am.model_name, + sender.full_name AS sender_full_name, + sender.username AS sender_username, + sender.email AS sender_email, + sender.role AS sender_role, + receiver.full_name AS receiver_full_name, + receiver.username AS receiver_username, + receiver.email AS receiver_email, + receiver.role AS receiver_role + FROM transcripts t + LEFT JOIN audio_assets aa ON aa.id = t.audio_asset_id + LEFT JOIN audio_metadata am ON am.transcript_id = t.id + LEFT JOIN users sender ON sender.id = t.sender_id + LEFT JOIN users receiver ON receiver.id = t.receiver_id +`; + +function appendFilters(base, params, filters, mode) { + const clauses = [ + mode === "inbox" + ? "t.receiver_id = :userId" + : mode === "sent" + ? "t.sender_id = :userId AND t.receiver_id IS NOT NULL" + : "(t.sender_id = :userId OR t.receiver_id = :userId)", + ]; + + if (filters?.q) { + clauses.push("(t.title LIKE :qLike OR t.transcript_text LIKE :qLike)"); + params.qLike = `%${filters.q}%`; + } + if (filters?.sender) { + clauses.push("t.sender_id = :sender"); + params.sender = Number(filters.sender); + } + if (filters?.receiver) { + clauses.push("t.receiver_id = :receiver"); + params.receiver = Number(filters.receiver); + } + if (filters?.date) { + clauses.push("DATE(t.created_at) = :date"); + params.date = filters.date; + } + if (filters?.status) { + clauses.push("t.status = :status"); + params.status = filters.status; + } + + return `${base} WHERE ${clauses.join(" AND ")} ORDER BY t.created_at DESC`; +} + +export async function createAudioAsset(asset) { + const result = await query( + `INSERT INTO audio_assets + (storage_driver, storage_key, public_url, original_name, mime_type, file_size, checksum_sha256) + VALUES + (:storageDriver, :storageKey, :publicUrl, :originalName, :mimeType, :fileSize, :checksumSha256)`, + { + storageDriver: asset.storageDriver, + storageKey: asset.storageKey, + publicUrl: asset.publicUrl ?? null, + originalName: asset.originalName ?? null, + mimeType: asset.mimeType ?? null, + fileSize: asset.fileSize ?? null, + checksumSha256: asset.checksumSha256 ?? null, + }, + ); + return result.insertId; +} + +export async function createQueuedTranscript({ + senderId, + title, + audioAssetId, + duration = null, + fileSize = null, + modelName = null, +}) { + return transaction(async (connection) => { + const [insert] = await connection.execute( + `INSERT INTO transcripts + (sender_id, receiver_id, audio_asset_id, title, transcript_text, language, timestamps, status) + VALUES (:senderId, NULL, :audioAssetId, :title, '', NULL, JSON_ARRAY(), 'queued')`, + { + senderId, + audioAssetId, + title: title || null, + }, + ); + + await connection.execute( + `INSERT INTO audio_metadata (transcript_id, file_size, duration, model_name) + VALUES (:transcriptId, :fileSize, :duration, :modelName)`, + { + transcriptId: insert.insertId, + fileSize, + duration, + modelName, + }, + ); + + const [job] = await connection.execute( + `INSERT INTO transcription_jobs (transcript_id, status) + VALUES (:transcriptId, 'queued')`, + { transcriptId: insert.insertId }, + ); + + return { transcriptId: insert.insertId, jobId: job.insertId }; + }); +} + +export async function listVisibleTranscripts(userId, filters = {}) { + const params = { userId }; + const rows = await query(appendFilters(transcriptSelect, params, filters, "all"), params); + return rows.map(toTranscript); +} + +export async function listInboxTranscripts(userId, filters = {}) { + const params = { userId }; + const rows = await query(appendFilters(transcriptSelect, params, filters, "inbox"), params); + return rows.map(toTranscript); +} + +export async function listSentTranscripts(userId, filters = {}) { + const params = { userId }; + const rows = await query(appendFilters(transcriptSelect, params, filters, "sent"), params); + return rows.map(toTranscript); +} + +export async function findTranscriptByIdForUser(id, userId) { + const rows = await query( + `${transcriptSelect} + WHERE t.id = :id AND (t.sender_id = :userId OR t.receiver_id = :userId) + LIMIT 1`, + { id, userId }, + ); + return toTranscript(rows[0]); +} + +export async function findTranscriptById(id) { + const rows = await query( + `${transcriptSelect} + WHERE t.id = :id + LIMIT 1`, + { id }, + ); + return toTranscript(rows[0]); +} + +export async function findTranscriptByIdForSender(id, senderId) { + const rows = await query( + `${transcriptSelect} + WHERE t.id = :id AND t.sender_id = :senderId + LIMIT 1`, + { id, senderId }, + ); + return toTranscript(rows[0]); +} + +export async function findTranscriptByJobForUser(jobId, userId) { + const rows = await query( + `${transcriptSelect} + INNER JOIN transcription_jobs tj ON tj.transcript_id = t.id + WHERE tj.id = :jobId AND (t.sender_id = :userId OR t.receiver_id = :userId) + LIMIT 1`, + { jobId, userId }, + ); + return toTranscript(rows[0]); +} + +export async function updateTranscript(id, senderId, { title, transcriptText }) { + await query( + `UPDATE transcripts + SET title = :title, transcript_text = :transcriptText + WHERE id = :id AND sender_id = :senderId`, + { id, senderId, title: title || null, transcriptText }, + ); + return findTranscriptBySenderOrId(id, senderId); +} + +async function findTranscriptBySenderOrId(id, senderId) { + return findTranscriptByIdForSender(id, senderId); +} + +export async function completeTranscript(id, result) { + await query( + `UPDATE transcripts + SET transcript_text = :transcriptText, + language = :language, + timestamps = :timestamps, + status = 'completed', + failure_reason = NULL + WHERE id = :id`, + { + id, + transcriptText: result.transcriptText, + language: result.language, + timestamps: JSON.stringify(result.timestamps ?? []), + }, + ); + await query( + `UPDATE audio_metadata + SET duration = COALESCE(:duration, duration), + processing_time = :processingTime, + model_name = :modelName + WHERE transcript_id = :id`, + { + id, + duration: result.duration ?? null, + processingTime: result.processingTime ?? null, + modelName: result.modelName ?? null, + }, + ); +} + +export async function markTranscriptProcessing(id) { + await query( + "UPDATE transcripts SET status = 'processing', failure_reason = NULL WHERE id = :id", + { + id, + }, + ); +} + +export async function failTranscript(id, reason) { + await query( + `UPDATE transcripts + SET status = 'failed', failure_reason = :reason + WHERE id = :id`, + { id, reason: String(reason).slice(0, 2000) }, + ); +} + +export async function sendTranscriptToUser(id, senderId, receiverId) { + return transaction(async (connection) => { + await connection.execute( + `UPDATE transcripts + SET receiver_id = :receiverId + WHERE id = :id AND sender_id = :senderId`, + { id, senderId, receiverId }, + ); + await connection.execute( + `INSERT INTO transcript_shares (transcript_id, sender_id, receiver_id) + VALUES (:id, :senderId, :receiverId) + ON DUPLICATE KEY UPDATE sender_id = VALUES(sender_id)`, + { id, senderId, receiverId }, + ); + return findTranscriptBySenderOrId(id, senderId); + }); +} + +export async function deleteTranscript(id, senderId) { + const transcript = await findTranscriptByIdForSender(id, senderId); + if (!transcript) return null; + await query("DELETE FROM transcripts WHERE id = :id AND sender_id = :senderId", { id, senderId }); + return transcript; +} + +export async function findJobById(id) { + const rows = await query("SELECT * FROM transcription_jobs WHERE id = :id LIMIT 1", { id }); + return rows[0] ?? null; +} + +export async function findJobByTranscriptId(transcriptId) { + const rows = await query( + "SELECT * FROM transcription_jobs WHERE transcript_id = :transcriptId ORDER BY id DESC LIMIT 1", + { transcriptId }, + ); + return rows[0] ?? null; +} + +export async function markJobProcessing(id) { + await query( + `UPDATE transcription_jobs + SET status = 'processing', attempts = attempts + 1, started_at = COALESCE(started_at, CURRENT_TIMESTAMP) + WHERE id = :id`, + { id }, + ); +} + +export async function completeJob(id) { + await query( + `UPDATE transcription_jobs + SET status = 'completed', completed_at = CURRENT_TIMESTAMP + WHERE id = :id`, + { id }, + ); +} + +export async function failJob(id, error) { + await query( + `UPDATE transcription_jobs + SET status = 'failed', last_error = :error, completed_at = CURRENT_TIMESTAMP + WHERE id = :id`, + { id, error: String(error).slice(0, 2000) }, + ); +} diff --git a/backend/src/repositories/userRepository.js b/backend/src/repositories/userRepository.js new file mode 100644 index 0000000..b8d5634 --- /dev/null +++ b/backend/src/repositories/userRepository.js @@ -0,0 +1,69 @@ +import { query } from "../config/database.js"; +import { toUser } from "../models/mappers.js"; + +export async function createUser({ fullName, username, email, passwordHash, role = "member" }) { + const result = await query( + `INSERT INTO users (full_name, username, email, password_hash, role) + VALUES (:fullName, :username, :email, :passwordHash, :role)`, + { fullName, username, email, passwordHash, role }, + ); + return findUserById(result.insertId); +} + +export async function findUserByEmail(email) { + const rows = await query("SELECT * FROM users WHERE email = :email LIMIT 1", { email }); + return rows[0] ?? null; +} + +export async function findUserByUsername(username) { + const rows = await query("SELECT * FROM users WHERE username = :username LIMIT 1", { username }); + return rows[0] ?? null; +} + +export async function findUserById(id) { + const rows = await query( + "SELECT id, full_name, username, email, role, created_at, updated_at FROM users WHERE id = :id LIMIT 1", + { id }, + ); + return toUser(rows[0]); +} + +export async function findUserCredentialsById(id) { + const rows = await query("SELECT * FROM users WHERE id = :id LIMIT 1", { id }); + return rows[0] ?? null; +} + +export async function updateUserProfile(id, { fullName, username }) { + await query( + `UPDATE users + SET full_name = :fullName, username = :username + WHERE id = :id`, + { id, fullName, username }, + ); + return findUserById(id); +} + +export async function updateUserPassword(id, passwordHash) { + await query("UPDATE users SET password_hash = :passwordHash WHERE id = :id", { + id, + passwordHash, + }); +} + +export async function listUsers({ excludeId, q = "", limit = 25 }) { + const rows = await query( + `SELECT id, full_name, username, email, role, created_at, updated_at + FROM users + WHERE id <> :excludeId + AND ( + :q = '' + OR full_name LIKE :likeQ + OR username LIKE :likeQ + OR email LIKE :likeQ + ) + ORDER BY full_name ASC + LIMIT :limit`, + { excludeId, q, likeQ: `%${q}%`, limit: Math.min(Number(limit) || 25, 50) }, + ); + return rows.map(toUser); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 0000000..325ff2a --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,32 @@ +import { Router } from "express"; +import { pingDatabase } from "../config/database.js"; +import { env } from "../config/env.js"; +import { checkWhisperHealth } from "../services/transcriptionService.js"; +import { sendSuccess } from "../utils/apiResponse.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { v1Routes } from "./v1/index.js"; + +export const routes = Router(); + +routes.get( + "/health", + asyncHandler(async (_req, res) => { + const [database, whisper] = await Promise.all([ + pingDatabase() + .then(() => ({ healthy: true })) + .catch((error) => ({ + healthy: false, + error: error.message, + })), + checkWhisperHealth(), + ]); + sendSuccess(res, "Orphion API is healthy", { + service: "orphion-api", + env: env.nodeEnv, + database, + whisper, + }); + }), +); + +routes.use(env.apiPrefix, v1Routes); diff --git a/backend/src/routes/v1/audioRoutes.js b/backend/src/routes/v1/audioRoutes.js new file mode 100644 index 0000000..cc7f64a --- /dev/null +++ b/backend/src/routes/v1/audioRoutes.js @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { transcribeAudio } from "../../controllers/audioController.js"; +import { authenticate } from "../../middlewares/authenticate.js"; +import { audioUpload } from "../../middlewares/upload.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; + +export const audioRoutes = Router(); + +audioRoutes.post( + "/transcribe", + authenticate, + audioUpload.single("audio"), + asyncHandler(transcribeAudio), +); diff --git a/backend/src/routes/v1/authRoutes.js b/backend/src/routes/v1/authRoutes.js new file mode 100644 index 0000000..4da95b2 --- /dev/null +++ b/backend/src/routes/v1/authRoutes.js @@ -0,0 +1,48 @@ +import { Router } from "express"; +import { + changeCurrentPassword, + forgotPassword, + login, + logout, + me, + refresh, + register, + updateCurrentUser, +} from "../../controllers/authController.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; +import { authenticate } from "../../middlewares/authenticate.js"; +import { authRateLimiter } from "../../middlewares/security.js"; +import { validate } from "../../middlewares/validate.js"; +import { + changePasswordSchema, + forgotPasswordSchema, + loginSchema, + registerSchema, + updateProfileSchema, +} from "../../validators/authValidators.js"; + +export const authRoutes = Router(); + +authRoutes.post("/register", authRateLimiter, validate(registerSchema), asyncHandler(register)); +authRoutes.post("/login", authRateLimiter, validate(loginSchema), asyncHandler(login)); +authRoutes.post("/refresh", asyncHandler(refresh)); +authRoutes.post( + "/forgot-password", + authRateLimiter, + validate(forgotPasswordSchema), + asyncHandler(forgotPassword), +); +authRoutes.get("/me", authenticate, asyncHandler(me)); +authRoutes.post("/logout", asyncHandler(logout)); +authRoutes.patch( + "/profile", + authenticate, + validate(updateProfileSchema), + asyncHandler(updateCurrentUser), +); +authRoutes.patch( + "/password", + authenticate, + validate(changePasswordSchema), + asyncHandler(changeCurrentPassword), +); diff --git a/backend/src/routes/v1/index.js b/backend/src/routes/v1/index.js new file mode 100644 index 0000000..d1f50e8 --- /dev/null +++ b/backend/src/routes/v1/index.js @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { authRoutes } from "./authRoutes.js"; +import { audioRoutes } from "./audioRoutes.js"; +import { transcriptRoutes } from "./transcriptRoutes.js"; +import { transcriptionRoutes } from "./transcriptionRoutes.js"; +import { userRoutes } from "./userRoutes.js"; + +export const v1Routes = Router(); + +v1Routes.use("/auth", authRoutes); +v1Routes.use("/audio", audioRoutes); +v1Routes.use("/transcripts", transcriptRoutes); +v1Routes.use("/transcriptions", transcriptionRoutes); +v1Routes.use("/users", userRoutes); diff --git a/backend/src/routes/v1/transcriptRoutes.js b/backend/src/routes/v1/transcriptRoutes.js new file mode 100644 index 0000000..4f9403f --- /dev/null +++ b/backend/src/routes/v1/transcriptRoutes.js @@ -0,0 +1,42 @@ +import { Router } from "express"; +import { + downloadTranscript, + getTranscript, + inbox, + listTranscripts, + removeTranscript, + sendTranscript, + sent, + streamAudio, + updateTranscriptText, +} from "../../controllers/transcriptController.js"; +import { authenticate } from "../../middlewares/authenticate.js"; +import { validate } from "../../middlewares/validate.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; +import { + sendTranscriptSchema, + transcriptIdSchema, + transcriptListSchema, + updateTranscriptSchema, +} from "../../validators/transcriptValidators.js"; + +export const transcriptRoutes = Router(); + +transcriptRoutes.use(authenticate); +transcriptRoutes.get("/", validate(transcriptListSchema), asyncHandler(listTranscripts)); +transcriptRoutes.get("/inbox", validate(transcriptListSchema), asyncHandler(inbox)); +transcriptRoutes.get("/sent", validate(transcriptListSchema), asyncHandler(sent)); +transcriptRoutes.post("/send", validate(sendTranscriptSchema), asyncHandler(sendTranscript)); +transcriptRoutes.get("/:id/audio", validate(transcriptIdSchema), asyncHandler(streamAudio)); +transcriptRoutes.get( + "/:id/download", + validate(transcriptIdSchema), + asyncHandler(downloadTranscript), +); +transcriptRoutes.get("/:id", validate(transcriptIdSchema), asyncHandler(getTranscript)); +transcriptRoutes.patch( + "/:id", + validate(updateTranscriptSchema), + asyncHandler(updateTranscriptText), +); +transcriptRoutes.delete("/:id", validate(transcriptIdSchema), asyncHandler(removeTranscript)); diff --git a/backend/src/routes/v1/transcriptionRoutes.js b/backend/src/routes/v1/transcriptionRoutes.js new file mode 100644 index 0000000..b6003ee --- /dev/null +++ b/backend/src/routes/v1/transcriptionRoutes.js @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { status, whisperHealth } from "../../controllers/transcriptionController.js"; +import { authenticate } from "../../middlewares/authenticate.js"; +import { validate } from "../../middlewares/validate.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; +import { transcriptionJobSchema } from "../../validators/transcriptValidators.js"; + +export const transcriptionRoutes = Router(); + +transcriptionRoutes.get("/whisper/health", authenticate, asyncHandler(whisperHealth)); +transcriptionRoutes.get( + "/:id/status", + authenticate, + validate(transcriptionJobSchema), + asyncHandler(status), +); diff --git a/backend/src/routes/v1/userRoutes.js b/backend/src/routes/v1/userRoutes.js new file mode 100644 index 0000000..6e6cd5c --- /dev/null +++ b/backend/src/routes/v1/userRoutes.js @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { searchUsers } from "../../controllers/userController.js"; +import { authenticate } from "../../middlewares/authenticate.js"; +import { validate } from "../../middlewares/validate.js"; +import { asyncHandler } from "../../utils/asyncHandler.js"; +import { userSearchSchema } from "../../validators/userValidators.js"; + +export const userRoutes = Router(); + +userRoutes.get("/", authenticate, validate(userSearchSchema), asyncHandler(searchUsers)); diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js new file mode 100644 index 0000000..fd0c714 --- /dev/null +++ b/backend/src/services/authService.js @@ -0,0 +1,84 @@ +import bcrypt from "bcrypt"; +import { + createUser, + findUserByEmail, + findUserById, + findUserByUsername, + findUserCredentialsById, + updateUserPassword, + updateUserProfile, +} from "../repositories/userRepository.js"; +import { AppError } from "../utils/AppError.js"; +import { issueTokenPair, rotateRefreshToken, revokeToken } from "./tokenService.js"; +import { toUser } from "../models/mappers.js"; + +export async function registerUser(input, req) { + const normalizedEmail = input.email.toLowerCase().trim(); + const normalizedUsername = input.username.toLowerCase().trim(); + + if (await findUserByEmail(normalizedEmail)) { + throw new AppError("Email is already registered", 409, "EMAIL_TAKEN"); + } + if (await findUserByUsername(normalizedUsername)) { + throw new AppError("Username is already taken", 409, "USERNAME_TAKEN"); + } + + const passwordHash = await bcrypt.hash(input.password, 12); + const user = await createUser({ + fullName: input.fullName.trim(), + username: normalizedUsername, + email: normalizedEmail, + passwordHash, + }); + const tokens = await issueTokenPair(user, req); + return { user, tokens }; +} + +export async function loginUser({ identifier, email, password }, req) { + const normalizedIdentifier = String(identifier ?? email ?? "") + .toLowerCase() + .trim(); + const record = normalizedIdentifier.includes("@") + ? await findUserByEmail(normalizedIdentifier) + : await findUserByUsername(normalizedIdentifier); + if (!record || !(await bcrypt.compare(password, record.password_hash))) { + throw new AppError("Invalid username/email or password", 401, "INVALID_CREDENTIALS"); + } + const user = toUser(record); + const tokens = await issueTokenPair(user, req); + return { user, tokens }; +} + +export async function refreshSession(refreshToken, req) { + return rotateRefreshToken(refreshToken, req); +} + +export async function logoutSession(refreshToken) { + await revokeToken(refreshToken); +} + +export async function updateProfile(userId, input) { + const fullName = input.fullName.trim(); + const username = input.username.toLowerCase().trim(); + const existing = await findUserByUsername(username); + if (existing && Number(existing.id) !== Number(userId)) { + throw new AppError("Username is already taken", 409, "USERNAME_TAKEN"); + } + return updateUserProfile(userId, { fullName, username }); +} + +export async function changePassword(userId, { currentPassword, newPassword }) { + const record = await findUserCredentialsById(userId); + if (!record || !(await bcrypt.compare(currentPassword, record.password_hash))) { + throw new AppError("Current password is incorrect", 401, "INVALID_CURRENT_PASSWORD"); + } + const passwordHash = await bcrypt.hash(newPassword, 12); + await updateUserPassword(userId, passwordHash); +} + +export async function requestPasswordReset(email) { + await findUserByEmail(String(email).toLowerCase().trim()); + return { accepted: true }; +} + +export { findUserById }; diff --git a/backend/src/services/storage/httpAdapter.js b/backend/src/services/storage/httpAdapter.js new file mode 100644 index 0000000..ff7beef --- /dev/null +++ b/backend/src/services/storage/httpAdapter.js @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import { Readable } from "node:stream"; +import { env } from "../../config/env.js"; +import { AppError } from "../../utils/AppError.js"; + +function buildUrl(storageKey) { + if (!env.storage.httpBaseUrl) { + throw new AppError("STORAGE_HTTP_BASE_URL is required for HTTP storage", 500, "STORAGE_CONFIG"); + } + return `${env.storage.httpBaseUrl}/${storageKey}`; +} + +function headers() { + return env.storage.httpToken ? { Authorization: `Bearer ${env.storage.httpToken}` } : {}; +} + +export function createHttpAdapter() { + return { + name: "http", + + async save({ sourcePath, storageKey, mimeType }) { + const response = await fetch(buildUrl(storageKey), { + method: "PUT", + headers: { + ...headers(), + "Content-Type": mimeType || "application/octet-stream", + }, + body: fs.createReadStream(sourcePath), + duplex: "half", + }); + if (!response.ok) { + throw new AppError( + `Remote storage rejected upload (${response.status})`, + 502, + "STORAGE_UPLOAD", + ); + } + return { + storageDriver: this.name, + storageKey, + publicUrl: env.storage.publicBaseUrl + ? `${env.storage.publicBaseUrl}/${storageKey}` + : buildUrl(storageKey), + }; + }, + + async createReadStream(storageKey) { + const response = await fetch(buildUrl(storageKey), { headers: headers() }); + if (!response.ok || !response.body) { + throw new AppError("Audio file not found in remote storage", 404, "AUDIO_NOT_FOUND"); + } + return Readable.fromWeb(response.body); + }, + + async remove(storageKey) { + await fetch(buildUrl(storageKey), { method: "DELETE", headers: headers() }).catch(() => {}); + }, + }; +} diff --git a/backend/src/services/storage/localAdapter.js b/backend/src/services/storage/localAdapter.js new file mode 100644 index 0000000..a62ea71 --- /dev/null +++ b/backend/src/services/storage/localAdapter.js @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import { env } from "../../config/env.js"; + +export function createLocalAdapter(basePath = env.storage.basePath) { + return { + name: "local", + + async save({ sourcePath, storageKey }) { + const destination = path.join(basePath, storageKey); + await fsp.mkdir(path.dirname(destination), { recursive: true }); + await pipeline(fs.createReadStream(sourcePath), fs.createWriteStream(destination)); + return { + storageDriver: this.name, + storageKey, + publicUrl: env.storage.publicBaseUrl + ? `${env.storage.publicBaseUrl}/${encodeURIComponent(storageKey)}` + : null, + }; + }, + + createReadStream(storageKey) { + return fs.createReadStream(path.join(basePath, storageKey)); + }, + + async remove(storageKey) { + await fsp.unlink(path.join(basePath, storageKey)).catch(() => {}); + }, + }; +} diff --git a/backend/src/services/storage/s3Adapter.js b/backend/src/services/storage/s3Adapter.js new file mode 100644 index 0000000..08218b2 --- /dev/null +++ b/backend/src/services/storage/s3Adapter.js @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import { env } from "../../config/env.js"; +import { AppError } from "../../utils/AppError.js"; + +async function client() { + const { S3Client } = await import("@aws-sdk/client-s3"); + if (!env.storage.s3.bucket) { + throw new AppError("S3_BUCKET is required for S3 storage", 500, "STORAGE_CONFIG"); + } + return new S3Client({ + endpoint: env.storage.s3.endpoint || undefined, + region: env.storage.s3.region, + forcePathStyle: env.storage.s3.forcePathStyle, + credentials: + env.storage.s3.accessKeyId && env.storage.s3.secretAccessKey + ? { + accessKeyId: env.storage.s3.accessKeyId, + secretAccessKey: env.storage.s3.secretAccessKey, + } + : undefined, + }); +} + +export function createS3Adapter() { + return { + name: "s3", + + async save({ sourcePath, storageKey, mimeType }) { + const { PutObjectCommand } = await import("@aws-sdk/client-s3"); + const s3 = await client(); + await s3.send( + new PutObjectCommand({ + Bucket: env.storage.s3.bucket, + Key: storageKey, + Body: fs.createReadStream(sourcePath), + ContentType: mimeType, + }), + ); + return { + storageDriver: this.name, + storageKey, + publicUrl: env.storage.publicBaseUrl ? `${env.storage.publicBaseUrl}/${storageKey}` : null, + }; + }, + + async createReadStream(storageKey) { + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); + const s3 = await client(); + const response = await s3.send( + new GetObjectCommand({ Bucket: env.storage.s3.bucket, Key: storageKey }), + ); + if (!response.Body) { + throw new AppError("Audio file not found in S3 storage", 404, "AUDIO_NOT_FOUND"); + } + return response.Body; + }, + + async remove(storageKey) { + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + const s3 = await client(); + await s3 + .send(new DeleteObjectCommand({ Bucket: env.storage.s3.bucket, Key: storageKey })) + .catch(() => {}); + }, + }; +} diff --git a/backend/src/services/storage/smbAdapter.js b/backend/src/services/storage/smbAdapter.js new file mode 100644 index 0000000..2516610 --- /dev/null +++ b/backend/src/services/storage/smbAdapter.js @@ -0,0 +1,8 @@ +import { createLocalAdapter } from "./localAdapter.js"; + +export function createSmbAdapter(basePath) { + return { + ...createLocalAdapter(basePath), + name: "smb", + }; +} diff --git a/backend/src/services/storage/storageService.js b/backend/src/services/storage/storageService.js new file mode 100644 index 0000000..1d09a10 --- /dev/null +++ b/backend/src/services/storage/storageService.js @@ -0,0 +1,68 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { env } from "../../config/env.js"; +import { AppError } from "../../utils/AppError.js"; +import { randomStorageName } from "../../utils/crypto.js"; +import { createHttpAdapter } from "./httpAdapter.js"; +import { createLocalAdapter } from "./localAdapter.js"; +import { createS3Adapter } from "./s3Adapter.js"; +import { createSmbAdapter } from "./smbAdapter.js"; + +function adapter() { + switch (env.storage.driver) { + case "local": + return createLocalAdapter(env.storage.basePath); + case "smb": + case "nfs": + return createSmbAdapter(env.storage.basePath); + case "http": + return createHttpAdapter(); + case "s3": + return createS3Adapter(); + default: + throw new AppError( + `Unsupported storage driver: ${env.storage.driver}`, + 500, + "STORAGE_CONFIG", + ); + } +} + +async function checksum(filePath) { + const file = await fs.readFile(filePath); + return createHash("sha256").update(file).digest("hex"); +} + +export async function saveAudio(file, userId) { + const today = new Date().toISOString().slice(0, 10); + const storageKey = path.posix.join(String(userId), today, randomStorageName(file.originalname)); + const activeAdapter = adapter(); + const stored = await activeAdapter.save({ + sourcePath: file.path, + storageKey, + mimeType: file.mimetype, + }); + return { + ...stored, + originalName: file.originalname, + mimeType: file.mimetype, + fileSize: file.size, + checksumSha256: await checksum(file.path), + }; +} + +export async function openAudioStream(storageKey) { + return adapter().createReadStream(storageKey); +} + +export async function removeAudio(storageKey) { + if (!storageKey) return; + await adapter().remove(storageKey); +} + +export async function cleanupTempFile(file) { + if (file?.path) { + await fs.unlink(file.path).catch(() => {}); + } +} diff --git a/backend/src/services/tokenService.js b/backend/src/services/tokenService.js new file mode 100644 index 0000000..6d85a3e --- /dev/null +++ b/backend/src/services/tokenService.js @@ -0,0 +1,86 @@ +import jwt from "jsonwebtoken"; +import { env } from "../config/env.js"; +import { + createRefreshToken, + findActiveRefreshToken, + revokeRefreshToken, +} from "../repositories/tokenRepository.js"; +import { findUserById } from "../repositories/userRepository.js"; +import { AppError } from "../utils/AppError.js"; +import { randomToken, sha256 } from "../utils/crypto.js"; + +function cookieOptions(maxAge) { + return { + httpOnly: true, + sameSite: env.auth.cookieSameSite, + secure: env.auth.cookieSecure, + maxAge, + path: "/", + }; +} + +export function signAccessToken(user) { + return jwt.sign( + { sub: user.id, email: user.email, role: user.role }, + env.auth.accessTokenSecret, + { + expiresIn: env.auth.accessTokenTtl, + }, + ); +} + +export function verifyAccessToken(token) { + return jwt.verify(token, env.auth.accessTokenSecret); +} + +export async function issueTokenPair(user, req) { + const accessToken = signAccessToken(user); + const refreshToken = randomToken(64); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + await createRefreshToken({ + userId: user.id, + tokenHash: sha256(refreshToken), + expiresAt, + userAgent: req.get("user-agent")?.slice(0, 255) ?? null, + ipAddress: req.ip ?? null, + }); + + return { accessToken, refreshToken }; +} + +export async function rotateRefreshToken(refreshToken, req) { + if (!refreshToken) { + throw new AppError("Refresh token is required", 401, "REFRESH_TOKEN_REQUIRED"); + } + const tokenHash = sha256(refreshToken); + const record = await findActiveRefreshToken(tokenHash); + if (!record) { + throw new AppError("Invalid or expired refresh token", 401, "INVALID_REFRESH_TOKEN"); + } + + await revokeRefreshToken(tokenHash); + const user = await findUserById(record.user_id); + if (!user) { + throw new AppError("Invalid refresh token", 401, "INVALID_REFRESH_TOKEN"); + } + + const tokens = await issueTokenPair(user, req); + return { user, ...tokens }; +} + +export async function revokeToken(refreshToken) { + if (refreshToken) { + await revokeRefreshToken(sha256(refreshToken)); + } +} + +export function setAuthCookies(res, { accessToken, refreshToken }) { + res.cookie(env.auth.accessCookieName, accessToken, cookieOptions(15 * 60 * 1000)); + res.cookie(env.auth.refreshCookieName, refreshToken, cookieOptions(30 * 24 * 60 * 60 * 1000)); +} + +export function clearAuthCookies(res) { + res.clearCookie(env.auth.accessCookieName, cookieOptions(0)); + res.clearCookie(env.auth.refreshCookieName, cookieOptions(0)); +} diff --git a/backend/src/services/transcriptionService.js b/backend/src/services/transcriptionService.js new file mode 100644 index 0000000..01a2aa4 --- /dev/null +++ b/backend/src/services/transcriptionService.js @@ -0,0 +1,122 @@ +import { env } from "../config/env.js"; +import { + enqueueTranscriptionJob, + configureTranscriptionQueue, +} from "../jobs/transcriptionQueue.js"; +import { + completeJob, + completeTranscript, + createAudioAsset, + createQueuedTranscript, + failJob, + failTranscript, + findJobById, + findJobByTranscriptId, + findTranscriptById, + findTranscriptByIdForUser, + findTranscriptByJobForUser, + markJobProcessing, + markTranscriptProcessing, +} from "../repositories/transcriptRepository.js"; +import { cleanupTempFile, openAudioStream, saveAudio } from "./storage/storageService.js"; +import { transcribeAudioStream, checkWhisperHealth } from "../transcription/whisperClient.js"; +import { AppError } from "../utils/AppError.js"; + +export async function createTranscriptionRequest({ userId, file, title, duration }) { + if (!file) { + throw new AppError("Audio file is required", 400, "AUDIO_REQUIRED"); + } + + let savedAsset = null; + try { + savedAsset = await saveAudio(file, userId); + const audioAssetId = await createAudioAsset(savedAsset); + const { transcriptId, jobId } = await createQueuedTranscript({ + senderId: userId, + title, + audioAssetId, + duration, + fileSize: file.size, + modelName: env.whisper.modelName, + }); + enqueueTranscriptionJob(jobId); + return { + job: { id: Number(jobId), status: "queued" }, + transcript: await findTranscriptByIdForUser(transcriptId, userId), + }; + } catch (error) { + if (savedAsset?.storageKey) { + await cleanupFailedRemoteAudio(savedAsset.storageKey); + } + throw error; + } finally { + await cleanupTempFile(file); + } +} + +async function cleanupFailedRemoteAudio(storageKey) { + const { removeAudio } = await import("./storage/storageService.js"); + await removeAudio(storageKey).catch(() => {}); +} + +export async function processTranscriptionJob(jobId) { + const job = await findJobById(jobId); + if (!job || !["queued", "failed"].includes(job.status)) return; + + await markJobProcessing(jobId); + await markTranscriptProcessing(job.transcript_id); + + try { + const transcript = await findTranscriptById(job.transcript_id); + if (!transcript?.audioPath) { + throw new AppError("Transcript audio asset is missing", 500, "AUDIO_MISSING"); + } + + const result = await transcribeAudioStream({ + streamFactory: () => openAudioStream(transcript.audioPath), + filename: transcript.metadata?.originalName ?? `transcript-${transcript.id}.webm`, + mimeType: transcript.metadata?.mimeType ?? "audio/webm", + }); + + await completeTranscript(transcript.id, result); + await completeJob(jobId); + } catch (error) { + await failTranscript(job.transcript_id, error.message); + await failJob(jobId, error.message); + throw error; + } +} + +configureTranscriptionQueue(processTranscriptionJob); + +export async function getTranscriptionStatus(jobId, userId) { + const job = await findJobById(jobId); + if (!job) { + throw new AppError("Transcription job not found", 404, "JOB_NOT_FOUND"); + } + + const transcript = await findTranscriptByJobForUser(jobId, userId); + if (!transcript) { + throw new AppError("Transcription job not found", 404, "JOB_NOT_FOUND"); + } + + return { + job: { + id: Number(job.id), + transcriptId: Number(job.transcript_id), + status: job.status, + attempts: Number(job.attempts), + lastError: job.last_error, + queuedAt: job.queued_at, + startedAt: job.started_at, + completedAt: job.completed_at, + }, + transcript, + }; +} + +export async function getTranscriptJob(transcriptId) { + return findJobByTranscriptId(transcriptId); +} + +export { checkWhisperHealth }; diff --git a/backend/src/transcription/whisperClient.js b/backend/src/transcription/whisperClient.js new file mode 100644 index 0000000..71777cf --- /dev/null +++ b/backend/src/transcription/whisperClient.js @@ -0,0 +1,121 @@ +import axios from "axios"; +import FormData from "form-data"; +import { env } from "../config/env.js"; +import { AppError } from "../utils/AppError.js"; + +function endpoint(path) { + return `${env.whisper.apiUrl}${path.startsWith("/") ? path : `/${path}`}`; +} + +function delay(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function checkWhisperHealth() { + if (env.whisper.allowMock) { + return { healthy: true, mode: "mock", url: env.whisper.apiUrl }; + } + + try { + const response = await axios.get(endpoint(env.whisper.healthPath), { + timeout: Math.min(env.whisper.timeoutMs, 5000), + validateStatus: (status) => status >= 200 && status < 500, + }); + return { + healthy: response.status >= 200 && response.status < 300, + status: response.status, + url: env.whisper.apiUrl, + data: response.data ?? null, + }; + } catch (error) { + return { + healthy: false, + url: env.whisper.apiUrl, + error: error.message, + }; + } +} + +export async function transcribeAudioStream({ stream, streamFactory, filename, mimeType }) { + if (env.whisper.allowMock) { + return mockTranscript(filename); + } + + let lastError; + for (let attempt = 0; attempt <= env.whisper.retries; attempt += 1) { + try { + const currentStream = streamFactory ? await streamFactory() : stream; + return await postToWhisper({ stream: currentStream, filename, mimeType }); + } catch (error) { + lastError = error; + if (attempt >= env.whisper.retries) break; + await delay(env.whisper.retryDelayMs * (attempt + 1)); + } + } + + throw new AppError( + `Whisper VM unavailable: ${lastError?.message ?? "request failed"}`, + 503, + "WHISPER_UNAVAILABLE", + ); +} + +async function postToWhisper({ stream, filename, mimeType }) { + const start = Date.now(); + const form = new FormData(); + form.append(env.whisper.fileField, stream, { + filename, + contentType: mimeType, + }); + + const response = await axios.post(endpoint(env.whisper.transcribePath), form, { + headers: form.getHeaders(), + maxBodyLength: Infinity, + maxContentLength: Infinity, + timeout: env.whisper.timeoutMs, + validateStatus: (status) => status >= 200 && status < 300, + }); + + return normalizeTranscript(response.data, Date.now() - start); +} + +function normalizeTranscript(payload, elapsedMs) { + const transcriptText = + payload.transcript_text ?? + payload.transcript ?? + payload.text ?? + payload.result?.transcript_text ?? + payload.result?.text ?? + ""; + + if (!transcriptText) { + throw new AppError("Whisper VM returned an empty transcript", 502, "EMPTY_TRANSCRIPT"); + } + + return { + transcriptText, + language: payload.language ?? payload.result?.language ?? null, + timestamps: payload.timestamps ?? payload.segments ?? payload.result?.timestamps ?? [], + duration: numberOrNull(payload.duration ?? payload.result?.duration), + processingTime: Number((elapsedMs / 1000).toFixed(3)), + modelName: env.whisper.modelName, + }; +} + +function mockTranscript(filename) { + return { + transcriptText: `Mock transcript for ${filename}. Configure WHISPER_API_URL to use Faster-Whisper Large v3.`, + language: "en", + timestamps: [{ start: 0, end: 4, text: "Mock transcript generated for local development." }], + duration: null, + processingTime: 0.15, + modelName: env.whisper.modelName, + }; +} + +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} diff --git a/backend/src/utils/AppError.js b/backend/src/utils/AppError.js new file mode 100644 index 0000000..431ffef --- /dev/null +++ b/backend/src/utils/AppError.js @@ -0,0 +1,16 @@ +export class AppError extends Error { + constructor(message, status = 500, code = "INTERNAL_ERROR", details = null) { + super(message); + this.name = "AppError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +export function assertFound(value, message = "Resource not found") { + if (!value) { + throw new AppError(message, 404, "NOT_FOUND"); + } + return value; +} diff --git a/backend/src/utils/apiResponse.js b/backend/src/utils/apiResponse.js new file mode 100644 index 0000000..3196f0f --- /dev/null +++ b/backend/src/utils/apiResponse.js @@ -0,0 +1,19 @@ +export function sendSuccess(res, message, data = {}, status = 200) { + return res.status(status).json({ + success: true, + message, + data, + }); +} + +export function sendError(res, error) { + const status = error?.status ?? 500; + return res.status(status).json({ + success: false, + message: error?.message ?? "Internal server error", + error: { + code: error?.code ?? "INTERNAL_ERROR", + details: error?.details ?? null, + }, + }); +} diff --git a/backend/src/utils/asyncHandler.js b/backend/src/utils/asyncHandler.js new file mode 100644 index 0000000..d8bc653 --- /dev/null +++ b/backend/src/utils/asyncHandler.js @@ -0,0 +1,5 @@ +export function asyncHandler(handler) { + return function wrappedHandler(req, res, next) { + Promise.resolve(handler(req, res, next)).catch(next); + }; +} diff --git a/backend/src/utils/crypto.js b/backend/src/utils/crypto.js new file mode 100644 index 0000000..cf9f851 --- /dev/null +++ b/backend/src/utils/crypto.js @@ -0,0 +1,19 @@ +import crypto from "node:crypto"; + +export function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export function randomToken(bytes = 48) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +export function randomStorageName(originalName = "audio.webm") { + const safeExt = originalName.includes(".") + ? originalName + .slice(originalName.lastIndexOf(".")) + .replace(/[^a-z0-9.]/gi, "") + .toLowerCase() + : ".webm"; + return `${Date.now()}-${crypto.randomUUID()}${safeExt || ".webm"}`; +} diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js new file mode 100644 index 0000000..4b4281a --- /dev/null +++ b/backend/src/utils/logger.js @@ -0,0 +1,18 @@ +const levels = ["debug", "info", "warn", "error"]; + +function write(level, message, meta) { + const payload = { + level, + time: new Date().toISOString(), + message, + ...(meta ? { meta } : {}), + }; + const output = JSON.stringify(payload); + if (level === "error") console.error(output); + else if (level === "warn") console.warn(output); + else console.info(output); +} + +export const logger = Object.fromEntries( + levels.map((level) => [level, (message, meta) => write(level, message, meta)]), +); diff --git a/backend/src/utils/safeFilename.js b/backend/src/utils/safeFilename.js new file mode 100644 index 0000000..e8f2d1e --- /dev/null +++ b/backend/src/utils/safeFilename.js @@ -0,0 +1,10 @@ +import path from "node:path"; + +export function safeFilename(value, fallback = "transcript") { + return ( + path + .basename(String(value ?? "")) + .replace(/[^a-z0-9._-]+/gi, "-") + .replace(/^-+|-+$/g, "") || fallback + ); +} diff --git a/backend/src/validators/authValidators.js b/backend/src/validators/authValidators.js new file mode 100644 index 0000000..c59d68e --- /dev/null +++ b/backend/src/validators/authValidators.js @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const registerSchema = z.object({ + body: z.object({ + fullName: z.string().trim().min(2).max(140), + username: z + .string() + .trim() + .min(3) + .max(40) + .regex(/^[a-z0-9_]+$/i, "Use letters, numbers, or underscores"), + email: z.string().trim().email().max(180), + password: z.string().min(8).max(128), + }), +}); + +export const loginSchema = z.object({ + body: z + .object({ + identifier: z.string().trim().min(1).optional(), + email: z.string().trim().min(1).optional(), + password: z.string().min(1), + }) + .refine((input) => input.identifier || input.email, { + path: ["identifier"], + message: "Email or username is required", + }) + .transform((input) => ({ + identifier: input.identifier ?? input.email, + password: input.password, + })), +}); + +export const forgotPasswordSchema = z.object({ + body: z.object({ + email: z.string().trim().email(), + }), +}); + +export const updateProfileSchema = z.object({ + body: z.object({ + fullName: z.string().trim().min(2).max(140), + username: z + .string() + .trim() + .min(3) + .max(40) + .regex(/^[a-z0-9_]+$/i, "Use letters, numbers, or underscores"), + }), +}); + +export const changePasswordSchema = z.object({ + body: z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8).max(128), + }), +}); diff --git a/backend/src/validators/transcriptValidators.js b/backend/src/validators/transcriptValidators.js new file mode 100644 index 0000000..3fdc36f --- /dev/null +++ b/backend/src/validators/transcriptValidators.js @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const transcriptListSchema = z.object({ + query: z.object({ + q: z.string().optional(), + sender: z.string().optional(), + receiver: z.string().optional(), + date: z.string().optional(), + status: z.enum(["queued", "processing", "completed", "failed"]).optional(), + }), +}); + +export const updateTranscriptSchema = z.object({ + params: z.object({ + id: z.string().regex(/^\d+$/), + }), + body: z.object({ + title: z.string().trim().max(220).optional(), + transcriptText: z.string().trim().min(1), + }), +}); + +export const transcriptIdSchema = z.object({ + params: z.object({ + id: z.string().regex(/^\d+$/), + }), +}); + +export const sendTranscriptSchema = z.object({ + body: z.object({ + transcriptId: z.coerce.number().int().positive(), + receiverId: z.coerce.number().int().positive(), + }), +}); + +export const transcriptionJobSchema = z.object({ + params: z.object({ + id: z.string().regex(/^\d+$/), + }), +}); diff --git a/backend/src/validators/userValidators.js b/backend/src/validators/userValidators.js new file mode 100644 index 0000000..1859bfb --- /dev/null +++ b/backend/src/validators/userValidators.js @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const userSearchSchema = z.object({ + query: z.object({ + q: z.string().optional().default(""), + limit: z.coerce.number().int().min(1).max(50).optional().default(25), + }), +}); diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..a02754e --- /dev/null +++ b/docs/API.md @@ -0,0 +1,46 @@ +# API Guide + +Base path: `/api/v1` + +All JSON responses follow: + +```json +{ + "success": true, + "message": "Transcript created", + "data": {} +} +``` + +## Auth + +- `POST /auth/register` +- `POST /auth/login` +- `POST /auth/refresh` +- `GET /auth/me` +- `POST /auth/logout` +- `PATCH /auth/profile` +- `PATCH /auth/password` +- `POST /auth/forgot-password` + +## Audio And Transcription + +- `POST /audio/transcribe` accepts multipart field `audio` +- `GET /transcriptions/:id/status` returns queued, processing, completed, or failed +- `GET /transcriptions/whisper/health` checks Whisper VM and queue state + +## Transcripts + +- `GET /transcripts` +- `GET /transcripts/inbox` +- `GET /transcripts/sent` +- `GET /transcripts/:id` +- `PATCH /transcripts/:id` +- `DELETE /transcripts/:id` +- `POST /transcripts/send` +- `GET /transcripts/:id/audio` +- `GET /transcripts/:id/download` + +## Users + +- `GET /users?q=kevin` diff --git a/docs/REMOTE_DB.md b/docs/REMOTE_DB.md new file mode 100644 index 0000000..191de9a --- /dev/null +++ b/docs/REMOTE_DB.md @@ -0,0 +1,32 @@ +# Remote MySQL Setup + +Create a database user that can connect from the API host: + +```sql +CREATE DATABASE orphion CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'orphion'@'%' IDENTIFIED BY 'NexaVault2026!Blue'; +GRANT ALL PRIVILEGES ON orphion.* TO 'orphion'@'%'; +FLUSH PRIVILEGES; +``` + +Configure: + +```env +DB_HOST=172.16.10.64 +DB_PORT=3306 +DB_NAME=orphion +DB_USER=orphion +DB_PASSWORD=NexaVault2026!Blue +``` + +For the VM setup, run the database/user bootstrap from this project: + +```sh +sh scripts/bootstrap-vm-mysql.sh @172.16.10.64 +``` + +That command runs the VM MySQL grant setup and then applies the project schema through: + +```sh +npm run migrate +``` diff --git a/docs/STORAGE.md b/docs/STORAGE.md new file mode 100644 index 0000000..49c93dc --- /dev/null +++ b/docs/STORAGE.md @@ -0,0 +1,46 @@ +# Storage Configuration + +Audio is not kept in the API project. Uploads land in a temporary folder and are copied or streamed +to the configured storage adapter before the temp file is removed. + +## Local Development + +The sample env stores audio in the repository-level ignored `storage/audio` directory: + +```env +STORAGE_DRIVER=local +STORAGE_BASE_PATH=../storage/audio +``` + +## SMB/NFS Mounted Storage + +Mount your storage on the API machine and configure: + +```env +STORAGE_DRIVER=smb +STORAGE_BASE_PATH=/mnt/orphion-audio +``` + +Use `STORAGE_DRIVER=nfs` for the same mounted-filesystem adapter. + +## HTTP Storage Server + +The HTTP adapter uses `PUT`, `GET`, and `DELETE` against a file server: + +```env +STORAGE_DRIVER=http +STORAGE_HTTP_BASE_URL=http://192.168.X.X:9000/audio +STORAGE_HTTP_TOKEN=optional-bearer-token +``` + +## S3-Compatible Storage + +```env +STORAGE_DRIVER=s3 +S3_ENDPOINT=http://192.168.X.X:9000 +S3_REGION=us-east-1 +S3_BUCKET=orphion-audio +S3_ACCESS_KEY_ID=... +S3_SECRET_ACCESS_KEY=... +S3_FORCE_PATH_STYLE=true +``` diff --git a/docs/WHISPER_VM.md b/docs/WHISPER_VM.md new file mode 100644 index 0000000..99df6fe --- /dev/null +++ b/docs/WHISPER_VM.md @@ -0,0 +1,32 @@ +# Faster-Whisper VM Setup + +The sample env expects the Whisper service to run on the VM at `172.16.10.64:8000`. + +The backend expects a Whisper-compatible HTTP service: + +```env +WHISPER_VM_IP=172.16.10.64 +WHISPER_VM_PORT=8000 +WHISPER_API_URL=http://172.16.10.64:8000 +WHISPER_TRANSCRIBE_PATH=/transcribe +WHISPER_HEALTH_PATH=/health +WHISPER_FILE_FIELD=file +WHISPER_ALLOW_MOCK=false +``` + +Expected endpoints: + +- `GET /health` returns any 2xx status when the VM is ready. +- `POST /transcribe` accepts multipart audio and returns one of: + +```json +{ + "transcript_text": "Meeting transcript...", + "language": "en", + "duration": 123.45, + "timestamps": [{ "start": 0, "end": 5, "text": "Hello" }] +} +``` + +The API retries failed requests, applies `WHISPER_TIMEOUT_MS`, and marks jobs as failed when the VM +is unavailable. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..05382e9 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + apps: [ + { + name: "orphion-api", + cwd: "./backend", + script: "src/index.js", + instances: 1, + exec_mode: "fork", + env: { + NODE_ENV: "production", + }, + }, + ], +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9715720 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,49 @@ +import js from "@eslint/js"; +import eslintPluginPrettier from "eslint-plugin-prettier/recommended"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "node_modules", + "frontend/dist", + "backend/.tmp", + "storage", + "frontend/src/routeTree.gen.ts", + "package-lock.json", + ], + }, + { + extends: [js.configs.recommended], + files: ["backend/src/**/*.js", "scripts/**/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.node, + }, + rules: { + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + }, + }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["frontend/src/**/*.{ts,tsx}", "frontend/vite.config.ts"], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": "off", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + }, + }, + eslintPluginPrettier, +); diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..29bb7bd --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +VITE_API_BASE_URL= +VITE_API_PREFIX=/api/v1 +VITE_API_PORT=4000 +VITE_ORPHION_SERVICE_HOST=127.0.0.1 diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f0817a8 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "css": "src/styles.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ade8fe9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + Orphion — Meeting Intelligence Portal + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..111a7bb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,69 @@ +{ + "name": "@orphion/frontend", + "private": true, + "type": "module", + "sideEffects": false, + "scripts": { + "dev": "vite dev --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0", + "lint": "eslint ." + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-router": "^1.168.25", + "@tanstack/router-plugin": "^1.167.28", + "@vitejs/plugin-react": "^5.0.4", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.38.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.575.0", + "react": "^19.2.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.6.5", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.3.4", + "vaul": "^1.1.2", + "vite": "^7.3.1", + "vite-tsconfig-paths": "^6.0.2", + "zod": "^4.4.3" + } +} diff --git a/frontend/src/components/ui/accordion.tsx b/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000..16ee900 --- /dev/null +++ b/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..072a665 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..cd0a062 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/components/ui/aspect-ratio.tsx b/frontend/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c9e6f4b --- /dev/null +++ b/frontend/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..7904926 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..3aabd17 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..94eb629 --- /dev/null +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,101 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>