Pentester R143 (Robustheit): getCustomerPortalPassword lieferte 'ok',
wenn portalPasswordEncrypted gesetzt, aber portalPasswordHash null ist.
Ohne Hash ist ein Login unmoeglich -> ein revealtes Passwort waere
irrefuehrend. Jetzt -> desync (Reveal/Send blocken mit 409). Aktuell
ueber die API nicht erreichbar, nur als DB-Altlast; jetzt sauber
abgefangen.
Verifiziert: encrypted+hash=null -> desync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eine Gutschrift/ein Lieferschein braucht eine Empfaengeradresse aufs
Dokument. Rechnungsadresse hat Vorrang, sonst Lieferadresse. Ist keine
von beiden hinterlegt -> Anlegen blockiert.
- Frontend: Klick auf 'Gutschrift anlegen' prueft defaults.hasRecipient-
Address; wenn false -> Modal-OK-Meldung statt Formular.
- Backend Defense-in-Depth: createCreditNote wirft 400, wenn weder
billingAddressId noch addressId gesetzt. getCreditNoteDefaults liefert
hasRecipientAddress.
Verifiziert: ohne Adresse -> hasRecipientAddress false + create 400;
mit Adresse -> ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Beleg-Upload jetzt fuer beide Arten: bei Geld die Ueberweisungs-
bestaetigung, bei Sachwert das unterschriebene Dokument. ReceiptControls
in der Liste fuer Geld UND Sachwert (Label je nach Typ). Endpoint war
schon typ-agnostisch.
- PDF-Unterschriftsblock nur noch bei Sachwert - eine Ueberweisung wird
nicht unterschrieben (Beleg = hochgeladene Ueberweisungsbestaetigung).
Bei Geld entfaellt der Unterschrift/Ort-Block; im Formular sind Ort +
'Unterschrift am' bei Geld ausgeblendet.
Verifiziert: beide PDFs erzeugen sauber (je 1 Seite).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pentester R142: Uebergang Geld->betragsloser Sachwert setzte die schon
vergebene Gutschriftsnummer auf null -> Luecke in der GS-Serie.
Loesung: betragsloser Sachwert = Lieferschein mit eigener
Lieferscheinnummer aus separatem Nummernkreis.
- Schema: CreditNote.deliveryNoteNumber (nullbar, unique) + neues Model
DeliveryNoteNumberRange (Default-Praefix 'LS-') + Migration.
- deliveryNoteNumberRange.service (mirror, eigener Zaehler, FOR UPDATE).
- Nummern lazy pro Serie, NIE freigeben: Uebergaenge behalten die
jeweils vergebene Nummer der anderen Serie reserviert -> kein
Doppelverbrauch, keine Luecke. effectiveNumber() liefert je nach Typ
die passende (LS/GS) fuer Anzeige/PDF/Audit.
- Endpunkte GET/PUT /credit-notes/delivery-note-number-range; Settings-
Seite verwaltet jetzt beide Nummernkreise. PDF-Titel 'Sachwert-
Uebergabe', Dateiname lieferschein-...
- Frontend: Typ + displayNumber in Liste/Modal.
Verifiziert: Sachwert 0 -> LS-Nr, GS-Zaehler unberuehrt; Geld -> GS-Nr;
Uebergaenge behalten beide Nummern (kein Neuverbrauch, keine Luecke).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pentester-Hinweis: bcrypt-Hash (Login) und verschluesseltes Reveal-Feld
koennen out-of-sync sein -> Support liest ein Passwort vor, das beim
Login scheitert.
Analyse: alle aktuellen Schreibpfade sind konsistent (beide Felder
zusammen, oder encrypted=null, oder Rehash desselben Passworts) - der
Code erzeugt keinen Desync. Ursache = Altlast/manueller DB-Eingriff.
Fix (defensiv, unabhaengig von der Ursache):
- getCustomerPortalPassword liefert {status: ok|none|desync} und prueft
den entschluesselten Klartext per bcrypt.compare gegen den Login-Hash.
- Bei desync (oder Entschluesselungsfehler) geben WEDER Reveal NOCH
Send-Credentials das Passwort aus -> 409 'Dateninkonsistenz, bitte
neu setzen'. Reveal-Read wird mit Status auditiert.
- Neues Diagnose-Script scripts/check-portal-password-sync.ts scannt
alle Portal-Kunden auf Desync (nur Diagnose, aendert nichts) - fuer
Prod, da der Pentester keinen FS-Zugriff hat.
Verifiziert: desync -> nicht ausgegeben; konsistent -> ok; kein PW ->
none. Scan laeuft (0 Desync auf Dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ein betragsloser Sachwert ist eher ein Lieferschein als eine Gutschrift
-> er soll KEINE Gutschriftsnummer aus dem Nummernkreis verbrauchen.
- Schema: CreditNote.number nullbar (Migration MODIFY ... NULL, UNIQUE
bleibt - MySQL erlaubt mehrere NULLs).
- createCreditNote: betragsloser Sachwert -> number=null, assignNextNumber
wird NICHT aufgerufen (Zaehler unangetastet).
- updateCreditNote: Uebergaenge - wird betragslos -> Nummer entfernen;
bekommt nachtraeglich Betrag & hatte keine -> jetzt Nummer vergeben.
- PDF/Liste/Modal/Audit: Fallback 'Sachwert-Uebergabe'/'Beleg #id' wenn
keine Nummer; PDF-Titel 'Sachwert-Uebergabe', kein ZUGFeRD (schon vorher).
Verifiziert: Sachwert 0 -> number null + Zaehler bleibt; Geld -> Nummer
+ Zaehler +1; Sachwert nachtraeglich mit Betrag -> Nummer vergeben.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eine Sachwert-Gutschrift darf betragslos sein (Betrag leer/0): dann
findet keine Rechnungsstellung statt - der Kunde hat den Gegenstand
einfach als Subvention erhalten.
- Backend: leeres amount -> 0; Sachwert erlaubt 0, Geld verlangt > 0
(400 sonst). vatRelevant bei 0 erzwungen-false.
- PDF: betragsloser Sachwert -> Titel 'Sachwert-Uebergabe', kein
Betrags-/USt-Block (Hinweis keine Rechnungsstellung), KEIN ZUGFeRD-
Embedding. Mit Betrag -> unveraendert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet ohne
Betrag; Liste zeigt 'Sachwert ohne Betrag (keine Rechnung)'.
Verifiziert: Sachwert 0 -> kein factur-x.xml; Geld 0 -> 400;
Sachwert 200 -> ZUGFeRD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verträge mit hinterlegter Kuendigungsbestaetigung im Status EXPIRED
(Abgelaufen) werden jetzt auch im Cockpit-Filter gelistet, zusaetzlich
zu ACTIVE/DRAFT/CANCELLED. Die Cockpit-Query laedt EXPIRED ohnehin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pentester-Hygiene zu a6b1dac:
1) Konsolidierung: neuer utils/fileCleanup.ts mit deleteFileAbsolute
(absoluter Pfad, z.B. Multer-Temp) + deleteUploadByRelativePath
(in DB gespeicherter /uploads/-Pfad). Ersetzt die 3x kopierten
deleteFileIfExists/cleanupFile in creditNote-, upload- und
customer-Service.
2) Reihenfolge: In deleteCreditNote/updateCreditNote erst die DB-
Operation, DANN die Datei loeschen. Schlaegt der DB-Schritt fehl,
bleibt die Datei erhalten (kein ins-Leere-zeigender Zustand).
Verifiziert: Update -> pdfPath null + alte Datei weg; Delete -> gibt
geloeschte Row zurueck (Audit) + Datei weg. Kein Regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1) Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit
previousContractId an. Der Verlust passierte im Frontend - das
Vorgaenger-Dropdown holt Vertraege ueber getAll, das DEACTIVATED
standardmaessig ausblendet. Beim Bearbeiten des Folgevertrags war der
deaktivierte Vorgaenger nicht als Option da -> Verknuepfung ging beim
Speichern verloren.
Fix: getAllContracts + Controller + contractApi.getAll um
includeDeactivated erweitert; Vorgaenger-Dropdown nutzt es und markiert
deaktivierte Vertraege mit '· deaktiviert'. Verifiziert (Flag inkludiert
deaktivierte; Folgevertrag setzt previousContractId).
2) Kundendaten-Modal: zeigt jetzt zusaetzlich Lieferadresse + (falls
abweichend) Rechnungsadresse des geoeffneten Vertrags, die Stressfrei-
Adresse des Vertrags einzeln und darunter alle weiteren Stressfrei-
Adressen des Kunden. CustomerInfoModal nimmt optionale Vertragskontext-
Props; ContractDetail + ContractForm uebergeben sie.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pentester-Hinweis: generierte Gutschrift-PDFs blieben nach dem Loeschen
der Gutschrift als verwaiste Files im Upload-Ordner liegen (harmlos, da
ohne DB-Referenz nicht mehr abrufbar - aber unsauber).
deleteCreditNote entfernt jetzt PDF (pdfPath) + Ueberweisungsbeleg
(receiptPath) von der Platte. updateCreditNote loescht das alte PDF
beim Leeren von pdfPath. Kein verwaister Ordner-Muell mehr.
Verifiziert: PDF nach Erzeugung vorhanden, nach Loeschen der Gutschrift
weg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pentest R138 Hygiene + neue Vorgaben:
1) endDate wird bei DRAFT-Vertraegen NICHT mehr gesetzt (Entwurf = nur
Vorlage). Nur Status wurde vorher geschont, endDate zog trotzdem mit.
2) Ueberweisungsbelege (credit-note-receipts) sind jetzt reine
Mitarbeiter/Admin-Downloads: neuer FileOwner-kind 'contract-staff'
blockt Portal-Kunden im fileDownload-Controller. Das generierte
Gutschrift-PDF (credit-notes) bleibt vertragsbasiert -> der
besitzende Kunde darf seine eigene Gutschrift laden.
Bereits vorher abgesichert (bestaetigt): Kunden koennen keine
Gutschriften anlegen (blockPortal) und keine Belege hochladen
(Portal-403 im Upload).
Verifiziert: DRAFT haelt endDate; Beleg-Owner=contract-staff,
PDF-Owner=contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).
2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
cancellationConfirmations (Vertraege mit Bestaetigung in Status
ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
der Cockpit-Liste auftauchen.
Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unteren Rand fuer die Fusszeile temporaer auf 22 verkleinert, damit sie
naeher am Seitenende sitzt - weiterhin nur 1 Seite (verifiziert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Footer wurde mit fester y=790 gesetzt und lief dadurch ueber den
unteren Seitenrand -> pdfkit legte eine zweite Seite an. Position jetzt
aus Seitengeometrie berechnet (heightOfString + page.height/margins),
sodass die Fusszeile am unteren Rand der ersten Seite endet, auch bei
zweizeiligem Umbruch. Verifiziert: PDF hat nur noch 1 Seite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bei Geld-Gutschriften kann die Auszahlung auf ein anderes Konto gehen
als das Vertrags-Abbuchkonto:
- CreditNote.payoutBankCardId + Migration (FK ON DELETE SET NULL, damit
Loeschen einer Bankkarte die Gutschrift nicht mitreisst).
- Formular: Dropdown mit allen Bankkonten des Kunden (Default =
Vertrags-Abbuchkonto). getCreditNoteDefaults liefert bankCards +
contractBankCardId.
- Server prueft, dass die gewaehlte Bankkarte dem Kunden des Vertrags
gehoert (kein Fremdkonto unterschieben).
- PDF: bei Ueberweisung 'Unsere Bankverbindung' (Absender) + darunter
'an Bankkonto: <Kunden-IBAN> (<Inhaber>)'. Section-Zeile zeigt das
Auszahlungskonto.
Lokal verifiziert (Anlage + PDF).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PDF-Erzeugung fuer Gutschriften mit Firmenstammdaten als Absender,
Empfaenger (Kunde+Adresse), Netto/USt/Brutto (bzw. ohne USt-Ausweis),
Sachwert/'Ware erhalten' bzw. Ueberweisungs-Bankverbindung,
Unterschriftsblock, Firmen-Fusszeile.
Endpoint POST /credit-notes/:id/pdf; 'PDF'-Button in CreditNotesSection
(erzeugen + im Tab oeffnen bzw. vorhandenes ansehen). PDF-Pfad wird bei
inhaltlicher Aenderung geleert -> Neu-Erzeugung. Download ueber
fileDownload (credit-notes subDir, Vertrags-Ownership).
Lokal verifiziert (valides %PDF, korrekte Betraege).
ZUGFeRD-XML-Embedding (PDF/A-3, EN 16931) folgt als Teil 2 mit
Validator-Pruefung.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kunden-Feld vatExempt (Kleinunternehmer/USt-befreit §19) + Migration
(ADD COLUMN IF NOT EXISTS). Checkbox im Kundenformular nur fuer
Firmenkunden.
Gutschrift-Vorbelegung vatRelevant wird aus dem Kunden abgeleitet:
Firmenkunde ohne USt-Befreiung -> USt-relevant an (Netto), sonst aus
(wie Privat). Jede Gutschrift speichert ihren eigenen Snapshot, ein
spaeterer Statuswechsel des Kunden aendert bestehende Gutschriften
nicht.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pentester R130: der erste Fix (c39d252) setzte bei Plesk nur -passwd,
liess die Adresse aber auf -mailbox false stehen -> Mailbox wurde nie
aktiviert, IMAP/SMTP-Login scheiterte trotz korrektem Passwort.
Jetzt enableMailboxForExistingEmail (-mailbox true -passwd ...), das
sowohl den existierte-als-Forward-Fall als auch den Neu-Anlage-Fall
idempotent abdeckt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beim Anlegen einer Adresse mit echter Mailbox (IMAP/SMTP) wird das
frisch generierte Passwort jetzt immer explizit am Provider gesetzt
(updateMailboxPassword direkt nach dem Provisioning).
Behebt den Fall, dass die Adresse beim Provider bereits als reine
Weiterleitung existierte: dann kehrte provisionEmailWithMailbox frueh
mit success zurueck, ohne je ein Postfach-Passwort zu setzen. Im CRM
lag dann ein verschluesseltes Passwort, das der Provider nicht kannte
-> IMAP/SMTP-Login schlug fehl. Jetzt stimmen CRM und Provider ueberein.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beziehungs-Dropdown erweitert um Schwiegertochter/Schwiegersohn,
Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa (Whitelist
front- und backend synchron).
Bearbeiten-Stift pro Zeile (vor der Muelltonne): Beziehung und/oder
Gegen-Kunde aenderbar. Neuer PUT /:customerId/referrals/:referralId
mit Whitelist-Pruefung, Doppel-Werber-409 (Self-Ausschluss),
Portal-Block und UPDATE-Auditeintrag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Neuer Tab vor "Datenschutz", nur Mitarbeiter/Admin (nicht Portal),
ohne Consent-Pflicht. Zwei Abschnitte:
1. "<Kunde> wurde an Board geholt durch:" – max. 1 Werber
(DB-Unique auf recruitedId).
2. "<Kunde> hat folgende Kunden an Board geholt:" – beliebig viele.
Jede Zeile: Kunde per Lupe-Such-Modal (breite Suche über Name/
Kundennr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown. Löschen +
Externtab-Link zur Kundenakte pro Zeile.
Bidirektional aus EINEM Datensatz: "A geworben durch B" erscheint
automatisch bei B unter "hat geworben"; von beiden Akten
hinzufügbar/löschbar.
Backend: neues Model CustomerReferral (recruiter/recruited FKs,
recruitedId @unique, relationship) + Migration. Beziehungs-Whitelist
serverseitig; Self-Werbung + Doppel-Werber (409) abgefangen.
Portal-Token wird explizit geblockt (Defense-in-Depth, nicht nur
UI-Ausblendung). CREATE/DELETE auditiert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mobileNetwork akzeptierte serverseitig jeden stripHtml-bereinigten
String – das Frontend-Dropdown beschränkte nur clientseitig. Jetzt
Whitelist TELEKOM|VODAFONE|TELEFONICA (normalizeMobileNetwork),
angewandt in Create- UND Update-Pfad (der Update-Spread reichte den
Wert vorher ungefiltert an Prisma durch). Unbekannte/leere Werte
werden zu null normalisiert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nachtrag zum R124-Fix: statt bei nicht auffindbarem Junk-Ordner still
auf INBOX bzw. den geratenen String 'Junk' zurückzufallen, wird jetzt
ein klarer Fehler zurückgegeben – sonst könnte im Randfall (Junk-Ordner
serverseitig entfernt/umbenannt) doch wieder die falsche UID aus dem
falschen Ordner gezogen werden.
- 4 Controller-Funktionen (downloadAttachment, saveAttachmentTo,
saveAttachmentAsInvoice, saveAttachmentAsContractDocument):
404 "Postfach hat keinen Spam-/Junk-Ordner (mehr)".
- 2 Service-Funktionen (moveEmailToTrash, restoreEmailFromTrash):
klare error-Message im TrashOperationResult.
Kein undefined mehr Richtung IMAP-Lib.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Kleine Nachhärtung zum Spam-Tab: in getCachedEmails blieb where.folder
bei einem unbekannten folder-Wert ungesetzt und mischte alle Ordner
des Postfachs. Jetzt defaulten unbekannte/fehlende Werte klar auf
INBOX. Ownership-Scope (customerId/stressfreiEmailId) war nie
betroffen – rein Ordner-Filter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neuer Tab "Spam" (zwischen Gesendet und Papierkorb), zeigt den Junk-/
Spam-Ordner des gewählten Postfachs – damit fälschlich als Spam
einsortierte Mails auffindbar sind.
Backend:
- EmailFolder-Enum um SPAM erweitert (Migration, Wert angehängt →
kein Rewrite bestehender Zeilen).
- imapService.findJunkFolderPath: ermittelt den Junk-Ordner per
Special-Use-Flag \Junk + üblicher Namensliste (Junk/Spam/…).
- syncAllFoldersForAccount synct den Junk-Ordner zusätzlich als
dbFolder=SPAM (syncEmailsForAccount bekommt dbFolder-Option).
- getCachedEmails + getFolderCountsForAccount um SPAM erweitert.
- Papierkorb-Move/Restore für Spam-Mails nutzt den echten Junk-Pfad
als Quell-/Zielordner.
Frontend: Tab + Badge (ungelesen/gesamt), gleicher List-/Detail-Pfad
wie INBOX; Zuordnen-zu-Vertrag auch aus Spam möglich.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neues Dropdown "Mobilfunknetz" in der Anbieter-&-Tarif-Karte, nur
sichtbar bei Vertragstyp Mobilfunk. Optionen: Bitte auswählen (leer),
Telekom, Vodafone, Telefónica.
Neues Feld MobileContractDetails.mobileNetwork (String nullable,
speichert TELEKOM/VODAFONE/TELEFONICA) + idempotente Migration
(ADD COLUMN IF NOT EXISTS). String statt Enum, damit weitere Netze
ohne Migration ergänzbar sind. Anzeige in der Vertragsansicht mit
lesbarem Netz-Namen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Beim Upload einer Lieferbestätigung mit Datum wurde startDate nur
gesetzt, wenn es noch leer war (if (!contract.startDate)). Hatte der
Vertrag schon ein geschätztes Beginndatum, blieb es trotz
eingetragenem Lieferdatum stehen.
Fix in maybeActivateOnDeliveryConfirmation: ein explizit eingegebenes
Lieferdatum übernimmt jetzt IMMER den Vertragsbeginn (die Liefer-
bestätigung ist das maßgebliche tatsächliche Startdatum), auch
überschreibend. Der Fallback "heute" (kein Datum angegeben) füllt
weiterhin nur ein leeres Feld, damit ein echtes Datum nicht
versehentlich mit heute überschrieben wird. No-op-Guard + Audit-Log
unverändert.
Frontend schickte das Datum bereits mit und lädt den Vertrag nach
dem Upload neu – kein Frontend-Change nötig.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
POST /api/audit-logs/verify meldete ~73% der Einträge als
"manipuliert" – kein echtes Tampering, sondern ein Serialisierungs-
Bug in generateHash: resourceId war beim Schreiben oft undefined
(Middleware-Einträge ohne Route-ID). JSON.stringify lässt einen
undefined-Wert weg → Hash ohne resourceId-Key. In der DB steht der
Wert aber als NULL; verifyIntegrity/rehashAll lasen null zurück und
JSON.stringify({resourceId:null}) schrieb ihn rein → anderer Hash
→ Fehlalarm für jede Zeile mit leerem resourceId. Die vom Pentester
gefundene durationMs-Korrelation war nur ein Proxy (Middleware-
Einträge haben durationMs UND oft kein resourceId).
Fix: generateHash lässt nullish resourceId weg – reproduziert exakt
das historische Schreibverhalten (undefined → Key weg). Kein Caller
übergibt je null explizit (verifiziert), daher matchen alle
Bestands-Hashes ohne Rehash; Feldreihenfolge unverändert.
Empirisch gegen echte DB bewiesen: aktuelle Hash-Ära (id>4141)
482/482 valide (vorher wurden alle 289 leeren-resourceId-Zeilen
falsch geflaggt).
Separat/vorbestehend (NICHT dieser Fix): Einträge vor Commit
fd55742 ("complete new audit system") nutzen ein altes Hash-Schema
und brauchen einmalig POST /api/audit-logs/rehash zum Re-Baselinen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Retest deckte auf, dass abgewehrte Cross-Customer-Zugriffe
(canAccess*-403) nur im SecurityEvent-Monitoring-Stream landeten
(ACCESS_DENIED, /api/monitoring/events) – nicht im AuditLog, wo
der Pentester suchte. Der Monitoring-Stream ist zudem löschbar
(DELETE /api/monitoring/events) und nicht hash-verkettet.
emitAccessDenied schreibt jetzt zusätzlich einen tamper-evidenten
AuditLog-Eintrag (action READ, resourceType 'AccessDenied',
Sensitivity HIGH), der ein Clearen des Monitoring-Streams
überlebt und über die Hash-Kette manipulationssicher ist. Gilt
für alle canAccess*-403 (Contract + Customer, inkl. Vollmacht-
fehlt-Fall). Kein Enum-/Schema-Change: READ + distinktiver
resourceType, filterbar via searchAuditLogs.
Korrigiert damit auch meine frühere ungenaue Aussage „landet im
Audit" – vorher war das die falsche Tabelle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Vertragsbaum beim Kunden blendete DEACTIVATED-Verträge
komplett aus. Da der aktuellste Vertrag die Baumwurzel ist und
Vorgänger als Children hängen, verschwand eine ganze Kette aus
der Ansicht, sobald die Wurzel deaktiviert wurde – so „verschwand"
ein Vertrag scheinbar, als ein aktiver Folgevertrag gelöscht und
der Vorgänger vorher deaktiviert worden war.
getContractTreeForCustomer bekommt ein optionales
includeDeactivated-Flag (Default false = bisheriges Verhalten),
durchgereicht per Query-Param includeDeactivated=true. Toggle-
Button (Eye/EyeOff) im Kunden-Vertragstab; showDeactivated ist
Teil des Query-Keys → frischer Fetch beim Umschalten. Deaktivierte
behalten ihr graues DEACTIVATED-Badge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Zwei zusammenspielende Bugs:
1) Backend: deleteContract-Reihenfolge scheiterte am UNIQUE-
Constraint auf Contract.previousContractId. Beim Middle-Delete
(A → B → C, B löschen) hielt B im UPDATE-Moment noch selbst
previousContractId=A – der Versuch, C ebenfalls auf A
umzubiegen, warf MariaDB Duplicate-Entry, das UPDATE brach ab.
Fix: Transaction, umgedrehte Reihenfolge – erst B löschen
(DB-Cascade ON DELETE SET NULL räumt C.previousContractId ab,
gibt A-Slot frei), dann C sauber auf A umbiegen.
2) Frontend: deleteMutation invalidierte weder Contract-Listen
noch Kunden-Vertragsbaum und hatte keinen onError-Handler.
Nach dem Delete wurde nach /contracts navigiert, dort zeigte
der stale Cache noch den gelöschten Vertrag – Eindruck: „ganze
Historie weg". Bei Bug 1 fehlgeschlagen brach das schweigend
ab.
Fix: queryClient.invalidateQueries für ['contracts'],
['contract-tree', customerId], ['customer', customerId] nach
Erfolg. onError-Toast bei Fehler.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dritter Radio-Button in der Zugangsdaten-Card des Vertragsformulars.
Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangs-
daten fehlen" für diesen Vertrag – für Anbieter ohne Portal oder
Kunden, die bewusst keine Zugangsdaten pflegen. Verstopft das
Cockpit sonst dauerhaft.
Neues Feld Contract.portalCredentialsNotRequired (Boolean, default
false) + idempotente Migration (ADD COLUMN IF NOT EXISTS). Bestand
bleibt auf false, Warnung greift wie bisher.
Beim Umschalten auf Opt-out werden portalUsername,
portalPasswordEncrypted und stressfreiEmailId server-seitig
explizit auf NULL gesetzt – Datenhygiene, damit keine verwaisten
Anmeldedaten in der DB stehen bleiben.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Ein DEACTIVATED-Vertrag (manuell archiviert) zeigte im
Cockpit weiter "Vertragsende: Vertrag seit 79 Tagen abgelaufen!"
– sinnlos, weil der Mitarbeiter den Vertrag ja aktiv beendet
hatte.
Fix: skipDeadlines-Guard, der bisher nur ONGOING abfing, greift
jetzt auch bei DEACTIVATED und CANCELLED. Beide sind terminale
Status – laufende Fristen ergeben da semantisch keinen Sinn.
Bewusst weiterhin AKTIV:
- Block 13a (Schlussrechnung fehlt): feuert wegen dieser Status,
nicht trotzdem.
- Block 8 (DSGVO/Consent): läuft pro Kunde, nicht pro Vertrag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der User sah beim Postfach-Sync stumpf "Command failed" – realer
Grund war ein abgelaufenes Postfach-Passwort. Der Bug betraf
Anhang-Download UND Sync und war der gleiche wie 2 Commits zuvor:
imapflow wirft `new Error('Command failed')` und legt Details in
`.responseText`/`.responseStatus` ab, wir haben sie nirgends
gelesen.
Fix: humanizeImapError() als zentraler Helper in imapService:
- Extrahiert responseText/responseStatus aus imapflow-Errors
- Erkennt Auth-Fehler ("authentication failed", "invalid
credentials", NO+auth) und liefert klare Meldung mit
Handlungsanweisung: "Passwort stimmt nicht mehr, bitte
Zugangsdaten synchronisieren".
- Deckt zusätzlich Netzwerk/TLS/Timeout/UID-Stale ab.
Angewendet auf:
- fetchAttachmentInner (Anhang-Fetch)
- downloadAttachment-Controller (Response an UI)
- syncEmailsForAccount (Sync-Ergebnis + Toast)
Damit sieht der User im Toast künftig statt "Command failed" die
tatsächliche Ursache – gleicher Mechanismus für Sync und
Anhang-Download.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Anhang-Download schlug mit "Fehler beim Herunterladen des
Anhangs: Command failed" fehl – ohne jeden Kontext, warum.
Ursache: imapflow wirft bei jedem IMAP-NO/BAD-Response nur
`new Error('Command failed')` und legt den echten Server-Grund
in `.responseText` / `.responseStatus` ab. Unser Code las nur
`.message` und verlor damit alle Information.
Fix:
- fetchAttachmentInner extrahiert responseText/responseStatus
aus dem imapflow-Error und packt sie in die geworfene Meldung.
- downloadAttachment-Catch macht das gleiche, damit auch andere
IMAP-Fehler (mailboxOpen, connect) mit sinnvollem Text
durchkommen.
- Zusätzlicher Friendly-Mapping-Fall für "no longer exist" /
"no such message" → klare Meldung, dass die E-Mail-Liste
neu synchronisiert werden sollte.
Damit sieht der User im UI jetzt z.B. "NO Some of the requested
messages no longer exist" statt "Command failed" und weiß, dass
ein Resync hilft.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neues optionales Freitext-Feld `description` (TEXT NULL) auf
BankCard – z.B. "Geschäftskonto", "gemeinsames Konto mit Partner".
Migration mit IF NOT EXISTS.
Backend service create/update nimmt description entgegen.
Frontend:
- BankCard-Type um description ergänzt.
- BankCardModal: neue Textarea zwischen Ablaufdatum und Aktiv-
Checkbox mit Placeholder-Hilfetext.
- Bank-Kartenübersicht zeigt die Beschreibung kursiv unter den
bestehenden Feldern, falls gesetzt (whitespace-pre-line für
mehrzeilige Notizen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
INFO-Finding: GET/PUT /api/customers/:id/salutation-preference
warfen bei nicht-existierendem Customer 500 statt 404.
canAccessCustomer prüft für Staff-User nur den Portal-Flag, kein
Existenz-Check. Der Service warf `new Error('Kunde nicht
gefunden')`, was der Controller-Catch generisch auf 500 mappte.
set/clear kamen als Prisma-P2003 (FK-Constraint) durch.
Fix:
- Neuer Helper assertCustomerExists() im customer.service, wirft
ApiError(404, ...). Wird von get/set/clearSalutationPreference
aufgerufen.
- Die drei Controller-Catches respektieren jetzt
ApiError.statusCode → 404 kommt sauber durch, alle anderen
Fehler bleiben bei 500.
Doku: docs/todo.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neue Tabelle UserCustomerSalutation (PK userId+customerId,
preference 'DU'|'SIE'). Fehlender Eintrag → Fallback auf
Customer.useInformalAddress (Kunden-Default).
Backend:
- customerService: get/set/clearSalutationPreference mit Fallback-
Logik. Response enthält immer effektive Präferenz + `source`
('user' | 'customer-default'), damit die UI den Standard-Text
anzeigen kann.
- customerController: 3 Endpunkte GET/PUT/DELETE
/:customerId/salutation-preference. userId aus dem JWT, canAccessCustomer
greift.
Frontend:
- customerApi: 3 neue Methoden.
- CustomerDetail: neues Feld "Anrede für mich" mit Du/Sie-Toggle
und Zurücksetzen-Link. Wird nur Mitarbeitern angezeigt, nicht
Portal-Usern.
- Bestehendes Feld "Anrede per" bekommt Hinweis "Standard für alle
Mitarbeiter", damit die Semantik der beiden Felder klar ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Enum-Wert ONGOING neu, MariaDB-Migration (ans Ende gehängt, damit
keine Rows umgeschrieben werden). Prisma Client neu generiert.
Cockpit-Logik: ONGOING wird in die Query mit reingeholt (damit
DSGVO-/Consent-Warnungen weiter greifen), aber alle Fristen-
Blöcke pro Vertrag geskippt:
- Kündigungsfrist
- Vertragsende
- Zwischenrechnung-Frist
Kein Skip für Daten-Qualität (fehlende Adresse, Bank, Portal-
Zugang etc.) – die sind auch für unbefristete Verträge relevant.
Frontend: statusLabels/statusVariants/statusDescriptions in
ContractDetail, ContractList, CustomerDetail und
ContractDetailModal um ONGOING ergänzt. Status-Select im
ContractForm bekommt die neue Option zwischen ACTIVE und
CANCELLED.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Suche und Add haben bisher nur Kunden mit portalEnabled=true zugelassen –
das machte das Feature quasi unnutzbar, weil die meisten Kunden kein
Portal aktiviert haben. Der Zugriff ist ohnehin erst dann effektiv,
wenn der Vertreter ein Portal-Konto bekommt.
- searchCustomersForRepresentative: portalEnabled-Filter raus, dafür
Feld im select mitgeliefert.
- addRepresentative: Portal-Pflicht-Check raus.
- getRepresentedByList: portalEnabled im rep-Include, damit die UI
auch für schon hinterlegte Vertreter das Badge zeigen kann.
- CustomerDetail: gelbes "Portal inaktiv"-Badge in Suchergebnissen
und in der Vertreter-Liste. Hinweistext geändert.
- CustomerSummary-Type: portalEnabled? ergänzt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: Im Vertrags-Tab (Gesendet/Gelöscht) und im Kunden-Haupt-
Postfach (Gelöscht) wurden Mails aus ALLEN Postfächern angezeigt,
unabhängig vom ausgewählten Postfach. Im Vertrag fehlte zusätzlich
der Vertrags-Filter im Papierkorb.
Backend:
- getEmailsForContract akzeptiert accountId → stressfreiEmailId
- getTrashEmails (controller + service) nimmt {accountId, contractId}
- getFolderCountsForContract bekommt optional stressfreiEmailId und
zusätzlich trash/trashUnread im Result
Frontend:
- API-Client (getForContract/getTrash/getContractFolderCounts) nimmt
Filter entgegen
- ContractEmailsSection reicht selectedAccountId in alle drei Queries
+ queryKey durch. Trash-Badge kommt jetzt aus contract-scoped
Counts statt account-globalem stressfreiEmailApi
- EmailClientTab reicht selectedAccountId in die Trash-Query durch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
R89.1 MEDIUM + R89.2 LOW: sanitizeNotes(…, 500) macht silent
slice(0, 500) statt 400, und stripHtml lief vor dem Length-
Check – `<script>…</script>` reduzierte auf "" → null in DB
→ vorheriger Wert silent überschrieben (R87.1-Pattern auf
Adress-Feldern).
Fix: validateProviderAddress() in sanitize.ts – Raw-Input,
max 500 mit ApiError(400), Blacklist <, >, Tab + alle
Control-Chars außer \n. CRLF → LF VOR dem Length-Check, damit
Editoren mit \r\n-Line-Endings nicht doppelt zählen. Eingehängt
in stripProviderStrings für contactAddress/cancellationAddress.
R89.3/R89.4 (Quotes/\n) bewusst akzeptiert – Pentester selbst
sagt "kein Risiko", sind in Adressen legitim.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sieben neue optionale Felder am Provider (contactEmail,
contactPhone, contactFax, contactAddress, cancellationEmail,
cancellationFax, cancellationAddress). Postadressen TEXT,
Rest VARCHAR(191). Migration mit IF NOT EXISTS.
Modal "Anbieter bearbeiten" bekommt neue Sektion "Kontakt &
Kündigung" mit zwei Untergruppen. Backend validiert Emails
gegen isValidEmail (Header-Injection-Schutz), Telefon/Fax
gegen sanitizePhoneField (kein CRLF), Postadressen via
sanitizeNotes mit 500-Cap. Factory-Defaults Export/Import
mitgezogen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contract.orderNumberAtSalesPlatform (VARCHAR(191) NULL) mit
Migration 20260619100000_contract_order_number_at_sales_platform
(IF NOT EXISTS). Form-Input, Detail-Zeile mit Copy-Button,
Audit-Mapping, Renewal-Copy und XSS-Strip-Allowlist analog zu
den bestehenden Sales-Platform-Feldern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>