# CLAUDE.md — myNextScreen (Multi-Tenant Digital Signage Platform) > **myNextScreen** — Multi-Tenant Digital-Signage-Plattform für Konzert-Venues. > Quelle der Wahrheit für Produkt/Features ist `VISION.md`, für die technische > Architektur `ARCHITECTURE.md`. Bei Widersprüchen gewinnt der **tatsächliche Code** > (siehe Hinweise unten). Diese Datei fasst zusammen, was beim Arbeiten am Repo > wichtig ist. ## Project Organisationen verwalten **Screens (TVs)** über ein Venue verteilt, laden Bilder/ Videos in eine geteilte **Content-Library** (mit Transcoding), bauen **Playlists**, planen sie kalenderbasiert über Screens hinweg (**Schedules**) und streamen **Live-Video** — alles über ein Angular-Admin-Dashboard. Screens ziehen ihren State per HTTP und bekommen Updates per **SSE** gepusht. Ein separater **Player** (Angular) läuft auf den Screens. Kernideen: - **Multi-Tenancy:** Jede **Organisation** ist voll isoliert (eigene Screens, Content, Playlists, Schedules, User). Provisioniert nur durch **Super-Admin**, keine Self-Registration. Jede Query ist serverseitig per `organisationId` gescoped. - **Rollen pro Organisation:** Org Admin / Editor / Viewer. Ein User kann mehreren Orgs angehören (separate Rolle je Org). Super-Admin ist system-level. - **Screens** registrieren sich per **API-Key** (einmalig angezeigt, regenerierbar), senden **Heartbeats** (online/offline) und cachen Content lokal (Playback läuft weiter, wenn die Verbindung abbricht). - **Screen-Groups:** Mirror-Mode (identischer, frame-synchroner Inhalt) oder Split-Mode (Video-Wall-Grid; Server sliced Content pro Screen-Viewport). - **Content:** Upload → Original gespeichert → BullMQ-Job transcodiert (Video → H.264 MP4, Bild → WebP/JPEG-Fallback). Original **und** Transcoded werden behalten. Storage-Limits pro Org (separat für Original/Transcoded) hart enforced. - **Schedules:** Kalender (Tag/Woche/Monat), drag&drop Playlist-Blöcke, **RRULE** für Recurrence, keine Overlaps, Lücken → **Fallback-Playlist** der Org. - **Live-Streams:** Eigener Modus (nicht Teil einer Playlist). FFmpeg ingestet die Quelle, transcodiert nach **HLS**, überschreibt die Schedule; bei Stream-Stop automatischer Fallback auf die geplante Playlist. - **Audit-Log** für alle signifikanten Aktionen; **Notifications** über In-App / E-Mail (SMTP) / **ntfy**, pro User togglebar. > **Hinweise zur Doku-Aktualität (Code gewinnt):** > > - **Echtzeit = SSE**, nicht WebSocket/Socket.IO (README-Tabelle ist veraltet). > Siehe `backend/src/dashboard/dashboard-sse.service.ts` + `@Sse`-Endpoints. > - **Screen-Protokoll = JSON-Adapter** (`screen-protocol/json-protocol-adapter.ts`). > SMIL ist nur als Abstraktion vorgesehen (`ScreenProtocolAdapter`-Interface), > noch **nicht** implementiert — VISION/README nennen SMIL als ersten Wert, > real ist JSON über HTTP + SSE. ## Domain Model (Backend-Module = Domains) ``` Organisation (Tenant; storage-limits, default/fallback playlist, time zone) ├── User-Membership { user, role(OrgAdmin|Editor|Viewer), notificationPrefs } ├── Screen { name, resolution/aspect, location, apiKey, heartbeat, status } │ └── gehört zu max. 1 ScreenGroup { mode(mirror|split), gridLayout } ├── Content { title, desc, tags, originalPath, transcodedPath, sizes, status } ├── Playlist { kind(static|dynamic), rule(jsonb), nextResolveAt } └── PlaylistItem { contentId, durationMs (für Bilder) } ├── ContentSource { adapterType, params, formats, style, syncInterval, lastSync* } │ └── SourceEvent { externalId, eventDate, venue, genres, status, goneAt } └── SourceEventSlide { format, contentId(managed), renderHash } ├── Schedule { screen|group, playlist, start/end, RRULE(recurring), color, managedByRuleId? } ├── EventScheduleRule { source, venues(canonical), playlist, windowStart/End(HH:MM lokal), excludeCancelled, enabled, │ genreVariants[] (Genre-Gruppen → versteckte Varianten-Playlists, playlists.managedByRuleId) } │ ├── Target { screen|group } └── Exclusion { sourceEvent } ├── LiveStream { sourceUrl, target screen(s)/group, ffmpeg lifecycle → HLS } ├── SiteAgent { name, location, version, network(kind/ssid/ip), heartbeat; Enrolment + rotierende Sessions } │ └── ScreenRemoteControl { screenId 1:1, agentId, localIp, mac, devmodePassphrase(enc), Toggles, Telemetrie } ├── AuditEntry { timestamp, userId, organisationId, action, resourceType/Id, details } └── Notification { channel(inapp|email|ntfy), userPrefs } ``` > Schema-Hinweise: > > - **Entity-IDs sind UUIDs.** Multi-Tenancy serverseitig per Guard/Query-Scope > durchsetzen (Viewer darf nichts anlegen) — nie nur im Frontend ausblenden. > - **Transcoded Files liegen im Filesystem, nicht in der DB.** Pfadschema: > `{MEDIA_BASE_PATH}/{organisationId}/originals|transcoded/{contentId}.{ext}`. > - **Geld/Storage als Integer-Bytes**, Limits separat für Original/Transcoded. > - **Split-/Slicing-Logik** liegt zentral (`screen/screen-state.service`, > `slice-content/`) — nicht pro Screen/Adapter duplizieren. ## Stack - **Frontend (Admin):** Angular ~22.2 (Standalone, Signals, **zoneless-orientiert**), Tailwind CSS v4 (`@tailwindcss/postcss`), eigene Auth-UI (email+password). Tests: **Vitest**. - **Player:** eigene Angular-22-App (`player/`), **hls.js** für Live-Streams. Tests: **Vitest**. - **Backend:** NestJS 12 (modular, ein Modul pro Domain), **Drizzle ORM** + **PostgreSQL** (`pg`), **BullMQ + Redis** (Transcoding-Jobs), `@nestjs/schedule`, `@nestjs/jwt` + `bcrypt` (interne Auth), `rrule`, `nodemailer`, `class-validator`/`class-transformer`. Tests: **Jest**. - **Auth:** **Interne Auth** (email+password, JWT-Access-Cookie + Redis-Refresh-Tokens, bcrypt-Hashing), **API-Keys** (Screens). Erster Super-Admin wird **nicht** per Env geseedet, sondern über ein **UI-First-Run-Setup** angelegt (`GET /api/auth/setup-status`, `POST /api/auth/setup`; nur möglich, solange kein User existiert — atomar). Der First-Run-Super-Admin **bleibt** (sieht alle Orgs, hebt Storage-Limits an). - **Self-Signup:** `POST /api/auth/register` legt **atomar** User + eigene Org + OrgAdmin-Membership an (`emailVerified=false`), mailt einen Verify-Link; **Login bleibt blockiert (403 „Email not verified") bis verifiziert** (`POST /api/auth/verify-email` → Auto-Login). Plus `resend-verification`, `change-email`/`confirm-email-change`, Passwort-Geändert-Notiz. Feature-Flag `SIGNUP_ENABLED` (in `setup-status` gespiegelt). Ein stündlicher Cron löscht nie-verifizierte Signups + deren Orphan-Org. - **Mailer-Trennung:** **Account/System-Mails** (verify, invite, reset, email-change, password-changed) laufen über den **ENV-getriebenen `PlatformMailerService`** (`notification/channels/`, SMTP via `SMTP_*`, event-getrieben, best-effort). **Per-Org-SMTP** bleibt nur für **Org-Notifications** (`EmailNotificationChannel`). Dev: **Mailpit** (`:8025`). - **Site Agent:** eigene NestJS-App (`apps/site-agent/`), laeuft im Venue-Netz. `ws` (SSAP), `ssh2` (Devmode-Verlaengerung ueber Luna), `dgram` (Wake-on-LAN). Eigene JWT-Audience `mynextscreen-agent`. Setup-UI auf Port 8787: Onboarding per **Dashboard-Setup-Code** (kein PIN mehr); `MNS_SERVER_URL` ist **Pflicht** und muss `https://` sein (Boot-Fehler sonst; `MNS_ALLOW_INSECURE_SERVER_URL=true` nur fuer lokale Entwicklung). Agent-Reset verlangt einen frischen Setup-Code (serverseitig validiert). Unbeaufsichtigt per `MNS_ENROLMENT_TOKEN` (nur solange keine Session gespeichert ist) plus eigenem Ansible-Playbook `ansible/site-agent.yml`. Tests: **Jest** (Gate 85/75/83/85). - **Media:** **FFmpeg** als Child-Process (Transcoding + HLS-Live). - **Echtzeit:** **SSE** (Dashboard-Updates + Screen-Pushes). - **Runtime:** Node.js 26 (LTS). **Package-Manager: npm** (npm@12.2.0, kein pnpm/yarn). - **Deployment:** Docker Compose (dev) · Ansible + rootless Podman Quadlets + Caddy (prod, `ansible/`). Repo-Remote: GitHub (`github.com/Flo-Schilli/mynextscreen`). ## Project Structure > **Nx integrated monorepo.** EIN Root-`package.json` + EIN `package-lock.json` > (Single Source of Truth, Version + alle Deps), `nx.json` (Targets/Caching), > `tsconfig.base.json` (Path-Mappings, u. a. `@mynextscreen/shared-types`). Apps haben > **kein** eigenes `package.json` — nur ein `project.json` mit Nx-Targets > (`nx:run-commands`). Gemischte Test-Runner: **Jest** (backend, Coverage-Gate), > **Vitest** (frontend via `ng test`, player via `vitest run`). ``` apps/ backend/ # NestJS API (REST + SSE + BullMQ-Worker) project.json # Nx-Targets: build/serve/test/typecheck/lint/format + db-* (drizzle-kit) jest.config.ts # Coverage-Gate 92/83/84/92; coverage → apps/backend/coverage drizzle.config.ts # Drizzle-Kit Config (generate/migrate/push/studio) src/ auth/ # Auth (Cookie email+password, API-Key-Auth, Role-Guards) organisation/ # Tenant-Mgmt, Storage-Limits, Fallback-Playlist, Time Zone user/ # Org-Membership, Rollen, Notification-Prefs screen/ # Registrierung, API-Key, Heartbeat, State-Service screen-group/ # Mirror/Split-Modi, Grid-Layout content/ # Upload, Metadata, triggert Transcoding-Jobs (dto/) slice-content/ # Video-Wall-Slicing pro Screen-Viewport playlist/ # Playlist-CRUD, geordnete Items mit Dauer; dynamische Playlists (Regel, Resolver, Resolve-Queue) content-source/ # Website-Adapter, Sync-Queue (+ täglicher Cleanup), Slide-Renderer (sharp), Event-Pool schedule/ # Kalender-CRUD, RRULE-Recurrence, Overlap/Fallback event-schedule/ # Event-Schedule-Regeln: Planer (pure), Reconcile (Advisory-Lock je Org), Listener + Stunden-Tick live-stream/ # Stream-URL, FFmpeg-Lifecycle, Override/Fallback (HLS) screen-protocol/ # Adapter-Abstraktion; JsonProtocolAdapter (erster Impl) notification/ # Hub + channels/ (in-app, email, ntfy) audit-log/ # Audit-Trail dashboard/ # SSE-Service + Controller für Echtzeit-Updates media/ # Filesystem-Media-Handling search/ # Globale Suche (screens/content/playlists, org-scoped) db/ # Drizzle-Schema + migrations/ (generierte SQL) health.controller.ts # Healthcheck Dockerfile.prod # baut aus Repo-Root-Context (COPY package*.json …) frontend/ # Angular 22 Admin-SPA (src/app/, shell/, shared/) project.json # build (@angular/build), serve, test (ng test → Vitest), … angular.json # minimal — hält Vitest-Root am App-Dir (TestBed-Isolation) player/ # Angular 22 Player-App (connection/, playback/, player/) project.json # build (@angular/build), serve, test (vitest run), … site-agent/ # NestJS Site-Agent — laeuft IM Venue, steuert die LG-TVs project.json # build/serve/test/typecheck/lint/format jest.config.ts # Coverage-Gate 85/75/83/85 Dockerfile.prod # baut aus Repo-Root-Context src/{setup,connection,config,tv,probe,supervisor,protocol}/ libs/ shared-types/ # geteilte TS-Typen (Lib; nur lint/typecheck-Targets) nx.json # Nx-Targets, namedInputs (production), Caching tsconfig.base.json # Path-Mappings (@mynextscreen/shared-types) für alle Projekte package.json # Single Source: Version + alle Deps (Root) package-lock.json # einziges Lockfile player-applications/ # Native/Plattform-Player lg-tvos/ # LG webOS App (app.js, appinfo.json) ansible/ # Prod-Deployment: quadlets/, templates/ (Caddy, fail2ban), *.yml docker-compose.yml # Dev: backend(3000) · frontend(4200) · player(4300) · redis · postgres(5432) ARCHITECTURE.md # Technische Architektur (kanonisch) VISION.md # Produkt-Vision & Feature-Details (kanonisch) ``` > Build-Outputs: backend → `dist/apps/backend/main.js`; frontend/player → > `dist/apps/{frontend,player}/browser`. ## Active ECC Rules - common - typescript - angular ## Default Skills to load - nestjs-patterns # Backend-Arbeit in backend/ - angular-developer # Frontend-/Player-Arbeit in frontend/ bzw. player/ ## Agents - @typescript-reviewer # vor jedem PR - @build-error-resolver # bei Build-/Type-Fehlern - @tdd-guide # beim Schreiben von Tests ## Conventions - **Strict TypeScript, kein `any`.** - **Angular: Standalone Components, Signals, keine NgModules.** Business-Logik in Services, nicht in Components. - **NestJS: ein Modul pro Domain.** DTOs immer mit `class-validator` validieren (`dto/`-Ordner pro Modul). Multi-Tenancy in Guards/Services durchsetzen. - **Drizzle-Schema (`src/db/`) ist die Schema-Quelle.** Schema-Änderungen immer per Migration (`nx run backend:db-generate` → `db-migrate`), nie Auto-Sync/`db-push` in Prod. - **TailwindCSS only** (v4), möglichst kein Custom-CSS. - **Prettier** für Formatierung (alle drei Apps haben `.prettierrc` + `format`/`format:check`), **ESLint** ohne Formatierungsregeln. - **npm** als Package-Manager — nie pnpm/yarn. - **Versions-Single-source-of-truth = Root-`package.json`.** Release via `npm run version:patch && git push --follow-tags`; alles andere (Image-Tags, OCI-Labels, `/api/version`, `version.json`) leitet sich daraus ab — nie manuell. Die Sub-`package.json`/`package-lock.json`-Versionen (backend/frontend/player) pflegt der `version`-npm-Hook via `scripts/sync-versions.mjs` aus der Root-Version — nie manuell editieren; CI (`sync-versions.mjs --check`) erzwingt das. - Externe Calls (ntfy, SMTP) hinter Interface/Modul, mockbar. - Geheimnisse/API-Keys nie plaintext loggen; Config über ENV (`@nestjs/config`). - **Private SSH-Keys der TVs gehoeren nie auf den Server** — nur die Devmode-Passphrase, verschluesselt. Der Agent zieht den Key vom Key-Server des TVs. Die Passphrase ist das einzige Feld, das einem Aufrufer im Klartext und einem anderen maskiert geliefert wird; deshalb sind Agent- und Dashboard-Payload getrennte Typen, nicht einer mit Flag. ## Do NOT - Keine NgModules — immer Standalone Components. - Keine Business-Logik in Angular-Components — immer Services. - Multi-Tenancy nicht nur im Frontend „verstecken" — serverseitig per Scope/Guard. - Transcoded/Original-Files nicht in die DB schreiben — Filesystem unter `MEDIA_BASE_PATH`. - Schema nicht manuell in der Postgres-DB ändern — immer Migration. - Nicht manuell formatieren — Prettier macht das. - Kein pnpm/yarn. Keine `.env`-Dateien committen. - README-Stack-Tabelle nicht blind übernehmen (Echtzeit = **SSE**, Protokoll = **JSON**). ## Commands ```bash # Alles per Docker (dev): backend:3000 · frontend:4200 · player:4300 · redis:6379 npm run dev # docker compose up --build # Root-Scripts fächern per `nx run-many` über ALLE Projekte (backend/frontend/player/site-agent/shared-types): npm run lint # nx run-many -t lint npm run test # nx run-many -t test (backend + site-agent Jest · frontend/player Vitest) npm run typecheck # nx run-many -t typecheck npm run format:check # nx run-many -t format:check # Release + lokale Prod-Images (Version aus Root-package.json): npm run version:patch # bump + commit + tag (push: git push --follow-tags) npm run images:build # baut alle 4 prod-Images aus Repo-Root-Context # Nx direkt (vom Repo-Root): npx nx affected -t lint typecheck test build # nur vom Diff betroffene Projekte (so läuft CI auf PRs) npx nx run-many -t lint typecheck test build # alle Projekte (so läuft CI auf main) npx nx build # backend/site-agent → dist/apps/ · frontend/player → dist/apps//browser npx nx serve # backend: nest start --watch · frontend/player: ng dev-server (4200/4300) npx nx test # backend: jest --coverage (Gate) · frontend: ng test · player: vitest run npx nx typecheck / lint / format:check # Backend-DB (Drizzle, via Nx run-commands → drizzle-kit, cwd apps/backend): npx nx run backend:db-generate # drizzle-kit generate (Migration aus Schema-Diff) npx nx run backend:db-migrate # drizzle-kit migrate npx nx run backend:db-push # drizzle-kit push npx nx run backend:db-studio # drizzle-kit studio ``` > **Lokal ohne Docker:** PostgreSQL (16+), Redis (7+) und FFmpeg müssen erreichbar/auf > dem `PATH` sein; `.env.example` → `.env` kopieren und `JWT_ACCESS_SECRET` setzen > (`openssl rand -base64 48`). Schema via `nx run backend:db-migrate` anlegen. ## Tests & Quality Gates - **CI:** `.github/workflows/ci.yml` — Trigger push `main`, Tags `v*.*.*`, PRs. Job 1 `lint-test-build` (EIN Job, kein Matrix mehr): `npm ci` am Root, `sync-versions.mjs --check`, dann `nrwl/nx-set-shas` + `nx affected -t format:check / lint typecheck build / test` auf **PRs** bzw. `nx run-many` auf **main** (nichts wird übersprungen). Backend-Coverage-Gate läuft im `test`-Target (Jest); zusätzlich `ArtiomTr/jest-coverage-report-action` (`working-directory: apps/backend`) für PR-Kommentar + Artifact aus `apps/backend/coverage`. Job 2 `docker` (Matrix backend/frontend/player): baut alle drei prod-Images aus Repo-Root-Context (`file: apps//Dockerfile.prod`), **published nach GHCR nur auf main/Tags** (nicht bei PRs). - **Coverage-Gate nur Backend** (`backend/jest.config.ts`, ~80% — gemessen ~85%). Frontend/Player ohne Schwelle (Test-Abdeckung dort dünn: ~8 bzw. ~3 Specs; Backend ~83 Suites / 900+ Tests). Keine künstlichen Tests nur fürs Gate. - **Frontend-Tests sind in CI ein harter Gate** (kein `continue-on-error` mehr). Der frühere `TestBed`-State-Leak über Spec-Dateien (Vitest + `@analogjs/vite-plugin-angular`) lag am Plugin-Default-Pool `vmThreads`, der `@angular/core/testing` externalisiert und das TestBed-Singleton über VM-Contexts auf demselben Thread teilt — auf Low-Core-Runnern, wo Specs sich einen Worker teilen, leakt das. Fix: `pool: 'forks'` in `frontend/vitest.config.ts` (frische, prozess-isolierte Modul-Registry je Spec-Datei). Grün auch bei `singleFork`/`--no-file-parallelism`. - Kein e2e-Setup; eine Backend-Integration-Spec (`playlist-transition.integration.spec.ts`). ## Environment (wichtigste Variablen) | Variable | Zweck | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `DATABASE_URL` | PostgreSQL-DSN (z. B. `postgres://signage:signage@localhost:5432/signage`) | | `REDIS_URL` | BullMQ-Queue + Refresh-Tokens (`redis://localhost:6379`) | | `MEDIA_BASE_PATH` | Root für Original/Transcoded Media | | `JWT_ACCESS_SECRET` | **Pflicht** — Secret für JWT-Access-Token (`openssl rand -base64 48`) | | `SECRETS_ENCRYPTION_KEY` | **Pflicht** — AES-256-GCM-Key (32 Byte, base64) für alle rückholbaren Secrets; DB lehnt Klartext per CHECK ab | | `JWT_ACCESS_TTL` / `JWT_REFRESH_TTL` | Token-Lebensdauer (z. B. `15m` / `30d`) | | `COOKIE_SECURE` / `COOKIE_SAMESITE` | Cookie-Härtung (Secure default nur bei `NODE_ENV=production`) | | `PUBLIC_BASE_URL` | Basis-URL der Admin-SPA (Verify/Set-Password/Reset/Confirm-Email-Links + CORS) | | `SMTP_HOST` / `_PORT` / `_USER` / `_PASSWORD` / `_FROM` / `_SECURE` | Platform-Mailer (Account-Mails); Dev → Mailpit (`localhost:1025`) | | `SIGNUP_ENABLED` | Feature-Flag Self-Signup (default `true`) | | `SIGNUP_DEFAULT_STORAGE_ORIGINAL_BYTES` / `_TRANSCODED_BYTES` | Default-Storage-Limits einer self-created Org (default 5 GiB) | | `SIGNUP_UNVERIFIED_TTL_HOURS` | Frist bis Cleanup-Cron nie-verifizierte Signups löscht (default 24) | | `SITE_AGENT_ENROLMENT_TTL_MS` | TTL des Site-Agent-Setup-Codes (default 900000 = 15 min; < 60 s wird ignoriert) | | `MAX_FILE_SIZE_BYTES` | Upload-Limit | | `FFMPEG_PATH` | FFmpeg-Binary (default: System-PATH) | | `FFMPEG_VIDEO_CRF` / `_PRESET` / `_MAXRATE` / `_BUFSIZE` | Transcoding-Qualität | | `METRICS_SCRAPE_TOKEN` | Bearer-Token für `GET /api/metrics` (Prometheus); unset = Endpoint 404-disabled, nie öffentlich routen | ## Deployment (Prod) — GHCR-Pull-Modell - **CI baut + published** die Images nach **GHCR** (`ghcr.io/flo-schilli/mynextscreen/{backend,frontend,player}`). - **Ansible** (`ansible/deploy.yml`) baut nichts lokal mehr — es `podman login`t (falls `ghcr_token` gesetzt) und **zieht** die Images. App-Quadlets sind Templates (`ansible/templates/signage-*.container.j2`) mit `AutoUpdate=registry` + `Pull=newer`; der Tag ist über `signage_image_tag` (default `latest`, pinbar auf `1.2.3`) steuerbar. Redis kommt direkt von `docker.io/library/redis:7-alpine`. - **Versions-Surfaces:** Build-args `APP_VERSION/GIT_COMMIT/BUILD_DATE` → OCI-Labels in allen Images; Backend `GET /api/version`; Frontend & Player `GET /version.json`. - **Caddy** als einziger öffentlicher Reverse Proxy (`templates/Caddyfile.j2`), inkl. SSE-Pass-Through (X-Real-IP) und **fail2ban**-Jail. - Container-Härtung: `tini` als PID 1 (backend), `HEALTHCHECK` in allen Images (backend → `/api/health`, nginx → `/`). Backend bleibt rootless-gemapptes root wegen Host-Bind-Mounts (`%h/app/{data,media}`); erzwungenes `USER 1001` würde Schreibrechte auf die Volumes brechen. - DB-Backup/Restore über `ansible/download_db.yml` / `upload_db.yml`. - Auth-Flow: Frontend ↔ Backend (email+password) → JWT-Access-Cookie + Redis- Refresh-Token; Backend signiert/validiert mit `JWT_ACCESS_SECRET`. Screens nutzen API-Keys.