Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Reusable monorepo baseline — not a product-specific app. Keep the starter gene

### Shared packages

- `@repo/consts` — string constants: `API_BASE_URL`, endpoint paths (`/api`, `/api/auth/*` including register/login/refresh/me/logout/verify/resend/forgot/reset, `/api/health`, `/api/notes`, `/api/notes/:id`, `/api/movies`, `/api/movies/resolve`, `/api/movies/:id`, `/api/movies/:id/upload`, `/api/movies/:id/stream`), starter copy. Leaf package, no internal deps.
- `@repo/schemas` — Zod 4 schemas and inferred types. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/rooms`, `/root`, `/errors` (RFC 7807 `problemDetailsSchema`). Notes exports include `noteIdParamsSchema` for UUID path params. Movies include upload/stream response shapes and relaxed create schema (`name` + `language` required).
- `@repo/contracts` — typed `EndpointContract<TResponse, TBody, TParams, TQuery>` objects. Each contract attaches `responseSchema` plus any of `bodySchema`/`paramsSchema`/`querySchema` as real Zod schemas from `@repo/schemas`. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/root`. Depends on `@repo/consts`, `@repo/schemas`, `zod`.
- `@repo/consts` — string constants: `API_BASE_URL`, endpoint paths (`/api`, `/api/auth/*` including register/login/refresh/me/logout/verify/resend/forgot/reset, `/api/health`, `/api/notes`, `/api/notes/:id`, `/api/movies`, `/api/movies/resolve`, `/api/movies/:id`, `/api/movies/:id/upload`, `/api/movies/:id/stream`), starter copy, and **`/realtime`** Socket.IO event names (`REALTIME_CLIENT_EVENTS` / `REALTIME_SERVER_EVENTS`) shared by the gateway and frontend client. Leaf package, no internal deps.
- `@repo/schemas` — Zod 4 schemas and inferred types. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/rooms`, `/realtime`, `/root`, `/errors` (RFC 7807 `problemDetailsSchema`). Notes exports include `noteIdParamsSchema` for UUID path params. Movies include upload/stream response shapes and relaxed create schema (`name` + `language` required). Realtime exports room-state, chat, playback, join/leave payload schemas (`connectedUserSchema` keyed by `userId` with a `socketIds` array; `roomStatusSchema` re-exported from `/rooms`).
- `@repo/contracts` — typed `EndpointContract<TResponse, TBody, TParams, TQuery>` objects. Each contract attaches `responseSchema` plus any of `bodySchema`/`paramsSchema`/`querySchema` as real Zod schemas from `@repo/schemas`. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/root`, and **`/realtime`** (`SocketEventContract<TPayload>` tying each event name to its payload schema + direction). Depends on `@repo/consts`, `@repo/schemas`, `zod`.
- `@repo/ui` — React components (button, card, code). Not currently consumed by any app.
- `@repo/eslint-config` — ESLint 9 flat configs: `base`, `node`, `react-internal`, `next-js`.
- `@repo/typescript-config` — TS configs: `base.json`, `node.json`, `nextjs.json`, `vite.json`, `react-library.json`.
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ NestJS 11 API starter. Should stay framework-clean and easy to extend into a rea
- `src/movies/` — movies domain (Mongo metadata + object storage via `src/storage/`); **`JwtAuthGuard`** on all routes. `POST /api/movies/resolve` idempotently returns an owned movie by name (200) or creates one (201). `POST /api/movies/:id/upload` (multipart MP4), `GET /api/movies/:id/stream` (presigned URL). Unique index on `{ ownerId, name }` where `deleted_at: null`; **`MovieIndexSyncService`** drops legacy global `name_1` on startup. Files purge from storage ~24h after linked room deactivates; metadata retained. **`MovieCleanupService`** cron hourly.
- `src/storage/` — **Cloudflare R2** via S3-compatible API (`S3StorageService`) or in-memory (`InMemoryStorageService`). R2 buckets: `uniwatch-dev` (development) and `uniwatch-production` (production), configured in gitignored `.env.development` / `.env.production` (`S3_*` vars). Defaults to in-memory in development/test unless `STORAGE_DRIVER=s3`; production uses S3/R2 automatically. Override with `STORAGE_DRIVER=memory|s3`. **Troubleshooting `EPROTO` / SSL alert 40:** `S3_ENDPOINT` must be `https://<ACCOUNT_ID>.r2.cloudflarestorage.com` where `<ACCOUNT_ID>` is from **R2 → Overview** (not the API token access key). Verify with `openssl s_client -connect <ACCOUNT_ID>.r2.cloudflarestorage.com:443 -servername <ACCOUNT_ID>.r2.cloudflarestorage.com` — handshake must succeed. EU: use `.eu.r2.cloudflarestorage.com`. Set `S3_FORCE_PATH_STYLE=true`.
- `src/rooms/` — rooms domain (Mongo + contracts); **`JwtAuthGuard`** on the controller. **`POST /api/rooms`** sets **`creator` from the JWT** (never from the client body). **List/get** return rooms where the user is **`creator` or in `allowed_users`**; **delete** is **creator-only** (others get **403**). Creating a room requires a **`movie` id owned by the same user** (validated via `MoviesService`).
- `src/realtime/` — `RealtimeModule` + `RealtimeGateway`: Socket.IO demo handler (`ping` → `pong` event).
- `src/realtime/` — `RealtimeModule` + `RealtimeGateway`: authenticated Socket.IO room lifecycle, chat, movie updates, playback sync, and per-user multi-socket tracking. The gateway is a **thin transport layer** — it resolves the actor via **`WsAuthGuard`** (`getAuthenticatedUser(socket)` reads `socket.data.user`), validates payloads via **`ZodWsValidationPipe`** (schemas from `@repo/schemas/realtime`), maps thrown `WsException`s to `room:error` via **`WsExceptionFilter`**, and delegates to focused services:
- `services/connection-registry.service.ts` — bidirectional index: `socketId → user` and `userId → Set<socketId>` for multi-tab/multi-device. Methods `register` / `unregister` / `getUser` / `getSocketIds`.
- `services/room-state.service.ts` — in-memory per-room runtime state (connected users keyed by `userId` with a `socketIds` array — no redundant single `socketId`, chat history, playback, countdown, status).
- `services/playback-countdown.service.ts` — pre-playback countdown timers + queued start; emits side effects via an `onComplete` callback so it stays socket-free.
- `services/room-moderation.service.ts` — kick/block: asserts the actor is the room creator, optionally bans, and disconnects every live socket the target holds.
- `services/realtime-broadcast.service.ts` — the single owner of every outbound socket write; wraps the Socket.IO `Server` and implements `RealtimeBroadcastPort`.
- `services/socket-auth.service.ts` — resolves `SocketUserInfo` from the access-token cookie during `handleConnection`.
Event names are the shared constants in `@repo/consts/realtime` (`REALTIME_CLIENT_EVENTS` / `REALTIME_SERVER_EVENTS`) with a typed `SocketEventContract` layer in `@repo/contracts/realtime`. The HTTP layer (`RoomsService`) depends on the **`REALTIME_BROADCAST_PORT`** abstraction (`realtime.broadcast-port.ts`), not the concrete gateway, keeping the Rooms↔Realtime cycle decoupled.
- `src/filters/` — `HttpExceptionFilter` that catches all exceptions and responds with RFC 7807 `ProblemDetails` from `@repo/schemas/errors`. Each response carries a stable `type` URI (`/problems/validation-failed` | `/problems/internal-error` | `/problems/http-error`) and a `traceId` from `RequestIdMiddleware`. Production-aware: redacts unexpected-error detail, redacts 5xx `HttpException` detail, **forces 5xx `HttpException` title to the canonical `STATUS_TITLES` entry** (prevents custom-error-name leaks via `throw new HttpException({ error: 'Postgres: secret_xyz' }, 500)`), strips Zod validation issues, strips the `errors` array from `HttpException` responses, and strips the query string from `instance`. Dev keeps all detail + issues + custom titles. The server log always has the full truth tagged with `[trace=<id>]` — use the id a user reports to find the matching log line. `response.headersSent` is checked before writing, and `safeStringify` caps output at 2 KB.
- `src/middleware/request-id.middleware.ts` — per-request correlation id, read from `x-request-id` (validated) or generated via `randomUUID()`. Echoed back as `x-request-id` on the response. Wired in `AppModule.configure()`.

