# Tessera — Anleitung für Entwickler Diese Anleitung richtet sich an Entwicklerinnen und Entwickler, die neu zu Tessera stoßen. Sie kennen TypeScript, aber nichts von diesem Repository. Ziel ist, Sie von einem frischen Checkout bis zu einem eigenen, lauffähigen Modul zu bringen — alle Angaben sind aus dem tatsächlichen Code geprüft, nicht aus einer geplanten Architektur abgeleitet. ## Inhaltsverzeichnis 1. [Aufbau des Monorepos](#aufbau-des-monorepos) 2. [Lokale Entwicklungsumgebung](#lokale-entwicklungsumgebung) 3. [Architektur im Überblick](#architektur-im-überblick) 4. [Das Modulsystem](#das-modulsystem) 5. [Mandantentrennung](#mandantentrennung) 6. [Berechtigungen](#berechtigungen) 7. [Datenbank und Migrationen](#datenbank-und-migrationen) 8. [Tests](#tests) 9. [Konventionen und Fallstricke](#konventionen-und-fallstricke) --- ## Aufbau des Monorepos Tessera ist ein pnpm-Workspace (`pnpm-workspace.yaml`), orchestriert über Turborepo (`turbo.json`). Der Paketmanager ist mit `packageManager: "pnpm@9.15.0"` in der Root-`package.json` fest verankert. ``` apps/ api/ @tessera/api — NestJS-Backend web/ @tessera/web — Next.js-Frontend desktop/ @tessera/desktop — Tauri-Desktop-Client (Windows/Linux), fertiges Produkt packages/ shared/ @tessera/shared — geteilte Konstanten/Typen, inzwischen auch die Manifest-Typen der Desktop-Pakete module-sdk/ — — TypeScript-Interfaces für den Modul-Vertrag (TesseraModule, ModuleManifest) ``` `apps/desktop` ist der fertige Desktop-Client (Tauri 2), kein Grundgerüst mehr: `src-tauri/src/lib.rs` bündelt Tray-Menü, die beiden Erststart-Kommandos (`check_server`/`save_server_url`) und die Versionsprüfung gegen den Server; `src/setup.html` ist die eigenständige Erststart-Seite (kein Bundler, spricht nur über `window.__TAURI__.core.invoke`). Die fertigen Installationspakete (Windows-`.exe`, Linux-`.AppImage`) entstehen nicht lokal, sondern im CI-Job `desktop` (siehe [Desktop-App lokal bauen](#desktop-app-lokal-bauen) für den lokalen Linux-Bau). `packages/shared` bleibt schlank, trägt inzwischen aber zusätzlich zu Konstante und Health-Interface auch die Typen für das Desktop-Paket-Manifest (`DesktopPlatform`, `DesktopManifest`, `DesktopLatestResponse`). **Root-Skripte** (`package.json`, laufen über Turborepo durch alle Workspaces): | Skript | Bedeutung | |---|---| | `pnpm dev` | `turbo dev` — startet alle `dev`-Tasks (bei API/Web persistent, ungecached) | | `pnpm build` | `turbo build` | | `pnpm lint` | `turbo lint` | | `pnpm test` | `turbo test` | | `pnpm type-check` | `turbo type-check` | Linting/Formatierung laufen über **Biome** (`biome.json`, Zeilenlänge 100, 2 Spaces, `organizeImports` aktiv) — es gibt kein ESLint/Prettier im Projekt. `pnpm lint` führt seit dem Vorgang #35 in jedem der fünf Workspaces tatsächlich `biome lint .` aus, und der CI-Schritt „Lint" prüft damit echt statt nur grün zu melden. Das Tor blockiert nur auf echte Fehler; Stilhinweise laufen als Warnungen mit und stoppen den Lauf nicht. Seit dem Vorgang 260921-bi2 trägt `biome.json` genau zwei gezielte Ausnahmen, sonst keine: In Testdateien (`**/*.spec.ts`, `**/*.spec.tsx`, `**/*.test.ts`, `**/*.test.tsx`) ist `suspicious/noExplicitAny` abgeschaltet, weil `any` dort ausnahmslos an Attrappen hängt und eine Umschreibung viel Bewegung bei null Gewinn wäre. In `apps/api/**` ist `style/useImportType` abgeschaltet, weil die dortige Korrektur die von NestJS über `emitDecoratorMetadata` erzeugten Abhängigkeitsdaten zerstört — Biome räumt das in der eigenen Regelbeschreibung ein (`biome explain useImportType`, Abschnitt „Caveat with TypeScript experimental decorators") und rät dort selbst zum Abschalten bei solchen Dekoratoren; der Schaden würde von keinem Tor dieses Projekts bemerkt, da kein Test `createTestingModule` aufruft. Nach Abschluss des gesamten Vorgangs 260921-bi2 (Konfiguration, maschineller Durchgang plus toter Code, und von Hand gepflegte Barrierefreiheit) steht der Rückstand bei **465 Warnungen (386 in echtem Quelltext, 79 in Testdateien)**, Fehlerstufe durchgehend 0 — vorher waren es 2923. Sechs namentlich benannte Klassen bleiben absichtlich offen, weil jede eine Gestaltungsentscheidung oder einen Verhaltenswechsel verlangt, den ein reiner Aufräum-Vorgang nicht treffen darf: `a11y/noNoninteractiveElementInteractions` (11 Fundstellen) und `a11y/noStaticElementInteractions` (5) verlangen die Entscheidung, ob ein geklickter Bereich eine echte Bedienung wird oder der Klick verschwindet; `a11y/useKeyWithClickEvents` (5) verlangt einen bisher fehlenden Tastaturweg; `a11y/noAutofocus` (4) würde den Eingabefokus beim Seitenaufruf verschieben; `a11y/useAriaPropsSupportedByRole` (5) verlangt einen Blick auf jede Rolle einzeln; und `complexity/noUselessSwitchCase` (1, in `apps/api/src/tenders/tender-normalizer.service.ts:60`) wurde bewusst nicht angewendet, weil der Vorschlag eine Fallmarke streichen würde, die einen Kommentar zur Absicht des Standardzweigs trägt — eine Unterdrückung wäre hier unehrlicher als das sichtbare Stehenlassen. Dazu vier gemeldete, nicht reparierte Symptome aus dem toten-Code-Durchgang (D-03): `force-password-change.interceptor.ts` prüft das HTTP-Verfahren nicht, bevor es eine Route freigibt; `change-password/page.tsx` leitet nach erzwungenem Passwortwechsel nicht weiter und frischt die Benutzerablage nicht auf; `VehicleTable.tsx` hat keinen Besetztzustand auf der Löschschaltfläche; `SplitTab.tsx` trägt fest verdrahtete Texte statt Übersetzungen. Alle vier sind als eigene Folgeaufgaben zu planen. `pnpm lint` formatiert dabei nichts und besteht auch nicht auf Formatierung; für Formatierung gibt es getrennt `biome format --write`, das absichtlich von Hand angestoßen wird. Testrunner ist **Vitest** in beiden Apps (`apps/api/vitest.config.ts`, `apps/web/vitest.config.ts`); für `apps/web` läuft die `jsdom`-Umgebung mit `@testing-library/react`. ## Lokale Entwicklungsumgebung ### Voraussetzungen - Docker und Docker Compose - pnpm 9.x (`packageManager` in `package.json` pinnt `pnpm@9.15.0`) Die produktiven `Dockerfile`s ziehen `node:24-alpine` — das ist die verbindliche Node-Version für Container-Builds. ### Umgebungsvariablen Kopieren Sie `.env.example` nach `.env`. Zwei Werte sind praxisrelevant: - `DB_PASSWORD` — Postgres-Passwort, Default in Compose ist `tessera_dev`, falls nicht gesetzt. - `TESSERA_ENCRYPTION_KEY` — verschlüsselt alle gespeicherten Zugangsdaten (LDAP-Bind, Kalender- und Postfach-Logins). **Pflichtfeld**, der Stack startet ohne diesen Wert nicht. Erzeugen mit `openssl rand -hex 32`. Geht der Wert verloren, sind alle gespeicherten Zugangsdaten unwiederbringlich — der Schlüssel gehört zu jedem Datenbank-Backup dazu, aber getrennt davon aufbewahrt. Alle übrigen Variablen (JWT-Secret, SMTP für den lokalen `mailhog`, Admin-Zugangsdaten) haben in `docker-compose.yml`/`docker-compose.dev.yml` brauchbare Entwicklungs-Defaults. ### Stack starten ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build ``` Das startet: - **web** — Next.js mit Turbopack (`next dev --turbopack`), Port `3000` - **api** — NestJS mit Watch-Modus (`nest start --watch`), Port `3001` - **db** — Postgres 16 (Alpine), **ohne Host-Port** - **mailhog** — SMTP-Testserver, UI auf `8025`, SMTP auf `1025` - **openldap** / **phpldapadmin** — für LDAP-Sync-Entwicklung, Ports `389`/`636` bzw. `6443` `docker compose up` ohne `--build`/`--force-recreate` baut bestehende Images **nicht** neu — nach Änderungen an Dockerfiles oder Dependencies muss `--build` explizit mitgegeben werden. ### Datenbank vom Host erreichen (wichtig) Der `db`-Service hat **keinen Host-Port** — `docker-compose.yml` exponiert für `db` bewusst nichts nach außen, nur die internen Container-Netze. Ein `psql` oder `prisma`-Aufruf **vom Host** kann sich also nicht über `localhost:5432` verbinden. Stattdessen über die Container-IP: ```bash docker compose ps # Namen des db-Containers ermitteln docker inspect \ | grep -A1 '"Networks"' | grep IPAddress # Container-IP im internen Netz ``` Verbindung dann mit den Zugangsdaten aus Compose (`tessera` / `tessera_dev` im Dev-Setup): ``` postgresql://tessera:tessera_dev@:5432/tessera ``` Das ist relevant, sobald Sie `prisma migrate dev`, `prisma studio` oder ein manuelles `psql` **vom Host aus** statt aus dem `api`-Container heraus ausführen wollen. ### Desktop-App lokal bauen Voraussetzungen zusätzlich zu oben: - Rust (stable) über [rustup](https://rustup.rs/) - Auf Ubuntu/Debian folgende Systempakete: ```bash sudo apt-get install -y libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \ libayatana-appindicator3-dev librsvg2-dev libgtk-3-dev libssl-dev patchelf ``` Version setzen und Linux-Paket bauen: ```bash sh .gitea/scripts/desktop-version.sh pnpm --filter @tessera/desktop exec tauri build --bundles appimage --no-sign ``` `desktop-version.sh` schreibt die Version des letzten Freigabe-Tags in `tauri.conf.json`/`Cargo.toml` — die im Repository eingecheckten Versionsdateien sind nur eine Basislinie, nicht die tatsächliche Freigabeversion. Das fertige Paket liegt danach unter `apps/desktop/src-tauri/target/release/bundle/appimage/`. **`--no-sign` ist lokal Pflicht.** Seit der Update-Funktion in der App verlangt `tauri build` den Signierschlüssel (Umgebungsvariable `TAURI_SIGNING_PRIVATE_KEY`), weil `bundle.createUpdaterArtifacts` und der öffentliche Schlüssel `plugins.updater.pubkey` in `tauri.conf.json` gesetzt sind — ohne Schlüssel bricht der Bau mit „A public key has been found, but no private key" ab. Mit `--no-sign` entsteht keine `.sig`-Datei; `desktop-collect.sh` warnt dann nur (Kanal `dev`) und lässt das Feld `signature` weg, und die API antwortet auf `GET /desktop/update` mit `204`. Der In-App-Update-Weg lässt sich lokal also nur mit dem echten Schlüssel durchspielen (Betriebshandbuch, Kapitel 10). Zwei Hinweise für `tauri dev`: Dort läuft die App ohne AppImage — den Update-Download (`download_and_install`) nie auslösen, er würde die Binary in `target/` überschreiben; die Versionsprüfung selbst ist im Debug-Bau auch gegen `http://localhost` erlaubt (das Plugin warnt nur, der Release-Bau lehnt `http` ab). Um das Paket so einzusammeln, wie die API es ausliefern würde: ```bash sh .gitea/scripts/desktop-collect.sh --require linux ``` Das schreibt `desktop-dist/` (per `.gitignore` vom Git ausgeschlossen, bis auf einen Platzhalter) samt `manifest.json`. Starten Sie danach den lokalen Docker-Stack (`docker compose build api` genügt für die API allein), liefert `GET /desktop/latest` die dort abgelegten Pakete aus. **Der Windows-Installer wird nur im CI gebaut** (`cargo-xwin`-Cross-Bau, NSIS aus dem Ubuntu-Paket `nsis` — siehe `docs/ci-cd-setup.md`, Abschnitt 4). Lokal genügt für Rust-Änderungen `cargo check`/`cargo clippy` in `apps/desktop/src-tauri`; einen Windows-Installer lokal zu bauen ist nicht vorgesehen. Sprache, Symbol und Bilder des Installers stehen in `apps/desktop/src-tauri/tauri.conf.json` unter `bundle.windows.nsis` (Deutsch ohne Sprachauswahl, `icons/nsis-header.bmp` 150×57 und `icons/nsis-sidebar.bmp` 164×314 als 24-Bit-BMP); Änderungen daran lassen sich nur über den CI-Bau auf einem Windows-Rechner prüfen, lokal validiert `cargo check` lediglich die Schlüssel. **Im CI wird die Desktop-App nur gebaut, wenn sich etwas an ihr geändert hat.** Der Job `desktop` vergleicht einen Stempel aus Versionsnummer und letztem Commit an `apps/desktop/`, `desktop-version.sh`, `desktop-collect.sh`, `desktop-stamp.sh` und `ci.yml` mit dem Zwischenspeicher des Runners und übernimmt bei Treffer die zuletzt gebauten Pakete (Details: `docs/ci-cd-setup.md`, Abschnitt 4). Eine Änderung außerhalb dieser Pfade – etwa nur in `pnpm-lock.yaml` – löst keinen Desktop-Bau aus; soll trotzdem neu gebaut werden, genügt eine Änderung unter `apps/desktop/`. Stempel lokal ansehen: ```bash DESKTOP_TAG=v1.2.0 GITHUB_REF=refs/heads/main sh .gitea/scripts/desktop-stamp.sh stamp ``` ## Architektur im Überblick **Frontend** (`apps/web/src/app`, Next.js App Router): ``` (auth)/ — /login, /reset-password — öffentliche Routen (portal)/ — alles hinter Login: /admin, /marketplace, /modules, /settings, /change-password ``` Die Route-Groups `(auth)` und `(portal)` teilen sich kein gemeinsames Layout im URL-Pfad, tragen aber unterschiedliche Layout-Bäume. Innerhalb von `(portal)` liegt `modules/[category]/[moduleSlug]` als generische Route für beliebige Module sowie vier fest verdrahtete Modulverzeichnisse (`cert-manager`, `dkv-fleet`, `domaincheck`, `tender-radar`) mit eigenen `layout.tsx`-Dateien — Details dazu im Abschnitt [Das Modulsystem](#das-modulsystem). **Backend** (`apps/api/src`, NestJS): ein Modul pro fachlicher Domäne (`auth`, `user`, `tenant`, `groups`, `module-registry`, `domaincheck`, `dkv`, `cert-manager`, `tenders`, `calendar`, `dashboard`, `favorites`, `settings`, `ldap`, `mail`, `crypto`, `health`, `prisma`). Jedes Domänen-Modul folgt dem NestJS-Muster `*.module.ts` / `*.controller.ts` / `*.service.ts`. **Weg einer Anfrage** (Beispiel: eine Modulseite lädt Daten): 1. Eine Server- oder Client-Komponente unter `apps/web/src/app/(portal)/...` ruft die API über `fetch` gegen `NEXT_PUBLIC_API_URL` (Browser) bzw. `API_INTERNAL_URL` (Server-Komponenten, zeigt intern auf `http://api:3001`) auf. 2. Die Anfrage trifft in `apps/api/src/main.ts` auf die globale `ValidationPipe` und läuft dann durch die drei global registrierten `APP_GUARD`s aus `app.module.ts`, in genau dieser Reihenfolge: `JwtAuthGuard` (Auth) → `TenantGuard` (setzt `req.tenantId` aus dem JWT) → `RolesGuard` (prüft `@Roles()`). 3. Trägt der Controller zusätzlich `@UseModule('slug')`, prüft anschließend `ModuleGuard` (`apps/api/src/module-registry/module.guard.ts`) Modulzugriff über `ModuleAccessService`. 4. Der Controller ruft den zugehörigen Service auf, der über `PrismaService` (`apps/api/src/prisma/prisma.service.ts`) oder — für mandantensensible Tabellen — über einen dienst-intern per `forTenant()` gebundenen Client auf Postgres zugreift. 5. Die Antwort geht als JSON zurück; das Frontend rendert sie in der jeweiligen Server- oder Client-Komponente. ## Das Modulsystem Module sind das zentrale Organisationsprinzip von Tessera: fachliche Werkzeuge (Domaincheck, Zertifikat-Manager, DKV-Rechnung, Ausschreibungs-Radar), die im Marktplatz erscheinen, pro Mandant aktiviert und dann einzelnen Gruppen oder Benutzern freigegeben werden. ### Registrierung Jedes Modul-`Module` (NestJS) seedet sich beim Start selbst in die Datenbanktabelle `Module` — über `OnModuleInit` und eine `seed*Module()`-Funktion, siehe `apps/api/src/domaincheck/domaincheck.seed.ts`: ```ts await moduleRegistryService.seedModule({ slug: 'domaincheck', name: 'Domaincheck', version: '1.0.0', category: 'domain-tools', description: { de: '...', en: '...' }, isSystem: true, }); ``` `ModuleRegistryService.seedModule` (`apps/api/src/module-registry/module-registry.service.ts`) macht daraus ein Upsert auf `slug` — bei jedem API-Start wird der Registry-Eintrag aktualisiert, nicht dupliziert. Ein Modul erscheint im Marktplatz (`GET /modules`, `GET /modules/catalog`), sobald dieser Seed einmal gelaufen ist — unabhängig von der Mandanten-Aktivierung. ### Zweistufiges Zugriffsmodell Zugriff auf ein Modul besteht aus zwei unabhängigen Stufen: 1. **Mandanten-Aktivierung** (`TenantModuleActivation`) — ein Admin schaltet das Modul für den gesamten Mandanten frei/aus (`POST /modules/:moduleId/activate|deactivate`, nur ADMIN/ SUPER_ADMIN). Ohne Aktivierung ist das Modul für niemanden im Mandanten erreichbar, auch nicht über einen Grant. 2. **Grant pro Gruppe oder Benutzer** (`ModuleGrant`) — erst wenn das Modul aktiv ist, entscheidet ein Grant, wer es tatsächlich sieht. `ModuleGrant` trägt bewusst kein Rechtestufen-Feld, nur An/Aus (D-04 im Code-Kommentar des Schemas), und ist Gruppe **oder** Benutzer, nie beides. Beide Stufen werden ausschließlich von einer einzigen Funktion aufgelöst: `ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role)` (`apps/api/src/module-registry/module-access.service.ts`). ADMIN und SUPER_ADMIN umgehen die Grant-Prüfung und bekommen automatisch alle mandantenweit aktiven Module. Für die Rolle USER ist es die Vereinigungsmenge aus Direkt-Grants und Grants über Gruppenmitgliedschaft, geschnitten mit den aktiven Modulen des Mandanten. Diese eine Funktion versorgt drei Stellen — den `ModuleGuard` im Backend, `GET /modules/active` (Sidebar) und `GET /modules/catalog` (Marktplatz) — damit keine dieser Stellen unabhängig voneinander driften kann. ### Vom Backend-Endpunkt zur Seite im Portal Ein Modul-Controller schützt seine Routen mit dem `@UseModule(slug)`-Dekorator: ```ts @Controller('modules/domaincheck') @UseModule('domaincheck') export class DomaincheckController { ... } ``` `UseModule` (`apps/api/src/module-registry/module.guard.ts`) setzt Metadaten und hängt `ModuleGuard` als `CanActivate` ein. **Ohne diesen Dekorator gibt `ModuleGuard` bewusst `true` zurück** — die Durchsetzung hängt vollständig am Dekorator, jeder neue Modul-Controller muss ihn tragen. Im Frontend gibt es zwei Wege, wie eine Modulseite unter `/modules/...` erreichbar ist: - **Generische Route** `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` — für jedes Modul über `[category]`/`[moduleSlug]` erreichbar. - **Vier fest verdrahtete Modulverzeichnisse**: `modules/cert-manager`, `modules/dkv-fleet`, `modules/domaincheck`, `modules/tender-radar` — mit eigenen Unterrouten (z. B. `dkv-fleet/vehicles`, `tender-radar/my-sources`, `*/settings`). Beide Wege rendern denselben Baustein: die Server-Komponente `ModuleAccessGate` (`apps/web/src/components/modules/module-access-gate.tsx`). Sie ruft `checkModuleAccess(moduleSlug)` auf, was `GET /modules/active` mit dem Session-Cookie anfragt — dieselbe `ModuleAccessService`-Auflösung, die auch Sidebar und `ModuleGuard` benutzen. Nur bei explizit `true` werden die `children` gerendert; jeder andere Ausgang (verweigert, oder die Prüfung wirft einen Fehler) zeigt eine gemeinsame 403-Ansicht. **Bekannter Fallstrick (behoben, aber lehrreich):** Ursprünglich saß dieser Zugriffs-Check nur in der generischen `[category]/[moduleSlug]`-Route. Die vier fest verdrahteten Modulverzeichnisse hatten **keinen eigenen** `ModuleAccessGate` und liefen an der Prüfung vorbei — ein direkter Aufruf von z. B. `/modules/dkv-fleet` umging die Freigabeprüfung vollständig, obwohl die generische Route korrekt geschützt war. Der Fix (Commit `74a30fb`/`5504931`) gibt jedem der vier Modulverzeichnisse ein eigenes `layout.tsx`, das denselben `ModuleAccessGate` einbindet: ```ts // apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx export default function DkvFleetLayout({ children }: { children: ReactNode }) { return {children}; } ``` **Regel für neue Module:** Ein neues fest verdrahtetes Modulverzeichnis unter `modules//` braucht **immer** ein eigenes `layout.tsx` mit `ModuleAccessGate`, genau wie sein Backend-Controller `@UseModule('')` braucht. Beide Prüfungen sind unabhängig voneinander — die eine ersetzt nicht die andere; das Frontend-Gate ist Komfort/UX (keine leere Seite ohne Erklärung), das Backend-Gate ist die tatsächliche Zugriffskontrolle. Zusätzlich läuft im Frontend eine dritte, unabhängige Absicherung: `ModuleShell` (`apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx`) lädt die Modul-Komponente nur, wenn ihr Slug in `MODULE_REGISTRY` (`apps/web/src/lib/module-loader.ts`) als lazy-geladenes `dynamic()`-Import gelistet ist — ein beliebiger Slug aus der URL löst sonst keinen Import aus. ### So entsteht ein neues Modul — Walkthrough am Beispiel Domaincheck Domaincheck ist das kleinste vorhandene Modul und eignet sich als Vorlage. Die realen Dateien: **Backend** (`apps/api/src/domaincheck/`): 1. `domaincheck.service.ts` — die eigentliche fachliche Logik. 2. `dto/check-domain.dto.ts` — Validierung des Request-Body per `class-validator`. 3. `domaincheck.controller.ts` — `@Controller('modules/domaincheck')` mit `@UseModule('domaincheck')` auf Klassenebene, ein `@Post('check')`-Handler. 4. `domaincheck.seed.ts` — die `seedDomaincheckModule()`-Funktion mit dem Manifest (`slug`, `name`, `version`, `category`, `description`, `isSystem`). 5. `domaincheck.module.ts` — bindet Controller/Service zusammen, importiert `ModuleRegistryModule`, ruft in `onModuleInit()` den Seed auf. 6. Eintrag des neuen Moduls in `apps/api/src/app.module.ts` unter `imports`. **Frontend** (`apps/web/src/app/(portal)/modules/domaincheck/`): 1. `page.tsx` — die eigentliche Modulseite (Client-Komponente mit den Formular-/Ergebnis-Teilen). 2. `layout.tsx` — `ModuleAccessGate moduleSlug="domaincheck"` um `{children}`. 3. `actions.ts` — Server Actions, die die Backend-Route aufrufen. 4. `components/` — `DomainInput.tsx`, `ResultList.tsx`. 5. Eintrag in `MODULE_REGISTRY` (`apps/web/src/lib/module-loader.ts`) mit dem `dynamic()`-Import auf `page.tsx`. 6. Übersetzungsschlüssel in `apps/web/src/messages/de.json` **und** `en.json` (siehe [Konventionen und Fallstricke](#konventionen-und-fallstricke)). Für ein Modul mit Unterrouten (Einstellungsseite, Verwaltungsansicht) orientieren Sie sich an `dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden. ## Mandantentrennung Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als `APP_GUARD` in `app.module.ts` registriert — er läuft nach `JwtAuthGuard`, weil `req.user` erst dann gesetzt ist. `TenantGuard` liest `tenantId` aus dem JWT-Claim des Anfragenden, erlaubt SUPER_ADMIN einen Wechsel per `x-tenant-id`-Header, und setzt anschließend AUSSCHLIESSLICH `req.tenantId` (260911-e2s). Die Bindung an den Mandanten geschieht dienst-intern, je Service-Methode neu, über das Bindungshilfsmittel `forTenant()` (`apps/api/src/prisma/prisma-tenant.extension.ts`), das vor **jeder** Query in einer Transaktion `SELECT set_config('app.current_tenant', $1, true)` ausführt — der Guard selbst erzeugt keinen Prisma-Client mehr und veröffentlicht keinen auf dem Anfrageobjekt. > Ein früherer Entwurf veröffentlichte zusätzlich einen gebundenen Prisma-Client auf dem > Anfrageobjekt, dupliziert in einer gleichnamigen, nie in `app.module.ts` registrierten > Express-Middleware mit identischer Logik — beides wurde mit 260911-e2s entfernt, nachdem eine > Volltextsuche keinen Leser dieser Eigenschaft außerhalb der beiden Dateien fand. `app.current_tenant` wird von **Postgres Row-Level-Security** ausgewertet. RLS-Policies liegen seit `20260909140000_rls_remaining_tenant_tables` auf 23 Tabellen (4 aus `20260618112133_rls_policies`, 3 aus `20260804130918_groups_rls_policies`, 16 aus der `_rls_remaining_tenant_tables`-Migration selbst — `grep -c "ENABLE ROW LEVEL SECURITY"` über die drei Migrationen, zur Ausführungszeit nachzählen), darunter `FavoriteLink` und `SmtpConfig`. Ohne eigene `tenantId`-Spalte bzw. bewusst plattformweit bleiben `Module`, `Tenant`, `Tender`, `TenderSource` und `TenderSourcePollConfig` (siehe die Bestandsaufnahme in `docs/mandantentrennung-zugriffsklassifikation.md`, Klasse `keine-mandantengebundene-tabelle`, für die vollständige, maschinell geprüfte Liste — von dort ableiten, nicht raten). **Was ein Entwickler nie vergessen darf:** jeder Zugriff auf eine mandantengebundene Tabelle läuft dienst-intern über einen mit `forTenant()` gebundenen Klienten `tenantPrisma` (`apps/api/src/prisma/prisma-tenant.extension.ts`) — `forTenant(prisma, tenantId, userId?)` trägt seit Migration `20260911120000_rls_user_dimension_personal_tables` (Etappe 3b, 260911-nke) einen optionalen dritten Parameter: zehn persönliche Tabellen (CalendarSource, DashboardLayout, FavoriteLink, SearchProvider, TenderEmailConfig, TenderNotificationPref, TenderRssFeedSource, TenderSavedSearch, TenderTriage, WidgetInstance) tragen die Benutzerdimension in der Regel (`current_user_id() IS NULL OR "userId" = current_user_id()`), vier Tabellen mit `userId`-Spalte aber ohne persönliche Daten (GroupMembership, ModuleGrant, PasswordResetToken, TenderMatch) nicht. Nur Nutzer-CRUD-Aufrufer setzen `userId`; Hintergrunddienste und Verwaltungswege rufen weiterhin ohne ihn — das macht die `IS NULL OR`-Form fuer sie wirkungslos, keine Verschlechterung. Die zusätzlichen `where`-Filter über `userId` im Anwendungscode bleiben in JEDEM Fall bestehen — zweites Netz, kein Ersatz (siehe `docs/mandantentrennung-etappe2-fehlerrichtung.md`). Bei den Tabellen ohne eigene `tenantId` (oben) filtert die Anwendung stattdessen — wo relevant — über den zutreffenden Bezug (z. B. plattformweiter Katalog, kein Mandantenfilter nötig); siehe `docs/mandantentrennung-zugriffsklassifikation.md` für den vollständigen Stand je Datei/Modell. Bei den RLS-geschützten Tabellen greift die DB-seitige Absicherung zusätzlich, vorausgesetzt die Query läuft tatsächlich über einen dienst-intern per `forTenant()` gebundenen Client und nicht über den globalen, ungebundenen `PrismaService`. ## Berechtigungen Rollen kommen aus dem Prisma-`enum Role { SUPER_ADMIN, ADMIN, USER }` und stecken im JWT — nie aus Body oder Query-Parametern, sondern ausschließlich `req.user.role`. Rollenschutz auf Controller-Ebene läuft über zwei Dekoratoren (`apps/api/src/auth/decorators/roles.decorator.ts`, `apps/api/src/auth/guards/roles.guard.ts`): ```ts @Roles(Role.ADMIN, Role.SUPER_ADMIN) ``` `RolesGuard` ist ebenfalls global als `APP_GUARD` registriert; ohne `@Roles()`-Metadaten lässt er jede Anfrage durch — die Einschränkung entsteht ausschließlich durch das explizite Setzen des Dekorators auf Handler oder Controller. Für den Modulzugriff kommt das oben beschriebene Grant-Modell hinzu: `Group` (mandantenintern, optional an ein AD-Objekt über `ldapDn`/`ldapObjectGuid` gebunden), `GroupMembership` (Benutzer-zu-Gruppe, `source: MANUAL | LDAP`) und `ModuleGrant` (Gruppe **oder** Benutzer, XOR, kein Rechtestufen-Feld). Die Schreibseite dafür ist `apps/api/src/groups/module-grants.controller.ts`, ausschließlich für ADMIN/SUPER_ADMIN: | Route | Zweck | |---|---| | `GET /module-grants/matrix` | Module × Gruppen-Matrix bestehender Grants | | `GET /module-grants/users/:userId` | Gruppenmitgliedschaften + geerbte/direkte Modulzugriffe eines Benutzers | | `POST /module-grants` | Grant anlegen | | `DELETE /module-grants` | Grant entziehen (Ziel im Body, nicht im Pfad) | ## Datenbank und Migrationen Prisma ist die einzige Zugriffsschicht (`apps/api/prisma/schema.prisma`, Provider `postgresql`). Der Workflow für eine Schemaänderung: 1. `schema.prisma` anpassen. 2. Migration erzeugen (im `api`-Container oder mit Zugriff auf die DB — siehe [Datenbank vom Host erreichen](#datenbank-vom-host-erreichen-wichtig)): ```bash pnpm --filter @tessera/api exec prisma migrate dev --name ``` 3. `prisma generate` läuft automatisch als `postinstall`-Skript von `@tessera/api` (`"postinstall": "test -f prisma/schema.prisma && prisma generate || true"`), muss also nach `pnpm install` nicht separat aufgerufen werden. **Migrationen laufen automatisch beim API-Start.** Das produktive `Dockerfile` (`apps/api/Dockerfile`) setzt als `CMD`: ``` prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js ``` Ein neu gestarteter `api`-Container wendet also jede noch ausstehende Migration selbst an, bevor die Anwendung überhaupt hochfährt — es gibt keinen separaten manuellen Migrationsschritt beim Deployment. RLS-Policies werden nicht von Prisma selbst verwaltet, sondern als reines SQL innerhalb regulärer Migrationsdateien mitgeliefert (`CREATE POLICY ...` in `migration.sql`) — siehe [Mandantentrennung](#mandantentrennung) für den aktuellen Stand, welche Tabellen das betrifft. ## Tests Beide Apps nutzen **Vitest**, aber mit unterschiedlicher Umgebung: - `apps/api` — `environment: 'node'`, sucht `src/**/*.spec.ts`, `passWithNoTests: true`. ```bash pnpm --filter @tessera/api test # einmalig pnpm --filter @tessera/api test:watch # Watch-Modus ``` - `apps/web` — `environment: 'jsdom'` mit `@testing-library/react`, `setupFiles: ['./src/test/setup.ts']`. ```bash pnpm --filter @tessera/web test ``` `pnpm test` im Root führt über Turborepo beide Suiten aus. Es gibt kein separates End-to-End-Test-Setup (kein Playwright-Config im Repository) — Tests sind Unit-/Integrationstests gegen Services, Controller-Logik und React-Komponenten. Guard-artige Spezifikationen wie `module.guard.spec.ts` oder die i18n-Wächter (siehe unten) sind das Vorbild für Regressionsschutz gegen bereits einmal aufgetretene Fehler — wiederkehrende Fallstricke werden in diesem Projekt durch einen Test abgesichert, nicht nur durch einen Kommentar. **Desktop-Modul (`apps/api/src/desktop`):** ```bash pnpm --filter @tessera/api exec vitest run src/desktop ``` Die Tests laufen als echter HTTP-Durchstich über `NestFactory.create()` + `app.listen(0)` gegen ein echtes temporäres Verzeichnis (kein `fs`-Mock) — Manifest lesen, 404 ohne Manifest, Plattform-Whitelist, Pfad-Traversal abgewiesen. **Rust (`apps/desktop/src-tauri`):** ```bash cargo check cargo clippy ``` Beide laufen auch im CI-Job `desktop` (D-16); ein grüner `cargo clippy` ohne Warnungen ist Voraussetzung für den Bauschritt. ## Konventionen und Fallstricke **NestJS-Routenreihenfolge:** NestJS matcht Routen in Deklarationsreihenfolge. Eine statische Route wie `@Get('source-config')` **muss vor** einem `@Get(':id')`-Platzhalter derselben Klasse stehen — sonst interpretiert der Platzhalter den literalen Pfadteil als `id` und "beschattet" die statische Route (404 auf die eigentlich vorhandene Route). Das betrifft ausschließlich denselben HTTP-Verb: ein `GET :id` kann niemals eine `POST`-Route beschatten. `apps/api/src/tenders/tenders.controller.ts` dokumentiert das an jeder betroffenen Stelle explizit im Kommentar (`source-config`, `rss-feeds`, `email-config`, `coverage`, `denylisted-portals`, `triage`, `saved-searches`, `notification-pref` — alle vor `@Get(':id')` deklariert) und `module-grants.controller.ts` hält `matrix` bewusst vor `users/:userId`. **Unit-Tests fangen diesen Fehler nicht** — sie rufen üblicherweise die Handler-Methode direkt auf, nicht den tatsächlichen Routing-Mechanismus. Bei jedem neuen `@Get(':id')`/`@Put(':id')`/`@Delete(':id')` in einem Controller mit weiteren statischen GET-Routen: statische Routen zuerst deklarieren. **Änderungsliste (`CHANGELOG.md`):** Jede Änderung, die Anwender oder Betrieb bemerken, wird sofort im selben Auftrag in `CHANGELOG.md` unter „Unveröffentlicht“ eingetragen — in Alltagssprache für Anwender, Sie-Form, echte Umlaute, gegliedert in „Neu“, „Geändert“ und „Behoben“; keine Dateinamen, keine Commit-Kürzel, keine unerklärten Fachbegriffe. Bei der Freigabe wird der Abschnitt in „X.Y.Z – JJJJ-MM-TT“ umbenannt und darüber ein neues leeres „Unveröffentlicht“ angelegt (siehe Betriebshandbuch Kapitel 9). Die Seite „Was ist neu“ (`apps/web/src/app/(portal)/changelog/page.tsx`) liest den Text zur Bauzeit aus `env.TESSERA_CHANGELOG_MD`, das `apps/web/next.config.ts` aus der Datei befüllt — deshalb steht `COPY CHANGELOG.md ./` im Web-Dockerfile und `!CHANGELOG.md` als Ausnahme in `.dockerignore`. Nur `page.tsx` darf `@/lib/changelog` importieren, damit der Text im Server-Bundle bleibt und nicht in öffentlich abrufbare Client-Chunks gelangt. Die Kanalregel (Live ohne „Unveröffentlicht“, Beta/Entwicklung mit „Noch nicht freigegeben (Beta)“) liegt in `filterChangelogForChannel` (`apps/web/src/lib/changelog.ts`) mit Tests. Beim Tag `vX.Y.Z` schneidet `.gitea/scripts/publish-release.sh` den Abschnitt der Version heraus und legt daraus den Gitea-Release an — fehlt der Abschnitt, bricht dieser CI-Schritt mit Exit 1 ab. **i18n — Schlüsselparität zwischen de.json und en.json:** Jeder benutzersichtbare Text gehört in beide Sprachdateien, `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json`. Ein strukturelle Wächter-Test, `apps/web/src/messages/tenderRadar-parity.spec.ts`, prüft für den `tenderRadar`-Namensraum automatisiert, dass beide Dateien exakt denselben (rekursiv aufgeschlüsselten) Schlüsselsatz besitzen und jeder Blattwert eine nicht-leere Zeichenkette ist — ein Schlüssel, der nur in einer Sprache ergänzt wird, lässt den Test fehlschlagen. Zusätzlich prüft `apps/web/src/messages/umlaut-guard.spec.ts` ausschließlich das geparste JSON von `de.json`/`en.json` gegen ein Wörterbuch aus `umlaut-dictionary.ts`: keine ae/oe/ue/ss-Ersatzschreibweise („fuer“, „loeschen“) darf mehr vorkommen, außer sie steht auf einer Allowlist korrekter deutscher Wörter, die zufällig `ae/oe/ue/ss` enthalten (z. B. „Passwörter“, „ausschließen“). Beide Wächter lesen bewusst nur das geparste JSON, nie den Quellcode-Baum — ein repo-weiter Grep würde am Wörterbuch selbst scheitern, weil dessen Schlüssel notwendigerweise die falschen Schreibweisen enthalten. **Tailwind 4 — der `dark:`-Selektor muss explizit an `.dark` gebunden werden:** Tailwind 4 bindet `dark:` standardmäßig an `prefers-color-scheme`, also an die Betriebssystem-Einstellung. Tessera schaltet den Modus aber über `next-themes` mit `attribute="class"` um — der Benutzer wählt hell/dunkel im Portal, unabhängig vom System. `apps/web/src/app/globals.css` bindet den Selektor deshalb explizit an die `.dark`-Klasse: ```css @custom-variant dark (&:where(.dark, .dark *)); ``` Fehlt diese Zeile, schalten die Farbtoken unter `.dark` weiter unten in derselben Datei zwar korrekt um, aber **jede einzelne `dark:`-Utility im Quellcode bleibt wirkungslos**, sobald System- und Portal-Einstellung nicht zufällig übereinstimmen. Der Fehler fällt dabei nicht sofort auf, weil Hintergrund- und Textfarbe über die CSS-Variablen laufen, nicht über `dark:`-Utilities — die Oberfläche wird also grundsätzlich dunkel, nur Feinheiten (Status-, Warn- und Fehlerfarben, Hinweisboxen, Badges, wie im Projekt bereits an über 100 Stellen betroffen) bleiben falsch. Jede neue `dark:`-Utility-Klasse im Projekt setzt voraus, dass diese Zeile in `globals.css` unverändert bleibt.