Files
tessera-ctl/docs/anleitung-entwicklung.md
T
schalli cb45d2663a docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik
- Unveroeffentlicht: Hintergrundwahl (Neu), neues Aussehen (Geaendert), Resize-Fehler (Behoben)
- Anwender-Anleitung: App-Leiste, Seitenleiste, Anmeldeseite, Befehlsleiste, Hintergrund, Kalender
- Entwickler-Anleitung: User.dashboardBackground, PATCH /users/me/dashboard-background, parseDashboardBackground

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:18:51 +02:00

43 KiB
Raw Permalink Blame History

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
  2. Lokale Entwicklungsumgebung
  3. Architektur im Überblick
  4. Das Modulsystem
  5. Mandantentrennung
  6. Berechtigungen
  7. Datenbank und Migrationen
  8. Tests
  9. 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 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 Dockerfiles 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

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:

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
  • Auf Ubuntu/Debian folgende Systempakete:
    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:

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:

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:

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.

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_GUARDs 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:

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:

@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:

// 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).

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.

Ein Modul mit Fremdsystem-Zugängen und Hintergrundabfrage: proxmox (260923-dhh) ist die Vorlage dafür — mehrere verschlüsselte Fremdsystem-Zugänge je Mandant (ProxmoxServer, Vorbild CalendarSource, nicht DkvModuleConfig), ein Zwischenlager, das ein Hintergrunddienst beschreibt und das die Modulseite ausschließlich liest (ProxmoxServerStatus), sowie ein Planer, der onApplicationBootstrap statt onModuleInit nutzt und je Mandant einen eigenen Cron-Auftrag registriert (proxmox-scheduler.service.ts, kombiniert die Muster von DkvSchedulerService und TenderSchedulerService).

Die undici-Dispatcher-Falle unter Node 24: wer aus Gewohnheit das globale fetch statt import { fetch as undiciFetch } from 'undici' verwendet, bekommt beim Kompilieren KEINEN Fehler, sondern eine zur Laufzeit STILLSCHWEIGEND ignorierte dispatcher-Option — ein selbstsigniertes Zertifikat wird dann trotz bewusst abgeschalteter Prüfung weiterhin abgelehnt, was beim ersten Test verwirrend aussieht, als sei die Datenbank-Einstellung falsch gelesen worden. Node 24 bündelt intern eine eigene undici-Kopie, die vom global gepatchten fetch verwendet wird — ein Agent aus dem npm-Paket undici ist eine ANDERE Klasse und wird von diesem globalen fetch ignoriert. Gemessen und dokumentiert in apps/api/src/favorites/icon-discovery.service.ts:33-40 (erstes Auftreten) und in apps/api/src/proxmox/proxmox-client.service.ts (zweites, unabhängig davon konstruiertes Auftreten mit demselben Befund).

Eine Kachel zum Modul

Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. Seit quick-260922-m1h sind dafür drei Stellen nötig (vorher waren es sieben):

  1. Die Kachel-Komponente schreiben — apps/web/src/components/dashboard/widgets/<name>-widget.tsx, nimmt die WidgetProps aus widget-registry.tsx (instanceId, config, isEditMode) entgegen.
  2. Den Typ eintragen — in WIDGET_TYPES in packages/shared/src/index.ts. Gehört die Kachel zu einem Modul, zusätzlich WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>' in derselben Datei. Das ist die einzige Liste: die API validiert POST /dashboard/widgets per @IsIn gegen genau sie, und das Frontend leitet Registry und Katalog davon ab.
  3. Anmelden — registerWidget('<typ>', <Name>Widget) in apps/web/src/app/(portal)/page.tsx, neben den übrigen Aufrufen. Der Aufruf steht dort und nicht in der Registry, weil die Kachel über den Wrapper wieder die Registry importiert — ein Import aus der Registry heraus wäre ein Zirkelimport.

