8d1c8f320b
- overrides fuer Testdateien (noExplicitAny off) und apps/api (useImportType off, siehe Regelbeschreibung "Caveat with TypeScript experimental decorators") - Fixture-Ausnahme in files.includes ohne angehaengten Doppelstern (Biome-Hinweis useBiomeIgnoreFolder befolgt) - Entwickleranleitung auf den neuen Stand gebracht: 754 Befunde (633 echt, 121 Test), beide Ausnahmen begruendet, Folgeaufgaben benannt Rueckstand: 2923 -> 754, Fehlerstufe weiterhin 0. security-Regelgruppe unangetastet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
599 lines
33 KiB
Markdown
599 lines
33 KiB
Markdown
<!-- 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/ @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 der
|
||
Konfigurationsbereinigung steht der Rückstand bei 754 Warnungen (633 in echtem Quelltext, 121 in
|
||
Testdateien), Fehlerstufe weiterhin 0 — vorher waren es 2923. Als eigene, benannte Folgeaufgaben
|
||
stehen noch aus: ein maschineller Durchgang über die verbleibenden mechanischen Regeln plus toter
|
||
Code, und danach ein von Hand gepflegter Durchgang über die Barrierefreiheits-Befunde in `apps/web`.
|
||
`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 <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.
|
||
|
||
### 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 <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`) — `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 <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.
|
||
|
||
**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.
|