L1 Stammdaten: Lane-Bericht und mobile Suchfelder

Bericht docs/craftvia/lanes/stammdaten.md (Umfang, Dateien, Tests, Smoke,
offene Punkte) sowie umbrechende Suchfelder der Kunden- und Objektliste bei
schmalen Viewports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:37:07 +02:00
co-authored by Claude Opus 5
parent 6423351035
commit f17eeaf47b
3 changed files with 100 additions and 2 deletions
+98
View File
@@ -0,0 +1,98 @@
# Lane L1 – Stammdaten
Branch `lane/stammdaten` (Basis `bf44567` auf `feature/craftvia-mvp`). Spec: §7 Kunden, §8 Objekte,
§11 Teams, §24 Dokumente (Objekt-/Kunden-Dokumente), US-003, US-005, US-011.
Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten.
## 1. Umfang / erfüllte Spec-Punkte
| Punkt | Umsetzung |
|---|---|
| §7.1 Kundendaten | alle Felder, Status aktiv/inaktiv/vorläufig/zusammengeführt, Anlage/Änderung als Popup, Soft Delete (blockiert bei offenen Aufträgen) |
| Kundennummer | `nextNumber(…, "customer")` (K-00001), manuell überschreibbar, eindeutig je Mandant; der Nummernkreis überspringt manuell vergebene Nummern |
| §7.2 Ansprechpartner | mehrere je Kunde, Funktion, Telefon/Mobil/E-Mail, bevorzugter Kontaktweg, Bemerkungen, Soft Delete |
| §7.3 / US-003 Dubletten | `src/lib/customers/duplicates.ts` (Normalisierung: Kleinschreibung, Umlaute/ß, Rechtsformen, Straße/Str., Telefon nur Ziffern, +49→0; Scoring 0..1 als probabilistisches ODER über Kundennummer 1.0 · E-Mail 0.6 · Name exakt 0.6 / ähnlich 0.45 · Telefon 0.5 · Adresse 0.4/0.25; Schwelle 0.4) + `services/customers/duplicates.ts#findDuplicateCustomers(ctx, candidate, { excludeId?, limit? })` → `{ customerId, score, reasons, customerNumber, displayName, city, status }[]` (Obermenge des Vertrags). Manuelle Anlage zeigt „Mögliche Dublette“ mit Link auf bestehende Kunden und „Trotzdem neu anlegen“ |
| Zusammenführen | `mergeCustomers(ctx, { sourceId, targetId, confirm: true })` nur mit `customer:merge` + Bestätigungs-Checkbox; hängt Kontakte, Objekte, Aufträge (Version +1 für Offline-Konflikte), Dokumente um; Quelle → `merged` + `mergedIntoId`; Audit für Quelle und Ziel; nie automatisch |
| Vorläufige Kunden | `confirmProvisionalCustomer(ctx, id)` (provisional → active), Filter „Vorläufig“, Hinweisbanner; für L8 nutzbar |
| §8.1 Objekte | alle Felder inkl. Zugangs-/Park-/Sicherheits-/technische Hinweise (hervorgehoben, Sicherheit mit Warnkante + Icon), Kontakt des Kunden oder Freitext, Koordinaten optional, Kartenlink OpenStreetMap (Koordinaten oder Adresse, kein Embed) |
| §8.2 / §24 Dokumente | Tab „Dokumente“ an Kunde und Objekt: Upload (Kategorie, Sichtbarkeit, Titel), neue Version, Versionsliste, Metadaten bearbeiten, Soft Delete; `/documents` Backoffice-Übersicht mit Filter Kategorie/Kunde/Objekt/Auftragsnummer, „nur aktuelle Versionen“ |
| §8.3 / US-011 Historie | `getSiteHistory(ctx, siteId, { onlyApproved, page, pageSize })`: neueste zuerst, Datum (Einsatzbeginn/Termin), Auftrag, Auftragsart, Team, Status (Brandbook-Statusgruppe), durchgeführte Arbeiten (ActivityNote work_done), Material aggregiert (ohne not_used), Fotoanzahl, Links auf freigegebene Berichte, Unterschrift ja/nein, offene Folgearbeiten (followUpWork + follow_up-Notizen) mit Warnkante/Icon/Text |
| §11.1 Teams | Liste + Popup: Teamleiter, Mitglieder mit gültig ab/bis, Telefon, Fahrzeug, Einsatzgebiet, interne Hinweise, Status; nur aktive Mandanten-Nutzer; Soft Delete (blockiert bei offenen Aufträgen) |
| §4.3 Dokumenten-Service | `storeFile` (Allowlist PDF/JPEG/PNG/WebP/HEIC/Audio, Magic Bytes vs. deklarierter Typ, Limits Bild 15 MB / PDF 25 MB / Audio 20 MB, Dateiname normalisiert, SHA-256, `storage.put`, Versionierung über `lineageId`), `FileScanner` (Magic Bytes + ClamAV-INSTREAM, wenn `CLAMAV_HOST` gesetzt), `getDownloadUrl` → `/files/<documentId>` |
| Download-Route | `src/app/(app)/files/[documentId]/route.ts` ersetzt `files/[...key]`: Session + DB-autoritatives `document:read` → Dokument im Mandanten → `allowedDocumentVisibility` → Auftrag in `workOrderScope` bzw. (ohne Auftrag) Objekt in `siteScope` / Kunde in `customerScope`; sonst 404; `attachment` + `nosniff` + `no-store` |
| §29 API | `GET/POST /api/v1/customers`, `GET/PATCH /api/v1/customers/[id]`, `GET/POST /api/v1/sites`, `GET /api/v1/sites/[id]/history`; Paginierung `?page&pageSize` → `{ data, pagination }`; Fehler `{ error: { code, message, details? } }` |
## 2. Querschnitt, den andere Lanes nutzen
- `src/server/api/context.ts`
- `requireApiContext(moduleKey | null, ...permissions) → ServiceCtx` – Session-Cookie wie Guard; Mitgliedschaft, Identity-Status, Kill-Switch, Passwortzwang und **effektive Rechte aus der DB**; Fehler als `ApiError` (401/403); `moduleKey = null` für modulübergreifende Endpunkte (Downloads).
- `assertSameOrigin(req)` – CSRF-Schutz (Origin/Sec-Fetch-Site) für schreibende Route Handler.
- `requirePageContext(moduleKey)` – Lesekontext für Seiten (Modul-Gate + JWT-Rechte, wie in AGENTS.md für Lesepfade vorgesehen).
- `src/server/api/respond.ts` – `withApi`, `toErrorResponse` (ServiceError/ZodError/ForbiddenError/ModuleDisabledError/Isolation → HTTP), `json`, `paginated`, `parsePagination`, `readJson`.
- `src/server/api/action-state.ts` – einheitlicher Rückgabetyp für Formular-Actions (Fehlercodes statt Servermeldungen, CWE-209).
- `src/server/services/documents/{store,access,scanner}.ts` – Dokumenten-Service (siehe oben), `listDocuments`, `documentReadWhere`, `authorizeDocumentAccess`, `openDocumentContent`.
- `src/components/sites/site-history.tsx` – Server-Komponente, von L4 read-only einbindbar (`linkOrders={false}`).
- Upload aus dem Browser: `POST /documents/upload` (multipart; 303-Redirect mit `?docOk`/`?docError` oder JSON bei `Accept: application/json`).
## 3. Dateien
- Services: `src/server/services/customers/{customers,contacts,duplicates,merge,schemas,format}.ts`, `src/server/services/sites/{sites,history,map-link}.ts`, `src/server/services/teams/teams.ts`, `src/server/services/documents/{store,access,scanner}.ts`
- Lib: `src/lib/customers/duplicates.ts`
- API-Hilfen: `src/server/api/{context,respond,action-state}.ts`
- Actions: `src/server/actions/customers/{customers,contacts}.ts`, `src/server/actions/sites/sites.ts`, `src/server/actions/teams/teams.ts`, `src/server/actions/documents/documents.ts` (alle `moduleGuard` + `await guard(...)`)
- Routen: `src/app/(app)/customers/{page,[id]/page}.tsx`, `src/app/(app)/sites/{page,[id]/page}.tsx`, `src/app/(app)/teams/page.tsx`, `src/app/(app)/documents/{page.tsx,upload/route.ts}`, `src/app/(app)/files/[documentId]/route.ts` (alt `files/[...key]` entfernt), `src/app/api/v1/customers/{route,[id]/route}.ts`, `src/app/api/v1/sites/{route,[id]/history/route}.ts`
- Komponenten: `src/components/customers/{form-ui,action-form,status,customer-form,contact-form,merge-form}.tsx`, `src/components/sites/{site-form,site-history}.tsx`, `src/components/teams/team-form.tsx`, `src/components/documents/{document-panel,document-table,document-upload-form,document-edit-form}.tsx`
- Texte: `messages/{de,en}/{customers,sites,teams,documents}.json`
- Fremd-Einzeiler: `src/components/audit-trail.tsx` (Entity-Label `contact: "Ansprechpartner"`)
- Tests: `scripts/test-stammdaten-{duplicates,customers,sites,teams,documents}.ts`, Fixtures `scripts/lib-stammdaten-fixtures.ts`
## 4. Tests
`npm run gate` grün: prisma generate, tsc, lint (0 Fehler; 2 Warnungen im L6-Platzhalter `handle-event.ts`), build inkl. Modul-Guard-Check, **27/27 Testskripte** (davon 5 neu).
| Skript | Inhalt |
|---|---|
| `test-stammdaten-duplicates` | Normalisierung, Scoring/Schwelle, DB-Suche inkl. formatierter Telefonnummern, `excludeId`, zusammengeführte ausgeschlossen, **Mandant B findet nichts von A**, **Monteur ohne Auftrag findet nichts** |
| `test-stammdaten-customers` | Nummernkreis/manuelle Nummer/Eindeutigkeit je Mandant, Dublettenhinweis + Bestätigung, Validierung, Audit before/after, Ansprechpartner, vorläufig → aktiv, **Mandantentrennung** (lesen/ändern/Kontakt/bestätigen/löschen/zusammenführen → not_found), **Monteur** ohne Zuweisung not_found / mit Teamauftrag sichtbar / keine Schreibrechte, Merge (Rechte, Bestätigung, keine Mandantenkreuzung, Umhängen, Versionserhöhung, Audit, doppelt → conflict), Soft Delete |
| `test-stammdaten-sites` | Objekt-CRUD, Kontakt/Kunde-Validierung, Koordinaten, Kartenlink, Historie (Reihenfolge, Arbeiten, interne Notizen ausgeblendet, Material aggregiert, Fotos, Berichte, Unterschrift, Folgearbeiten, Paginierung), **Monteur sieht nur freigegebene Einsätze**, ohne sichtbaren Auftrag not_found, **Mandantentrennung**, Soft Delete |
| `test-stammdaten-teams` | Anlage, Mitglieder gültig ab/bis, fremder/inaktiver Nutzer abgelehnt, doppelte Mitglieder, Namenskonflikt, Wirkung auf `activeTeamIds`, Monteur liest aber verwaltet nicht, **Mandantentrennung**, Soft Delete |
| `test-stammdaten-documents` | Scanner (Magic Bytes, Mismatch, Allowlist, Alias), Dateinamen, **falscher Magic Byte → abgelehnt**, leer/zu groß/Sichtbarkeit, SHA-256, Versionierung, **backoffice_only für Monteur verweigert**, team_lead nur Teamleiter, Auftrags- und Objekt-Scope, **fremder Mandant verweigert**, Listenfilter, Byte-Roundtrip Garage, Metadaten/Soft Delete |
**Smoke gegen den Dev-Server** (Port 3101, Seed-Nutzer `backoffice@demo.example`, `monteur@demo.example`, `admin2@demo.example`; Session-Cookie lokal über `finalizeIdentityLogin` + Auth.js-`encode` ausgestellt, ohne Passworteingabe): **alle Prüfungen grün**.
- API: Anlage 201 mit K-Nummer, Dublette 409 `possible_duplicates`, Validierung 422 `invalid`, Cross-Site-Origin 403, Liste/Suche mit `pagination.total`, Detail, PATCH, Objekt anlegen, Historie `meta.onlyApproved`; Monteur ohne Zuweisung 404/403; Mandant demo2 → 404.
- Dateien: Upload → `?docOk=1`, falscher Magic Byte → `?docError=type_mismatch`, externes `returnTo` → kein Open Redirect, JSON-Upload, Download 200 byte-identisch mit `attachment` + `nosniff`, `backoffice_only` als Monteur 404, fremder Mandant 404, ohne Session Redirect.
- Server-Rendering (200 + erwarteter Text): `/customers` (+ `?status=provisional`, `?new=1`), `/customers/[id]` mit allen Tabs sowie `?edit=1`/`?merge=1`, `/sites` (+ `?new=1&customerId=`), `/sites/[id]` (Stammdaten, Kartenlink, Dokumente inkl. Upload-Rückmeldungen, Historie, `?edit=1`), `/teams` (+ `?new=1`, Monteur „Nur Lesezugriff.“), `/documents` (+ Kategoriefilter); Monteur `/customers/[id]` → 404, demo2 `/sites/[id]` → 404.
- Visuell im Browser: Kundenliste, Anlage-Popup, Ansprechpartner über das Client-Formular angelegt (Server Action → Liste aktualisiert); Viewport 800 px und 375 px.
## 5. Stubs / Abhängigkeiten zu anderen Lanes
- Keine Stubs nötig. Genutzt werden die Verträge aus dem Architektur-Commit (`visibility.ts`, `numbering.ts`, `status.ts`, `storage/adapter.ts`).
- Links auf `/work-orders/[id]` (L2) und `/reports/[id]` (L5) zeigen bis zum Merge ins Leere (404).
- Vertrag für L3: Architektur nennt `findDuplicateCustomers(db, candidate)`, umgesetzt ist (wie im Lane-Auftrag) `findDuplicateCustomers(ctx, candidate, opts?)`; Rückgabe ist eine Obermenge von `{ customerId, score, reasons }`.
- L4/L5/L8 legen Fotos, Sprachnotizen, Unterschriften, Berichts-PDFs über `storeFile` ab: mit `workOrderId` genügt `field:execute`, `report:write`, `emergency:create` oder `document:write` + Auftrag im Scope; unverknüpfte Originale (Import) brauchen `document:write` oder `import:write`.
## 6. Bekannte Lücken / offene Punkte
1. **Fundament – Proxy:** `/api/v1/**` ohne Session-Cookie wird von `src/proxy.ts` auf `/login` umgeleitet (307) statt `401` JSON. Vorschlag: `/api/v1` im Proxy durchlassen (die Handler prüfen selbst über `requireApiContext`).
2. **Fundament – Upload-Größe:** Mit aktivem Proxy puffert Next.js Request-Bodies nur bis `proxyClientMaxBodySize` (Default 10 MB). PDFs bis 25 MB brauchen `experimental.proxyClientMaxBodySize: "26mb"` in `next.config.ts` (nicht in dieser Lane geändert). Größere Uploads werden bis dahin mit `too_large` abgewiesen.
3. **Fundament – DSGVO:** Personenreferenzen der Fachmodelle (u. a. `Customer.createdById`, `Document.uploadedById`, `Team.leaderUserId`, `TeamMember.userId`) sind noch nicht in `src/server/dsgvo/pii-fields.ts` eingetragen (AGENTS.md Regel 6).
4. **Historie für Monteure (Entscheidung zur Prüfung):** Das Objekt muss über einen sichtbaren Auftrag erreichbar sein (`siteScope`). Dann sieht der Monteur **alle freigegebenen** Einsätze am Objekt, auch die anderer Teams (US-005 „freigegebene frühere Berichte“). Nicht freigegebene fremde Einsätze bleiben verborgen. Soll strikt nur `workOrderScope` gelten, ist das eine Zeile in `services/sites/history.ts`.
5. Downloads werden nicht modulgegated (`moduleKey = null`), weil Fotos/Berichte modulübergreifend sind; Rechte, Sichtbarkeit und Scope gelten weiterhin. Downloads werden nicht auditiert.
6. Soft Delete eines Dokuments löscht das Objekt im Speicher nicht (Aufbewahrung). Die Tests hinterlassen kleine Testobjekte im Garage-Bucket (Adapter hat kein `delete`).
7. Objekt-Formular: Die Kontaktauswahl steht nur bei bekanntem Kunden bereit (Bearbeiten bzw. Anlage aus dem Kundendetail). Bei freier Anlage folgt die Auswahl nach dem Speichern.
8. Dublettensuche: Namens-/PLZ-/E-Mail-Vorfilter per SQL (max. 200 Kandidaten), Telefonvergleich über die Telefonspalten im Speicher (max. 10 000 Zeilen) – für MVP-Größen ausreichend; bei großen Mandanten normalisierte Suchspalten ergänzen.
9. OpenAPI-Dokumentation der Endpunkte folgt mit L10.
10. **Fundament – Backoffice-Layout mobil:** Bei 375 px belegt die feste Sidebar (`w-60` in `src/app/(app)/layout.tsx`) den Großteil der Breite. Die L1-Seiten umbrechen Formulare/Filter und scrollen Tabellen horizontal, eine einklappbare Sidebar fehlt aber (Mobile-Zielgruppe nutzt `/m`, L4).
## 7. Screens / Routen
| Route | Inhalt |
|---|---|
| `/customers` | Suche, Statusfilter inkl. „Vorläufig“, 25 je Seite, „Neuer Kunde“ (`?new=1`, Dublettenhinweis) |
| `/customers/[id]` | Tabs Stammdaten · Ansprechpartner (`?contact=new|<id>`) · Objekte · Aufträge (Lesesicht → `/work-orders/[id]`) · Dokumente; Aktionen Bearbeiten (`?edit=1`), Kunden bestätigen, Zusammenführen (`?merge=1`), Löschen (`?delete=1`) |
| `/sites` | Suche, Statusfilter, 25 je Seite, „Neues Objekt“ (`?new=1&customerId=`) |
| `/sites/[id]` | Tabs Stammdaten (Hinweise hervorgehoben, Kartenlink) · Dokumente · Historie (Backoffice: alle / nur freigegebene) |
| `/teams` | Liste mit Mitgliedern (aktuell/beendet/ab Datum), Popup Anlage/Bearbeitung/Löschen; Monteur: Nur-Lese-Ansicht |
| `/documents` | Übersicht mit Filtern, Upload, Bearbeiten, neue Version, Entfernen |
| `/files/[documentId]` | autorisierter Download |
| `/documents/upload` | Upload-Endpunkt (POST, multipart) |
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function CustomersPage({ searchParams }: { searchParams: Se
/> />
<form method="get" action="/customers" className="mb-4 flex flex-wrap items-end gap-2" role="search"> <form method="get" action="/customers" className="mb-4 flex flex-wrap items-end gap-2" role="search">
<label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[12.5px] font-semibold"> <label className="flex w-full min-w-0 flex-col gap-1 text-[12.5px] font-semibold sm:w-auto sm:min-w-[14rem] sm:flex-1">
{t("searchLabel")} {t("searchLabel")}
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} /> <input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
</label> </label>
+1 -1
View File
@@ -67,7 +67,7 @@ export default async function SitesPage({ searchParams }: { searchParams: Search
<form method="get" action="/sites" className="mb-4 flex flex-wrap items-end gap-2" role="search"> <form method="get" action="/sites" className="mb-4 flex flex-wrap items-end gap-2" role="search">
{filterCustomer && <input type="hidden" name="customerId" value={filterCustomer} />} {filterCustomer && <input type="hidden" name="customerId" value={filterCustomer} />}
<label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[12.5px] font-semibold"> <label className="flex w-full min-w-0 flex-col gap-1 text-[12.5px] font-semibold sm:w-auto sm:min-w-[14rem] sm:flex-1">
{t("searchLabel")} {t("searchLabel")}
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} /> <input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
</label> </label>