Dazu kommen wie bei jeder Oberfläche die Übersetzungsschlüssel (<typ>.name und <typ>.description unter widgets in de.json und en.json), ein Symbol als Inline-SVG und die Größenvorgaben in WIDGET_CONSTRAINTS (minW/minH = kleinste noch bedienbare Kachel, defaultW/defaultH = Startgröße) — beides in widget-registry.tsx. Ein Test in widget-registry.test.tsx prüft, dass WIDGET_TYPES, Registry und Constraints deckungsgleich sind; vergisst man eine Stelle, schlägt er fehl.

Was eine Kachel mit moduleSlug automatisch tut: Sie verschwindet für Benutzer, die das Modul nicht nutzen dürfen — aus dem Katalog („Widget hinzufügen", visibleWidgetTypes) und aus dem Dashboard selbst. Ist die Modulliste unbekannt, weil ihr Abruf fehlschlug, bleibt die Kachel ebenfalls verborgen (fail-closed). Eine bereits angelegte Kachel eines gesperrten Moduls rendert nicht mehr leer, sondern zeigt den Hinweis widgets.unavailable.

Wie beim Modul-Gate gilt auch hier: Der Katalogfilter ist Komfort, nicht Zugriffskontrolle. Die verbindliche Prüfung sitzt serverseitig in DashboardService.getWidgets() (filtert Kacheln gesperrter Module fail-closed aus GET /dashboard/widgets) — und die Daten, die eine Modul-Kachel anzeigt, holt sie über die Endpunkte ihres Moduls, die @UseModule('<slug>') tragen müssen.

Erstes echtes Beispiel: Proxmox (quick-260924-i8v). Die Kachel steht in apps/web/src/components/dashboard/widgets/proxmox-widget.tsx, ihre reinen Modellfunktionen (resolveProxmoxWidgetConfig, selectServers, healthSummary, widgetKeyFigure) in proxmox-widget-model.ts daneben; WIDGET_MODULE_SLUGS trägt proxmox: 'proxmox'. Die Statuslogik, die Modulseite und Kachel teilen (proxmox-status.ts samt formatPercent/formatCount, HealthBar.tsx mit variant="compact", status-styles.ts, das Auswahl-Bauteil proxmox-server-picker.tsx), liegt seit 260924-i8v unter apps/web/src/components/proxmox/ — eine Kachel unter components/ greift so nicht in einen Routenordner unter app/. Das Einstellungsformular für Einstellungen > Dashboard ist components/settings/proxmox-widget-config-form.tsx. Zwei Regeln, die für jede weitere Modul-Kachel gelten: Die Kachel liest nur Daten, die das Modul ohnehin vorhält (hier das Zwischenlager über GET /modules/proxmox/servers), und löst nie selbst eine Abfrage beim Fremdsystem aus — ein Minutentakt je Kachel und Benutzer wäre sonst eine Last auf den Proxmox-Hosts. Und Zeilen, die im Ansichtsmodus Links sind, werden im Bearbeitungsmodus zu schlichten Elementen ohne Ziel: Links stehen im Abbruch-Selektor von dashboard-grid.tsx, eine Kachel aus Links ließe sich sonst kaum noch ziehen.

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):

@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):
    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 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.
    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'].
    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):

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):

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 Server-Code darf @/lib/changelog importieren: page.tsx und die 'use server'-Datei apps/web/src/lib/release-notice-actions.ts — nie eine Client-Komponente, auch nicht apps/web/src/lib/release-notes.ts (dessen Typen nutzen Client-Komponenten). So bleibt der Text im Server-Bundle und gelangt nicht in öffentlich abrufbare Client-Chunks. 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.

