Files
tessera-ctl/docs/anleitung-entwicklung.md
T
schalli 12409322f5 docs(quick-260911-gwh): Etappe 2 auf Endstand bringen -- sechster Fall, Befund K erfuellt, Ledger, Anleitung
- .planning/WINDOWS.md: drei neue offene Eintraege (#30 Startpfad des
  Mailmoduls, #31 verschluckte Leere favorites, #32 verschluckte Leere
  settings); Platzhalter WINDOWS #TBD-GWH in settings.service.ts und
  mail.module.ts durch #30 ersetzt
- docs/mandantentrennung-zugriffsklassifikation.md: Uebersichtszeilen
  favorites (0/8, war 7/0) und settings (1/3, war 4/0) neu gemessen;
  Summenzeile 68/178 (Endstand Etappe 2); Bestandsaufnahme (favoriteLink
  gebunden, smtpConfig gemischt, neue Zeile widgetInstance/gebunden);
  Klassen-Verteilung 65 Paare (33/17/13/2); Hintergrunddienst-Abschnitt mit
  sechstem Fall (mail.module.ts, WINDOWS #30) und erfuellter
  Befund-K-Bedingung; neuer Punkt in "Was diese Etappe NICHT entscheidet"
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtraege unter Befund
  K in (t4) und im Uebergaben-Absatz von (d4) -- Reihenfolgebedingung
  erfuellt; ## Etappe 2 -- Abschluss mit den derivierten Endzahlen
- docs/anleitung-entwicklung.md: 23 RLS-Tabellen statt sieben, FavoriteLink
  nicht mehr als Tabelle ohne Regel, tenantPrisma statt manuellem
  tenantId-Filter als gelebter Stil
- rls-access-inventory.spec.ts wieder gruen (11/11), volle Suite 994/994,
  Werkzeug 137/137; zwei Dokument-Falsifizierungen durchgefuehrt und
  zurueckgenommen (siehe SUMMARY)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 14:05:06 +02:00

459 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# 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/ — — Tauri-Wrapper, früher Stand (nur Cargo-Projekt + eine setup.html)
packages/
shared/ @tessera/shared — geteilte Konstanten/Typen, derzeit sehr klein (APP_NAME, HealthResponse)
module-sdk/ — — TypeScript-Interfaces für den Modul-Vertrag (TesseraModule, ModuleManifest)
```
`apps/desktop` besteht bislang nur aus dem Tauri-Grundgerüst (`src-tauri/`) und einer einzelnen
`setup.html` — dort ist noch keine eigentliche Anwendung zu finden. `packages/shared` ist ebenfalls
minimal; es enthält aktuell nur eine Konstante und ein Health-Interface, keine DTOs.
**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. 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 <db-container-name> \
| 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@<container-ip>: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.
## 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 <ModuleAccessGate moduleSlug="dkv-fleet">{children}</ModuleAccessGate>;
}
```
**Regel für neue Module:** Ein neues fest verdrahtetes Modulverzeichnis unter `modules/<slug>/`
braucht **immer** ein eigenes `layout.tsx` mit `ModuleAccessGate`, genau wie sein Backend-Controller
`@UseModule('<slug>')` 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`) — die zusätzlichen `where`-Filter über
`userId` bleiben bestehen, wo die Regel selbst keine Benutzerdimension kennt (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 <beschreibender-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.
## 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.
**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.