Expand Down
13 changes: 13 additions & 0 deletions apps/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ src/
auth.dto.ts — nestjs-zod DTOs wrapping `@repo/schemas/auth`
auth.consts.ts — cookie names for access / refresh tokens
auth.types.ts — JwtAccessPayload + global `Express.Request` merge (`authPayload`)
realtime/
realtime.module.ts — RealtimeModule (provides REALTIME_BROADCAST_PORT via RealtimeBroadcastService)
realtime.gateway.ts — thin Socket.IO transport: WsAuthGuard + ZodWsValidationPipe + WsExceptionFilter, delegates to the services below. Event names from `@repo/consts/realtime`.
realtime.broadcast-port.ts — RealtimeBroadcastPort interface + REALTIME_BROADCAST_PORT token (RoomsService depends on this, not the gateway)
ws-auth.guard.ts — resolves SocketUserInfo onto socket.data.user; getAuthenticatedUser(socket) helper
zod-ws-validation.pipe.ts — validates @MessageBody() against a Zod schema, throws WsException on failure
ws-exception.filter.ts — maps WsException → `room:error` emit
services/connection-registry.service.ts — socketId↔user + userId→Set<socketId> index (multi-socket)
services/room-state.service.ts — in-memory per-room state (members keyed by userId with socketIds[], chat, playback, countdown, status)
services/playback-countdown.service.ts — countdown timers + queued start, side effects via onComplete callback
services/room-moderation.service.ts — kick/block: creator check, optional ban, disconnect all target sockets
services/realtime-broadcast.service.ts — single owner of outbound socket writes; implements RealtimeBroadcastPort
services/socket-auth.service.ts — resolves the user from the access-token cookie on connect
test/
app.e2e-spec.ts — supertest e2e suite: verifies /api prefix, x-request-id echo + UUID fallback + unsafe-id rejection, ProblemDetails shape + traceId propagation, Swagger served at /docs, auth register/login/refresh/me/logout
jest-e2e.setup.ts — sets JWT_* env defaults so e2e can boot without a real `.env`
Expand Down
13 changes: 13 additions & 0 deletions apps/backend/src/realtime/realtime.broadcast-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Narrow port the HTTP layer (RoomsService) depends on to push realtime
* side effects, without coupling to the concrete gateway implementation.
*/
export interface RealtimeBroadcastPort {
clearCountdown(roomId: string): void;
emitRoomMovieUpdated(roomId: string, movieId: string): void;
emitRoomPlaybackChanged(roomId: string, actorUserId: string | null): void;
emitRoomState(roomId: string): void;
removeRoomMember(roomId: string, userId: string): void;
}

export const REALTIME_BROADCAST_PORT = Symbol('REALTIME_BROADCAST_PORT');
Loading