„Was ist neu“-Fenster nach einem Versionswechsel (quick-260925-bow): Beim ersten Laden des Portal-Rahmens nach einem Versionswechsel zeigt ReleaseNoticeHost (in AppShell, also nie auf der Anmeldeseite; nicht auf /change-password) einmal ein Fenster mit den Gruppen Neu / Geändert (angezeigt als „Verbessert“) / Behoben der verpassten Versionen, höchstens drei, neueste zuerst. Einzige Quelle der laufenden Version ist APP_VERSION der API, gelesen über getRunningRelease() in apps/api/src/health/app-version.ts — nicht NEXT_PUBLIC_APP_VERSION des Webs. Grund: der LDAP-Abgleich (Zeitplan) und der Erst-Administrator (API-Start) legen Benutzer ohne jede Web-Anfrage an und tragen dabei die laufende Version ein; ebenso prüft der Merk-Endpunkt gegen diesen Wert. Endpunkte: GET /users/me/release-notice liefert currentRelease und den gemerkten Stand; POST /users/me/release-seen mit { version } nimmt nur die kanonische Form X.Y.Z an, die nicht über der laufenden Version liegt, senkt einen gemerkten Stand nie ab und schreibt ausschließlich die eigene Zeile (forTenant(), where: { id: currentUser.id }). Gemerkt wird erst beim Schließen, nie beim Öffnen. Spalte User.lastSeenReleaseVersion: null = Bestandsbenutzer, dann zeigt das Fenster nur die laufende Version; UserService.create() und der Admin-Seed tragen bei der Anlage die laufende Version ein. parseReleaseVersion/compareReleaseVersions stehen einmal in packages/shared/src/index.ts (Laufzeit-Import in API und Web). Die Auswahl der Abschnitte macht selectReleaseNotice in apps/web/src/lib/release-notes.ts. Folge für die Freigabe: erst ein Tag vX.Y.Z (auf der Beta dessen Describe-Stand vX.Y.Z-N-g<sha>, gekürzt auf X.Y.Z) löst das Fenster aus; Punkte unter „Unveröffentlicht“ erscheinen darin nie. Lokal steht APP_VERSION auf dev — dann erscheint nie ein Fenster; zum Ausprobieren beim Bau --build-arg APP_VERSION=1.4.0 setzen. Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht kein Fenster und nichts wird gemerkt.

Dashboard-Hintergrund pro Benutzer (quick-260928-ujj): Die Hintergrundwahl des Designs „Mosaik“ steht in der Spalte User.dashboardBackground (JSONB, Migration 20260928120000_user_dashboard_background): null = nie gewählt, sonst ein normalisiertes Objekt { kind: 'none' }, { kind: 'preset', id } oder { kind: 'image', imageId }. Geschrieben wird nur über PATCH /users/me/dashboard-background mit { background } — ohne Kennungsparameter, ausschließlich die eigene Zeile (forTenant(), where: { id: currentUser.id }); ein ungültiger Wert ergibt 400 und schreibt nichts. Gelesen wird der Wert mit der Sitzungsantwort (GET /auth/me, AuthService.getMe, neben accentColor) und landet über die setUser-Abbildung in header.tsx im Auth-Store; useDashboardBackground() in apps/web/src/components/dashboard/dashboard-background.tsx liest von dort und speichert über updateDashboardBackgroundAction (optimistisch, bei Fehlschlag zurückgesetzt). Einzige Prüfregel ist parseDashboardBackground in packages/shared/src/index.ts (Laufzeit-Import in API und Web, angewendet beim Schreiben UND beim Lesen): kind und die Preset-Kennungen (DASHBOARD_BACKGROUND_PRESET_IDS) aus einer festen Liste, imageId nur als UUID, weil das Web den Wert als CSS-Hintergrund url("...") rendert. Wer ein neues Preset ergänzt, trägt es dort und in BACKGROUND_PRESETS (apps/web/src/lib/dashboard-background.ts) ein — ein Test prüft, dass beide Listen deckungsgleich sind. Der frühere localStorage-Schlüssel tessera.dashboardBackground.<userId> wird von takeLegacyDashboardBackground einmal gelesen, entfernt und nur übernommen, wenn der Server-Wert noch null ist.

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:

@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.