Änderungsprotokoll
Was ist neu in SW deck
> **Stand 2026-07-27, `4b74929` auf `main`:** deployed, CI-Volllauf **442
> passed / 24 skipped / 0 failed / 0 flaky** in 3,6 min, Unit-Tests 802/0.
> Production verifiziert über `/api/status`: `commitSha` passend,
> `schemaDrift: clean`. Die 442 gehen auf: 439 plus die 3 neuen
> USt-Guard-Tests.
>
> **Zu den flakigen Tests, damit die Zahl nicht falsch gelesen wird:** der
> Lauf davor (`bd08316`) hatte 441 + 1 flaky — dieselbe Menge, nur anders
> verteilt. Flaky war dort `files.e2e.ts:13`, **nicht** der bekannte
> Kalendertest (`task03-kalender.e2e.ts:64`); im Folgelauf war
> `files.e2e.ts:13` wieder grün. Der Kalendertest war jetzt dreimal in Folge
> im ersten Versuch grün. Beide bleiben offen: bei einer Race-Condition sagt
> ein grüner Lauf nur, dass das Rennen diesmal gewonnen wurde. HANDOFF § 2
> führt beide mit Reproduktionsbefehl.
Added — Stufe 3a: USt-Guard + Kleinunternehmer-Pfad (2026-07-27)
**Rechnungserzeugung ist gesperrt, bis die Umsatzsteuer-Lage der Organisation
bestätigt ist** (`smallBusinessExempt === true`). Umgelegt wird ausschließlich
manuell über den Schalter in Einstellungen → Zahlungen; es gibt keinen
Auto-Flip nach Datum oder Umsatzgrenze.
Der Grund ist § 14 Abs. 4 Nr. 8 UStG: das Portal kennt bis Stufe 3b keinen
Steuersatz. Für eine regelbesteuerte Organisation entstünde eine Rechnung, der
ausgerechnet die Angabe fehlt, die sie rechtsgültig macht — und sie sähe
vollständig aus (Aussteller, Steuernummer, IBAN, fortlaufende Nummer).
`createUploaded`, `createMirrorInvoice`. **Bewusst nicht im gemeinsamen
Snapshot-Helper:** `createMirrorInvoice` ruft den gar nicht auf (es bekommt
`issuerDefaults` vom `SubscriptionsService`), wäre dort also nicht gedeckt —
und das ist ausgerechnet der unbeaufsichtigte Pfad aus dem Stripe-Webhook.
Die frühere Handoff-Notiz „alle drei nutzen denselben Helper" war falsch.
glauben — sonst könnte ein Aufrufer die Sperre mit einem präparierten Wert
umgehen. Festgenagelt durch einen eigenen Test.
gegenstandslos: `handleWebhookEvent` fängt jede Exception, schreibt sie als
`result` in `stripeEvent` und kehrt normal zurück — Stripe bekommt 200, das
Event gilt als verarbeitet. Der Mirror-Pfad loggt die Ablehnung zusätzlich
als `error`, weil dort eine erfolgte Zahlung ohne Rechnungsdokument
zurückbleibt und das jemand sehen muss.
Nummer aus dem fortlaufenden Kreis verbraucht — eine Lücke in der
Nummernfolge muss man erklären können.
Netto / 0 % / Gesamt plus den §19-Hinweis. Der **Wortlaut ist ein
Platzhalter** mit `TODO(legal)` und steht zentral als
`SMALL_BUSINESS_NOTICE_DE` in `@atrium/shared` — drei Literale wären
garantiert auseinandergelaufen.
auf `InvoiceLineItem`, `country` / `vatId` / `vatIdValidatedAt` auf
`ClientProfile`. Alle nullable, kein Default. Sie existieren, damit der
Wechsel zur Regelbesteuerung später keine Umstrukturierung von Datenmodell
und Rechnungsstruktur erzwingt. `taxRate` ist **Int in Basispunkten**
(1900 = 19 %), nicht Decimal — der Prisma-Decimal-Typ zieht
`@prisma/client/runtime/library` in die Controller-Signaturen und lässt den
API-Build an TS2742 scheitern.
Produktivcode setzt `smallBusinessExempt` auf `true`, und der Prisma-Default
bleibt `false`. Beide Hälften waren gegen einen eingefügten Defekt
nachweislich rot.
nach Bestätigung, keine verbrauchte Nummer). `global-setup.ts` setzt das Flag
für das Suite-Konto — ohne das bekäme jeder `POST /invoices` der Suite ein
403. Verifiziert: 94 + 14 Tests lokal grün.
**Noch nicht gebaut (Stufe 3b, fällig BEVOR `smallBusinessExempt` je auf
`false` geht):** Regelbesteuerung, Reverse-Charge § 13b mit qualifizierter
BZSt-Abfrage, Drittland nach § 3a Abs. 2, ZM-Export.
Fixed — Dark Mode: Links und Icons waren kaum lesbar (2026-07-27)
Aufgefallen bei der ersten echten Nutzung der Instanz durch Felix. Die
Marken-Teal `--primary` (#006b68) hat als **Schriftfarbe** auf dem dunklen
Hintergrund nur **3,1:1** — WCAG AA verlangt 4,5:1 für Text. Als
**Flächen**farbe mit weißer Schrift ist dieselbe Farbe unauffällig (6,4:1).
Der Defekt betraf also ausschließlich Text und Icons.
`--primary` bleibt die Flächenfarbe. 64 `text-[var(--primary)]` umgestellt,
die 117 `bg-[var(--primary)]` bewusst unangetastet — überwiegend
`hover:underline`-Links, also genau das, was Felix gemeldet hatte.
Der Wert wird **abgeleitet statt hartkodiert**:
`oklch(from var(--primary) 0.78 c h)` hebt nur die Helligkeit an und lässt
Sättigung und Farbton in Ruhe. Ein fester Hex-Wert wäre nur für das
Default-Teal richtig gewesen — die Branding-Farbe ist pro Organisation frei
setzbar. Gemessen: #006b68 → #75c8c4 = 10,2:1 auf #0a0a0a, 9,0:1 auf Karten;
Gegenprobe mit Rot, Blau, Violett und Koralle: 6,6:1 bis 7,6:1.
Der Selektor lautet `:root, [style*="--primary"]` und nicht nur `:root`:
die Layouts von Dashboard, Portal und Setup setzen `--primary` als
**Inline-Style** fürs Org-Branding, und ein Inline-Style schlägt jede
`:root`-Regel. Eine Ableitung allein auf `:root` wäre ausgerechnet in den
beiden Bereichen wirkungslos geblieben, in denen die Links sitzen.
Davor steht eine `color-mix`-Zeile als Fallback für Browser ohne relative
Farbsyntax (Firefox < 128); beide Varianten erfüllen AA.
helle `--foreground` und standen hellgrau auf weiß. Drei davon waren
Einladungslinks — die Stellen, an denen man den Text ablesen und kopieren
muss. Das Signatur-Canvas bleibt bewusst weiß, es wird als Bild gespeichert.
gegen beide Defekte nachweislich rot) und `e2e/tests/dark-mode-kontrast.e2e.ts`,
das den Kontrast im Browser **ausrechnet**, statt einen Hex-Wert zu erwarten.
Changed — Solo-Ballast aus der Oberfläche entfernt (2026-07-27)
Aus derselben Durchsicht. In allen Fällen bleiben Komponenten, API-Routen und
Schema unangetastet — dieselbe Linie wie bei den bestehenden Feature-Flags,
damit der Upstream mergebar bleibt.
Flag: `features.timeTracking: false` blendete Reports und Time-Tab aus, die
Eingabefelder blieben stehen. Ein Stundensatz ohne Zeiterfassung hat keine
Wirkung. Jetzt hinter demselben Flag.
ist das SaaS-Abo von Atrium selbst (Free/Pro/Lifetime in Dollar) und auf
einer selbst gehosteten Einzelinstanz sinnlos; der Tab zeigte nur „nicht
konfiguriert". Die Route bleibt erreichbar. Nicht zu verwechseln mit
„Zahlungen", wo die Rechnungen an die eigenen Kunden konfiguriert werden.
Einzelinstanz auf `deck.sw-labs.de`, es gibt keine zweite Domain für ein
CNAME, und die Funktion hing zusätzlich am Pro-Plan desselben toten
SaaS-Billings.
„Zahlungen" — die Seite rendert einen eigenen Header, `IssuerSection` bringt
denselben mit. Wrapper-Header entfernt. Dazu Tippfehler „Steuuernummer"
korrigiert.
Fixed — Production war nicht in Betrieb nehmbar (2026-07-27)
Beim Audit des Live-Systems fiel auf, dass die Production-Datenbank **43
Tabellen mit je 0 Zeilen** hat: kein User, keine Organisation. Seit dem
Deploy am 8. Juli. Der Grund war kein Versäumnis, sondern ein Bug im einzigen
Weg hinein.
`OnboardingController` las die Base-URL aus `BETTER_AUTH_URL` und fiel sonst
auf `http://localhost:3001` zurück; `AuthService` löste dagegen
`API_URL ?? BETTER_AUTH_URL` auf und setzte genau das in `trustedOrigins`.
In Production ist `API_URL` gesetzt und `BETTER_AUTH_URL` nicht — der
Controller schickte also `Origin: http://localhost:3001` in Requests, deren
`trustedOrigins` nur `https://deck.sw-labs.de` enthielt.
Better Auth prüft den Origin, sobald ein Request ein Cookie trägt, per
strikter Gleichheit. `sign-up/email` läuft ohne Cookie und legte den User
an; `organization/create` und `set-active` tragen eins und liefen in ein
403 INVALID_ORIGIN. Ergebnis: HTTP 207, Konto vorhanden, Organisation
nicht — und `/setup` konnte das nicht auffangen, weil der `SetupController`
durchgängig `@CurrentOrg` verlangt.
**Lokal und in CI war der Fehler unsichtbar**, weil `API_URL` dort
`http://localhost:3001` ist, also identisch mit dem Fallback. Er konnte
ausschließlich in Production auftreten. Die Auflösung liegt jetzt in
`common/helpers/auth-base-url.ts`; ein Architektur-Test scannt den
Quellbaum und meldet jeden Lesezugriff auf `BETTER_AUTH_URL` außerhalb
dieses Helpers.
Fixed — Stille Fehlschläge und Entwurfs-Rechnungen im Portal (2026-07-27)
`DocumentViewer` hatte `try/finally` ohne `catch`, der Aufrufer verschluckte
den Fehler in `console.error`. Klickte ein Kunde auf „Annehmen" und der
Request schlug fehl, wurde der Button kurz inaktiv und dann wieder normal —
ununterscheidbar vom Erfolgsfall. Auf derselben Seite meldete der zweite
Antwortpfad (Bestätigungsdialog) immer korrekt.
`draft`/`cancelled`, `findOneMine` nicht — also die Detail- und die
PDF-Route. Der Portal-Kalender lieferte dazu genau die nötigen IDs.
Zusammen eine vollständige Kette. Die Regel liegt jetzt in
`invoices/invoice-visibility.ts`; der Admin-Kalender zeigt Entwürfe weiter.
tauschte nur `user.id` und ließ `member.role` auf `owner`/`admin` — jeder
rollenabhängige Endpunkt lieferte weiter die volle Org-Sicht. Ausgerechnet
die Funktion, mit der man die beiden Lecks oben hätte finden können.
`formatCurrency(invoiceStats?.outstandingAmount ?? 0)` zeigte „0,00 €" als
offenen Rechnungsbetrag, wenn der Request fehlschlug. Dazu zwei Seiten, die
nie fertig luden (Zeitbericht, Admin-Projektdetail) — letztere mit exakt dem
Bug, den die Portal-Seite bereits behoben und im Code kommentiert hatte.
`/dashboard/settings/account?reason=…#billing` — eine Seite ohne
Billing-Inhalt, ohne `id="billing"` und ohne Auswertung von `reason`.
Fixed — Härtung: CSP, Fehlerseiten, e2e-Trigger (2026-07-27)
setzte als einzige Direktive `frame-src`; live gemessen fehlten
`default-src`, `script-src`, `frame-ancestors` und `X-Frame-Options`
vollständig. Die API liefert dagegen eine komplette Helmet-CSP — der Schutz
saß auf den JSON-Antworten und fehlte auf den HTML-Seiten. `/portal/sign/
[token]`, die Vertragsunterschrift, war framebar.
`error.tsx` und `global-error.tsx` fehlten komplett; letzteres meldet jetzt
auch React-Render-Fehler an Sentry.
war vollständig crawlbar, und Web Push war ohne Manifest nur zur Hälfte
nutzbar.
`pull_request`, während `CLAUDE.md` „direkter Merge, kein PR" vorschreibt.
432 Tests hingen an einem Trigger, den der eigene Prozess umgeht. Jetzt
zusätzlich `push: main` — bewusst ohne Deploy-Gate, siehe Kommentar im
Workflow.
Fixed — e2e-Suite erstmals grün (2026-07-25)
`data.message || t.…` steht an 15 Stellen; die englische API-Meldung ist
immer gesetzt und gewinnt deshalb ausnahmslos — der deutsche Text war
überall toter Code. Im **Kundenportal** stand dadurch „Project not found"
statt der deutschen Meldung. Behoben an den drei durch Tests belegten
Stellen: Portal-Projektdetail, Login (Better Auth liefert „Invalid email or
password"; fehlgeschlagene Anmeldungen werden bewusst nicht weiter
aufgeschlüsselt) und Reset-Password (lokalisiert jetzt über den stabilen
`code`, nicht über den Meldungstext). Die übrigen 12 Fundstellen stehen im
Backlog #7.2.
das Endlos-Skelett ersetzt hat. Ein Fix hat den nächsten Fehler freigelegt.
Changed — Abnahmelauf nach CI verlagert
Der e2e-Volllauf läuft nicht mehr lokal. `test.yml` triggert jetzt zusätzlich
per `workflow_dispatch`; dort laufen frische Postgres, frische Server, keine
parallelen Edits — und `STORAGE_PROVIDER: local`, der Production-R2-Bucket
wird also nicht angefasst.
Der Grund ist gemessen, nicht vermutet. Gleicher Code, drei Umgebungen:
| Lauf | failed | passed | Laufzeit |
|---|---|---|---|
| Lokal, mit Edits während des Laufs | 139 | 302 | 44,3 min |
| Lokal, ohne Edits | 56 | 385 | 28,7 min |
| CI | 16 | 425 | 27,4 min |
40 der 56 lokalen Failures kamen von Serverneustarts (`nest --watch` nach
einem Edit um 21:47:46) und einem Next-Worker-Crash bei erschöpftem Swap
(22:06:14, Swap auf 2,6 von 32 GB) — nicht vom Code. Die Suite läuft mit
`workers: 1` über eine halbe Stunde gegen genau eine API- und eine
Web-Instanz; jeder Neustart trifft alles, was gerade in Flight ist, und das
Symptom ist nie „Server weg", sondern ein fehlender String oder ein nackter
Timeout.
**Endstand: 432 passed, 24 skipped, 1 flaky, 4,1 min.**
Fixed — 16 echte e2e-Failures abgearbeitet
Telemetrie-Consent-Banner prüften, was `features.ts` bewusst ausblendet
(Task 5). Neu: `e2e/feature-flags.ts` liest die Flags aus der Quelle, damit
der Skip automatisch verschwindet, wenn ein Flag zurückgedreht wird — und
laut scheitert, wenn eines umbenannt wird, statt Tests still verschwinden zu
lassen. Der Telemetrie-**Negativ**test kam in denselben Skip: bei
ausgeschaltetem Flag war er grün, ohne irgendetwas zu prüfen.
`/^post$/i` → `/^senden$/i`, `title="View as customer"` →
`"Als Kunde ansehen"`, Such-Placeholder u. a.
`${testIdPrefix}-${file.id}`, das Dashboard übergibt `"file"` — der Test
suchte `file-row-`, einen Präfix, den es nie gab.
der Kachel; `MaterialTiles` rendert `description` gar nicht. Die
API-Assertion zwei Zeilen darunter prüfte dasselbe bereits korrekt.
Admin-Liste und öffnete sie im Portal, wo der Nutzer kein Kunde ist. Jetzt
über `/projects/mine`.
Nicht angefasst: die englischen Selektoren in `invoices`, `documents`,
`notes`, `tasks`, `team`, `client-profiles`. Sie sind grün, weil die
Admin-Oberfläche gemischt bleiben darf — eine Sammelersetzung hätte dort
funktionierende Tests zerstört.
Fixed — Nachrunde zum Production-Review 2026-07-25
Drei Fehlschläge der e2e-Suite waren keine Test-, sondern Produktfehler:
Route band `@Query() pagination: PaginationQueryDto` und daneben ein
separates `@Query("category")`. Die globale `ValidationPipe` läuft mit
`forbidNonWhitelisted: true` und validiert den **kompletten** Query-String
gegen die eine gebundene DTO — `?category=…` starb mit `property category
should not exist`, bevor der Handler lief. Der Kategoriefilter aus Task 07
war damit für jeden Aufrufer tot; unentdeckt, weil die Web-UI Materialien
clientseitig filtert und den Parameter nie sendet. Behoben über
`ListProjectFilesQueryDto extends PaginationQueryDto`. Neuer
Architektur-Test `common/dto/query-binding.spec.ts` verhindert das Muster
künftig (gegen den echten Defekt rot verifiziert, nicht nur grün angenommen).
`if (!project) return <ProjectDetailSkeleton />` stand **vor** dem
Fehler-Banner, das dadurch unerreichbar war. Ein Kunde, der ein Projekt ohne
Zugriffsrecht öffnete, sah dauerhaft graue Platzhalter — keine Meldung, kein
Rückweg. Jetzt eigener Fehlerzweig mit Meldung und Link zurück.
Deep-Link seit jeher, das Portal nicht — `/portal/projects/x?tab=invoices`
landete still auf Updates. Angeglichen.
`htmlFor`/`id`; die `<label>` waren Geschwister der Inputs statt mit ihnen
verknüpft, für Screenreader also unbeschriftet. Verdrahtet.
Sammelersetzung — eine frühere Sammelersetzung hatte API-Pfade wie
`/api/auth/organization/invite-member` mitübersetzt. Projekt-Tabs laufen
jetzt über `data-testid="project-tab-<id>"` statt über Labels und überstehen
damit weitere Übersetzungsrunden. `no-translated-paths.e2e.ts` prüft jeden
`/api/<segment>` der Specs gegen die real registrierten `@Controller`-Präfixe.
Fixed — Production-Review 2026-07-25
19 Spalten der Migration `20260709212452_task02_de_invoice_fields`, obwohl
`_prisma_migrations` sie als angewandt führte (`prisma migrate resolve
--applied` auf eine echte Migration). Jede `systemSettings`- und
`invoice`-Query lief in einen Prisma-P2022 — damit waren in Production
Rechnungen, Settings **und der gesamte Mailversand** tot
(`getEffectiveEmailConfig` hängt an jedem `mail.send()`). Nachweis, Behebung
und Konsequenzen:
`docs/decisions/2026-07-25-schema-drift-migrate-resolve.md`.
`migrate deploy` ein `prisma migrate diff --exit-code` gegen die laufende DB;
`/api/status` meldet `schemaDrift` (`clean|drift|unknown`, fehlender Marker =
`unknown`, nie `clean`); `deploy.yml` bricht rot ab bei Commit-Mismatch,
gestopptem Container oder Drift.
sie nur per `@UseGuards` an einzelnen Controllern — Authentifizierung war
opt-in, ein vergessener Decorator machte eine Route stillschweigend öffentlich
(so geschehen in `7b50153`). `/api/health` hat dabei das fehlende `@Public()`
bekommen, das sonst jeden Deploy-Healthcheck auf 401 laufen ließe. Neuer
Architektur-Test `common/guards/public-routes.spec.ts` friert den Satz
öffentlicher Routen ein.
aus) mit automatischem Bootstrap-Fenster, solange keine Organisation
existiert — eine frische Instanz kann ihre erste Org weiter über die normale
UI anlegen, danach schließt sich das Fenster von selbst. UI blendet
Signup-Formular und -Links entsprechend aus.
`130,00 €` ausgegeben (hartkodiertes Dollarzeichen), Datum als
`December 31, 2026`, und der „Rechnung ansehen"-Button verlinkte auf
`/portal/invoices` — eine Route, die es nicht gab (404). Währung und Datum
laufen jetzt über den gemeinsamen Helper `common/utils/format-de.ts`, den
auch die PDF-Erzeugung nutzt.
Mailversand lösen den Empfänger beide über `ProjectClient` auf — eine
Rechnung ohne Projekt galt als „versendet", war für den Kunden aber weder
sichtbar noch wurde er benachrichtigt. Der Übergang `draft→sent` wird jetzt
mit klarer Meldung verweigert; ein Projekt ohne zugewiesene Kunden wird
geloggt statt verschluckt.
Organisation entfernte alle `Invoice`-Zeilen — entgegen § 147 AO (10 Jahre),
die der Löschpflicht aus Art. 17 DSGVO vorgeht (Art. 17 Abs. 3 lit. b).
Löschung wird bei ausgestellten Rechnungen abgelehnt; Entwürfe und Stornos
zählen nicht mit.
laufenden Betrieb dateiweise (zerrissener Snapshot). Neuer
`before_backup`-Hook `scripts/ops/deck-db-dump.sh` legt einen konsistenten
`pg_dump -Fc` ab; per Restore in eine Wegwerf-DB verifiziert (43 Tabellen).
Restore-Anleitung in `DEPLOY.md` → *Backup & Restore*.
Added — Production-Review 2026-07-25
`/agb` als öffentliche Routen, `LegalFooter` in Landing-, Auth- und
Portal-Layout. Inhalte sind ausgewiesene Platzhalter mit TODO-Markern für die
Pflichtangaben — bewusst kein erfundener Rechtstext. 8 e2e-Tests.
plus Nav-Eintrag. Löst zugleich den toten Link aus der Rechnungs-E-Mail.
4 e2e-Tests.
bereits konfiguriert, es gab nur keinen Einstieg — besonders relevant für
Kunden ohne gesetztes Passwort.
verschickt jetzt beim Übergang `sent→overdue` genau einmal eine neutral
formulierte Erinnerung (bewusst keine Mahnung mit Verzugsfolgen).
Einladungsmail — der allererste Kundenkontakt. Gemeinsames Layout
(`packages/email/src/components/shell.tsx`) statt in jedem Template
wiederholtem Markup.
Datumsformaten (vorher durchgehend `en-US`) — das ist die Beweisdokumentation
der E-Signatur.
Added
Architektur nach ADR Option D (Getrennte Hoheit): Stripe steuert Zyklus +
SEPA-Lastschrift-Zahlung + Dunning nativ (kein eigener Cron, kein
Single-Instance-Lock); das Portal erzeugt jedes rechtsgültige
Rechnungsdokument (§14 UStG) als Spiegelung des Stripe-Events.
- **Neues Modell `ClientSubscription`** (bewusst getrennt von Atriums
SaaS-`Subscription`, Namens-Clash vermieden) + Migration
`20260725024144_task04_client_subscription`: clientId/projectId,
stripeCustomerId/SubscriptionId/PriceId, status, amount/currency/interval,
currentPeriodStart/End, cancelAtPeriodEnd, mandateReference.
- **Neues Modell `StripeEvent`** für Webhook-Idempotenz: gleicher
Stripe-Event (`evt_…`) wird genau einmal verarbeitet — Webhook-Repeat
≠ Doppel-Rechnung.
- **`subscriptions.service`**: Admin-Checkout (Stripe subscription mode,
SEPA priorisiert), SEPA-Mandat-Setup (setup mode, an E-Sign-Vertrags-
abschluss gekoppelt), Customer-Portal-Deeplink, Webhook-Handler
(`invoice.paid`/`finalized`/`payment_failed`, `customer.subscription.
created/updated/deleted`) idempotent, Spiegelungsrechnung via
`invoices.service.createMirrorInvoice` (RE-JJJJ-NNNN-Nummernkreis,
DE-Ausstellerdaten aus SystemSettings).
- **Webhook-Dispatch**: `payments.service.handleWebhookEvent` delegiert
Recurring-Events an `SubscriptionsService`; einmalige Rechnungs-Checkouts
bleiben im PaymentService (Sauber getrennt via metadata).
- **Portal-Ansicht** read-only: Plan, Betrag, Status, nächste Abbuchung +
"Verwalten"-Button → Stripe Customer Portal (Zahlungsmethode ändern /
kündigen, Stripe-gehostet).
- **Admin-UI**: Subscriptions-Tab im Projekt-Detail (Dashboard), Anlegen-
Modal (Stripe-Price-ID + Client), Kündigen.
- **Kalender**: `subscription_renewal`-Event aus
`ClientSubscription.currentPeriodEnd` (Admin + Portal, scoped via
`assertProjectAccess`/ProjectClient).
- **Feature-Flag** `features.recurringBilling` (Standard: an).
- **17 Unit-Tests** (Idempotenz, Spiegelung, Portal-Isolation,
Subscription-Sync, SEPA-Setup, Customer-Portal, Cancel) + **5/7 e2e**
(2 skipped bei leerer Test-DB). 726 unit pass, build grün, bestehende
calendar/invoices-e2e ohne Regression. Siehe
[Task 04](docs/deck/tasks/04-recurring-billing.md). _(Implementiert
2026-07-25.)_
Der Tresor-Tab im Kundenportal ist ein reiner Verweis auf zwei externe
Dienste — es werden **keine Credentials in der Portal-DB gespeichert**
(Architektur-Regel: Zero-Knowledge-Krypto nicht selbst bauen; gespeicherte
Kunden-Zugänge = max. Haftung + Breach-Ziel).
- **Neue Felder `SystemSettings.vaultUrl` +
`SystemSettings.oneTimeSecretUrl`** (Plaintext-URL-Pointer, keine
Secrets, nicht verschlüsselt) + Migration
`20260725103009_task08_vault_url` (beide Spalten). NULL = jeweilige
Sektion im Portal-Tresor-Tab ausgeblendet.
- **`GET /api/settings/vault-url`** (alle authentifizierten User inkl.
Portal-Kunden, keine `@Roles`-Einschränkung — analog
`payment-instructions`). Liefert `{ vaultUrl, oneTimeSecretUrl }`.
`PUT /settings` akzeptiert beide (URL-validiert mit `require_protocol`,
null = clear).
- **Portal-Layout:** Tresor-Nav-Link erscheint **nur**, wenn mind. eine
URL konfiguriert ist (Vaultwarden ODER OneTimeSecret).
- **`/portal/tresor`:** zwei Karten:
1. **Vaultwarden** — dauerhafter geteilter Tresor (Org-pro-Kunde):
"Tresor öffnen"-Link (`target=_blank`) + Anleitung (eigener
Org-Schlüssel, E2E-Verschlüsselung, 2FA).
2. **OneTimeSecret** — ephmere einmalige Credential-Übergabe (Initial-
Passwort, ad-hoc-Token): Link zum OneTimeSecret-Service +
Anleitung (Secret nach einmaligem Lesen permanent vernichtet).
Beide mit Privacy-Hinweis ("Portal speichert keine Zugangsdaten") +
je "Noch nicht eingerichtet"-State wenn URL leer.
- **Admin-Workspace-Settings:** Tresor-URL-Konfig-Sektion
(`vault-section.tsx`) mit beiden URL-Feldern in einem Formular.
- **i18n:** `t.nav.tresor`, `t.portal.tresor.*` (inkl.
`oneTimeSecret*`), `t.dashboard.settings.vault.*` (inkl.
`oneTimeSecret*`).
- **16 Unit-Tests** (8 service: getVaultUrls null/configured/cleared für
beide URLs, plaintext storage; 8 dto: URL-Validierung für beide Felder)
+ **19/19 e2e** (`task08-tresor.e2e.ts`: beide URLs, Tab-Sichtbarkeit
iff mind. eine gesetzt, Karten + Links, Not-configured-States pro
Dienst, Anleitungen). Build grün, bestehende
ballast-ausblenden-e2e ohne Regression.
- **Doku:** [vaultwarden-org-pro-kunde.md](docs/deck/vaultwarden-org-pro-kunde.md)
— Org-pro-Kunde-Prinzip, Onboarding-Runbook, **kompletter
Kunden-Workflow** (Onboarding via OneTimeSecret → Dauergebrauch via
Vaultwarden → Ad-hoc-Secrets), OneTimeSecret (Open Source,
self-hostbar, EU-Region), Self-Host-vs-Hosted-Vergleich,
Härtungs-Checkliste (Instanz, Account/2FA, Backup, Monitoring,
Netzwerk). Infra-Umsetzung an Felix delegiert (vor erstem Kunden mit
Tresor). Siehe
[Task 08](docs/deck/tasks/08-secret-sharing.md). _(Implementiert
2026-07-25.)_
Fixed
gegen Production gefahren.** Der automatische `Deploy`-Workflow
(`deploy.yml`) schlug nach Merge von Task 03 (`c021b0d`) und Task 07
(`e6a0f13`) fehl, sodass die Migrationen `20260724083729_task03_calendar_entry`
und `20260724180303_task07_file_pinned_category` nicht auf Production
angewandt wurden (Production lief weiter auf `c517791`, vor Task 03). Die
CI-Phase `Build & unit test` (inkl. `db:migrate:deploy` gegen die CI-Postgres)
war in beiden Läufen grün — der Fehler lag im `Deploy to Hetzner`-Job.
Zwei server-seitige Ursachen behoben: (1) 44 Dateien unter `/opt/deck-src`
gehörten `root:root` (darunter `packages/database/prisma/migrations/`),
sodass der `deploy`-User beim `git checkout` keine neuen
Migrations-Verzeichnisse anlegen konnte — `chown -R deploy:deploy`
durchgeführt. (2) Der lokale `main`-Branch auf vps2 hinkte hinter
`origin/main` hinterher; `deploy.sh` checkte den stale Branch aus —
`deploy.sh` gepatcht, bei `REF=main` auf `origin/main` zu fast-forwarden
+ Guard gegen stale Deploys. Post-Mortem:
`docs/decisions/2026-07-24-deploy-incident-migrations-not-applied.md`;
Runbook-Ergänzung in `DEPLOY.md` (Troubleshooting). Beide Fixes sind
server-seitig (nicht im Repo). Verifikation: alle 4 Migrationen in
`_prisma_migrations` applied, `/api/status` reportet `e6a0f13`, Auto-Deploy
re-run grün.
Added
- Schema: `File.pinned Boolean @default(false)` + `File.category String?`
(Migration `20260724180303_task07_file_pinned_category`).
- API: `PATCH /files/:id/pin` Pin-Toggle-Endpoint (beidseitig — Admin und
Kunde über `assertProjectAccess`, kein `@Roles`-Lock); `category`-Filter
in `GET /files/project/:projectId?category=...` (leere String = "ohne
Kategorie"); `category` in `CreateFileLinkDto` und `UpdateFileDto`;
`findByProject` ordnet gepinnte zuerst, dann neueste.
- UI: geteilte `MaterialTiles`-Komponente (`components/material-tiles.tsx`)
— quadratisches Kachel-Grid, Bild-Thumbnail via `mimeType`, Link-Icon,
Pin-Badge, Kategorie-Filter-Chips, Hover-Actions. Admin und Portal nutzen
dieselbe Komponente (Portal ohne Delete/Edit, aber mit Pin + Upload).
- i18n: `pin`, `unpin`, `pinned`, `category`, `categoryPlaceholder`,
`allCategories` zur `dashboard.files`-Sektion hinzugefügt.
- Tests: 709 Unit-Tests grün (togglePin, category CRUD, findByProject-Filter,
beidseitiger Pin-Access); 6/6 Task07-e2e grün; files-links.e2e unverändert
grün. Pre-existing i18n-Failures in files.e2e.ts (DE-Rebrand, nicht Task 7).
- Prisma-Modell `CalendarEntry` (type/title/date/time/location/link/notes,
scoped zu Project + Organization, onDelete Cascade).
- Shared-Types: `CALENDAR_EVENT_TYPES` um `contract_deadline` + `entry`
erweitert; `CalendarEvent`-Union mit entsprechenden Varianten.
- Backend `calendar.service`: refactored in shared `aggregate()` mit
`projectIds`-Scoping; `contract_deadline` aus `Document.expiresAt`;
`invoice_due` inkl. `paidAt` im Select (Status zeigt "bezahlt");
`CalendarEntry`-CRUD (create/update/delete, org-scoped).
- Backend `calendar.controller`: `/calendar/mine` Portal-Endpoint (member,
`assertProjectAccess` via `ProjectClient`); `/calendar/entries` CRUD
(owner/admin). Klassen-`@Roles` entfernt zugunsten Method-level.
- Web: `calendar-utils` extrahiert nach `lib/` für Wiederverwendung;
`event-chip` mit `contract_deadline` + `entry` + portal-aware href;
Admin-Kalender mit "Termin hinzufügen"-Modal (`entry-modal.tsx`);
Portal-Kalender `/portal/calendar` (Monatsgrid + Agenda, ohne task-Events).
- i18n: `nav.calendar`, neue Event-Typ-Labels, CRUD-Labels, Portal-Strings.
- Tests: 22 Unit-Tests (CRUD, contract_deadline, Portal-Scoping, Isolation);
7/9 e2e-Tests grün (2 UI-Render-Tests flaky, API-Level alle grün).
- `DEV_BYPASS_AUTH=false` in `.env` (vorbestehend: Preview-Mode blockierte
Signup bei e2e-Tests).
Endpoint zur schnellen Verifikation, was gerade in Produktion laeuft.
Auth via `X-Status-Token`-Header gegen `STATUS_CHECK_TOKEN`-Env (fehlend/falsch → 401).
Antwort: `commitSha` (via Docker build-arg `GIT_SHA` baked), `deployedAt`
(Commit-Timestamp via `GIT_COMMIT_DATE` build-arg, formatiert in
`Europe/Berlin` mit IANA-Timezone-Handling — auto-adjusts CET/CEST, kein
hardcoded Offset), `containerStatus` (`running`/`unhealthy`, reuses die
selbe DB-Ping-Logik wie `/api/health`, nicht reimplementiert). Dev-Fallback
liest `git rev-parse HEAD`/`git show -s --format=%cI HEAD` live. `/api/health`
und der CI deploy-gate unberuehrt. Siehe `apps/api/src/status.controller.ts`.
Changed
Added
Fixed
Added
Fixed
Fixed
Fixed
Added
Added
Changed
Database
Added
Changed
Fixed
Database
Added
Fixed
Added
Security
Added
Changed
Fixed
Upgrade Notes
If you set `BETTER_AUTH_URL` in your `.env`, rename it to `API_URL`. The old name still works as a fallback but will be removed in a future release.
Added
Changed
Fixed
Added
Added
Security
Upgrade Notes
New database tables: `comment`, `label`, `project_label`, `task_label`, `file_label`, `member_label`, `notification`, `push_subscription`. New relation columns on `project`, `task`, `file`, and `member`. Docker handles this automatically via `prisma db push` in the entrypoint; bare-metal deployments must run `bun run db:push` after updating.
Added
Mobile-Responsive UI
Testing
Fixed
Security
Breaking Changes
Added
Document Lifecycle
E-Signature Enhancements
Expiration & Reminders
Direct Signing Links
Completion Certificate
Client Choices
UI Improvements
Document Versioning
Direct Signing Links
Email Templates
Testing
Fixed
Upgrade Notes
Schema changes require `bun run db:push`. **Data migration needed**: run `bun run packages/database/scripts/migrate-document-sent-at.ts` to backfill `sentAt` on existing pending documents. New tables: `document_audit_event`, `document_access_token`. New columns on `document` and `signature_field` models.
Added
Fixed
Upgrade Notes
Additive schema changes only (new tables + nullable columns). No data migration needed. Docker entrypoint handles it automatically; manual deployments run `bun run db:push`.
Added
Fixed
Added
Account Deletion
Supabase Row Level Security
Docker
Unraid
Invitations
UX
Security
Changed
Fixed
Added
Billing & Subscriptions
Performance
Fixed
Security
Database
Security
Fixed
Changed
Added
Tasks
Invoicing
Internal Notes
Client Profiles
Email Verification
Notifications
System Settings
Setup Wizard
1. Organization profile (name, logo, colors)
2. Email configuration (None / Resend / SMTP with test send)
3. Create first project
4. Invite first client
5. Completion summary