- Betriebshandbuch: neues Kapitel 10 (Pipeline-Herkunft, Ablageort im Abbild, Release-Anhaenge, DESKTOP_DIST_DIR, Fehlerbilder-Tabelle); Kapitel 9 um Satz zu Desktop-Paketen im Freigabe-Tag ergaenzt - CI/CD-Runbook: aus drei werden vier Jobs, Job desktop ausfuehrlich beschrieben (Cross-Bau, Cache-Reihenfolge, Cache-vs-upload-artifact- Begruendung), Fehlerbehebung um drei Unterabschnitte ergaenzt (Job desktop, cache miss, Release-Upload 413) - Entwicklungshandbuch: apps/desktop ist kein Grundgeruest mehr, neuer Abschnitt "Desktop-App lokal bauen", Testabschnitt um vitest src/desktop und cargo check/clippy ergaenzt Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
29 KiB
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
- Aufbau des Monorepos
- Lokale Entwicklungsumgebung
- Architektur im Überblick
- Das Modulsystem
- Mandantentrennung
- Berechtigungen
- Datenbank und Migrationen
- Tests
- 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. 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 (
packageManagerinpackage.jsonpinntpnpm@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 isttessera_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 mitopenssl 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), Port3000 - api — NestJS mit Watch-Modus (
nest start --watch), Port3001 - db — Postgres 16 (Alpine), ohne Host-Port
- mailhog — SMTP-Testserver, UI auf
8025, SMTP auf1025 - openldap / phpldapadmin — für LDAP-Sync-Entwicklung, Ports
389/636bzw.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
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/. Um es wie die
API es ausliefern würde einzusammeln:
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.
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):
- Eine Server- oder Client-Komponente unter
apps/web/src/app/(portal)/...ruft die API überfetchgegenNEXT_PUBLIC_API_URL(Browser) bzw.API_INTERNAL_URL(Server-Komponenten, zeigt intern aufhttp://api:3001) auf. - Die Anfrage trifft in
apps/api/src/main.tsauf die globaleValidationPipeund läuft dann durch die drei global registriertenAPP_GUARDs ausapp.module.ts, in genau dieser Reihenfolge:JwtAuthGuard(Auth) →TenantGuard(setztreq.tenantIdaus dem JWT) →RolesGuard(prüft@Roles()). - Trägt der Controller zusätzlich
@UseModule('slug'), prüft anschließendModuleGuard(apps/api/src/module-registry/module.guard.ts) Modulzugriff überModuleAccessService. - 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 perforTenant()gebundenen Client auf Postgres zugreift. - 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:
- 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. - Grant pro Gruppe oder Benutzer (
ModuleGrant) — erst wenn das Modul aktiv ist, entscheidet ein Grant, wer es tatsächlich sieht.ModuleGrantträ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/):
domaincheck.service.ts— die eigentliche fachliche Logik.dto/check-domain.dto.ts— Validierung des Request-Body perclass-validator.domaincheck.controller.ts—@Controller('modules/domaincheck')mit@UseModule('domaincheck')auf Klassenebene, ein@Post('check')-Handler.domaincheck.seed.ts— dieseedDomaincheckModule()-Funktion mit dem Manifest (slug,name,version,category,description,isSystem).domaincheck.module.ts— bindet Controller/Service zusammen, importiertModuleRegistryModule, ruft inonModuleInit()den Seed auf.- Eintrag des neuen Moduls in
apps/api/src/app.module.tsunterimports.
Frontend (apps/web/src/app/(portal)/modules/domaincheck/):
page.tsx— die eigentliche Modulseite (Client-Komponente mit den Formular-/Ergebnis-Teilen).layout.tsx—ModuleAccessGate moduleSlug="domaincheck"um{children}.actions.ts— Server Actions, die die Backend-Route aufrufen.components/—DomainInput.tsx,ResultList.tsx.- Eintrag in
MODULE_REGISTRY(apps/web/src/lib/module-loader.ts) mit demdynamic()-Import aufpage.tsx. - Übersetzungsschlüssel in
apps/web/src/messages/de.jsonunden.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.
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.tsregistrierten 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:
schema.prismaanpassen.- 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> prisma generateläuft automatisch alspostinstall-Skript von@tessera/api("postinstall": "test -f prisma/schema.prisma && prisma generate || true"), muss also nachpnpm installnicht 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', suchtsrc/**/*.spec.ts,passWithNoTests: true.pnpm --filter @tessera/api test # einmalig pnpm --filter @tessera/api test:watch # Watch-Modusapps/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 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:
@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.