feat: prevent duplicate resource-project assignments

Engine (packages/engine):
- New checkDuplicateAssignment() pure function: detects same resource
  assigned to same project with overlapping dates
- 15 unit tests covering: overlap, no-overlap, cancelled, self-exclude,
  string dates, PROPOSED status

Application layer (packages/application):
- createAssignment: throws CONFLICT before DB write if duplicate found
- fillDemandRequirement: same check before entering transaction

AI Assistant (packages/api/router/assistant-tools.ts):
- create_allocation: checks before creating, returns helpful error message
- fill_demand: same check using demand's projectId

UI (apps/web):
- AllocationModal: amber warning when resource already assigned to
  selected project with overlapping dates (non-blocking)

Database cleanup:
- Found and merged 1 duplicate: Wong Wong on Porsche Taycan Sport Film
  (2 overlapping PROPOSED assignments merged into 1)

Regression: 298 engine tests pass (283 + 15 new). TypeScript clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-23 08:51:49 +01:00
parent 1f079d0309
commit 47b2aeec72
8 changed files with 408 additions and 189 deletions
+117 -186
View File
@@ -1,78 +1,17 @@
# Activity History System — Detailed Plan
# Duplicate Assignment Prevention — Plan
## Anforderungsanalyse
**Ziel:** Ein lueckenloses Aenderungsprotokoll, das jede Mutation im System erfasst und ueber UI und AI Assistant abfragbar macht. Nutzer sollen fragen koennen: "Wer hat die Buchung von Person X geaendert?" oder "Was ist in den letzten Tagen bei Projekt Y passiert?"
**Problem:** Ressourcen koennen demselben Projekt mehrfach zugewiesen werden mit ueberlappenden Zeitraeumen. Beispiel: Wong Wong ist zweimal fuer "Porsche Taycan Sport Film" am 15. April eingetragen.
**Ist-Zustand:**
- AuditLog-Modell existiert (entityType, entityId, action, userId, changes JSONB, createdAt)
- 10 von 36 Routern loggen Aenderungen (44% Abdeckung)
- userId wird nur in ~60% der Faelle erfasst
- Kein Query-Endpoint (write-only)
- Keine UI zum Anzeigen der Historie
- AI Assistant kann keine Historie abfragen
- Inkonsistente before/after Snapshots
**Ursache:** Weder die Application-Layer-Funktionen (`createAssignment`, `fillDemandRequirement`) noch die API-Router pruefen, ob dieselbe Resource bereits eine aktive Zuweisung zum selben Projekt im selben Zeitraum hat. Die bestehende `validateAvailability` prueft nur die Gesamt-Stunden (Overbooking), nicht Projekt-Duplikate.
**Soll-Zustand:**
- 100% Mutation-Abdeckung ueber alle Router
- Konsistente before/after Snapshots mit User-Attribution
- Query-API mit Filtern (entityType, entityId, userId, dateRange, action)
- Admin-UI: `/admin/activity-log` mit suchbarer, filterbarer Timeline
- Entity-Detail-Seiten: "History"-Tab/-Drawer auf Project/Resource/Allocation
- AI Assistant Tool: `query_change_history` fuer natuerlichsprachliche Abfragen
- Change-Source Tracking: UI vs API vs AI vs Import
**Loesung:** Duplicate-Check an **3 Stellen** einfuegen (defense-in-depth):
1. **Application Layer**`checkDuplicateAssignment()` Funktion im Engine-Paket
2. **API Layer** — Validierung in den Mutations vor dem Create
3. **AI Assistant**`create_allocation` und `fill_demand` Tools pruefen vor Ausfuehrung
---
## Architektur-Entscheidungen
### 1. Audit Middleware statt manuelle Calls
**Entscheidung:** tRPC Middleware die automatisch vor/nach jeder Mutation auditiert
**Grund:** Eliminiert vergessene `auditLog.create()` Calls, garantiert 100% Abdeckung
**Umsetzung:** Middleware auf `protectedProcedure` die:
- Vor der Mutation: Entity-Snapshot speichert (before)
- Nach der Mutation: Neuen Snapshot speichert (after)
- Diff berechnet und AuditLog-Entry erstellt
### 2. Standardisiertes Changes-Format
```typescript
interface AuditChanges {
before?: Record<string, unknown>; // Snapshot vor der Aenderung
after?: Record<string, unknown>; // Snapshot nach der Aenderung
diff?: Record<string, { old: unknown; new: unknown }>; // Nur geaenderte Felder
metadata?: {
source: "ui" | "api" | "ai" | "import" | "cron"; // Wer hat die Aenderung ausgeloest
reason?: string; // Optionaler Kommentar
ip?: string; // Request IP (optional)
batchId?: string; // Fuer Bulk-Operationen
};
}
```
### 3. Schema-Erweiterungen
```prisma
model AuditLog {
// Existierende Felder behalten
id String @id @default(cuid())
entityType String
entityId String
action AuditAction
userId String?
user User? @relation(fields: [userId], references: [id])
changes Json @db.JsonB
createdAt DateTime @default(now())
// NEU: Zusaetzliche Felder
source String? // "ui" | "api" | "ai" | "import" | "cron"
entityName String? // Menschenlesbarer Name (z.B. "Porsche Taycan Project")
summary String? // Einzeiler: "Changed status from DRAFT to ACTIVE"
@@index([entityType, entityId])
@@index([userId])
@@index([createdAt])
@@index([entityType, createdAt]) // NEU: Fuer sortierte Timeline-Queries
}
```
**Scope:** Betrifft `packages/engine`, `packages/application`, `packages/api`, UI (Warnmeldung).
---
@@ -80,158 +19,150 @@ model AuditLog {
| Paket | Dateien | Art der Aenderung |
|-------|---------|------------------|
| `packages/db` | `prisma/schema.prisma` | **edit**AuditLog um source, entityName, summary erweitern |
| `packages/api` | `src/lib/audit.ts` | **create**`createAuditEntry()` Helper + `auditMiddleware` |
| `packages/api` | `src/router/audit-log.ts` | **create**Query-Router (list, getByEntity, getTimeline) |
| `packages/api` | `src/router/index.ts` | **edit** — auditLog Router registrieren |
| `packages/api` | `src/router/assistant-tools.ts` | **edit**`query_change_history` Tool hinzufuegen |
| `packages/api` | 26 Router-Dateien | **edit** — fehlende audit Calls nachruesten |
| `apps/web` | `src/app/(app)/admin/activity-log/page.tsx` | **create**Activity Log Seite |
| `apps/web` | `src/components/admin/ActivityLogClient.tsx` | **create**Suchbare Timeline |
| `apps/web` | `src/components/ui/EntityHistory.tsx` | **create** — Wiederverwendbare History-Komponente |
| `apps/web` | `src/components/layout/AppShell.tsx` | **edit**Nav-Link fuer Activity Log |
| `packages/engine` | `src/allocation/duplicate-check.ts` | **create**Pure Funktion `checkDuplicateAssignment()` |
| `packages/engine` | `src/index.ts` | **edit**Export hinzufuegen |
| `packages/application` | `src/use-cases/allocation/create-assignment.ts` | **edit**Duplicate-Check vor DB-Write |
| `packages/application` | `src/use-cases/allocation/fill-demand-requirement.ts` | **edit** — Duplicate-Check vor DB-Write |
| `packages/api` | `src/router/allocation.ts` | **edit**Duplicate-Check in `create`, `createAssignment` Mutations |
| `packages/api` | `src/router/assistant-tools.ts` | **edit** — Check in `create_allocation`, `fill_demand` Tools |
| `packages/api` | `src/router/timeline.ts` | **edit**Check in `batchShiftAllocations` (falls Shift Duplikat erzeugt) |
| `apps/web` | `src/components/allocations/AllocationModal.tsx` | **edit**Warning anzeigen wenn Duplikat erkannt |
| `apps/web` | `src/components/staffing/StaffingPanel.tsx` | **edit** — Warning im Assign-Formular |
| `packages/engine` | `src/__tests__/duplicate-check.test.ts` | **create**Unit Tests |
---
## Task-Liste (atomare Schritte)
## Task-Liste
### Phase 1: Infrastruktur (Basis)
### Phase 1: Engine — Pure Duplicate-Check Funktion
- [ ] **Task 1:** Schema erweitern → `packages/db/prisma/schema.prisma`
- `source String?`, `entityName String?`, `summary String?` hinzufuegen
- Index `@@index([entityType, createdAt])` hinzufuegen
- `prisma db push` + `prisma generate`
- [ ] **Task 1:** Duplicate-Check Funktion erstellen → `packages/engine/src/allocation/duplicate-check.ts`
```typescript
interface ExistingAssignment {
id: string;
resourceId: string;
projectId: string;
startDate: Date;
endDate: Date;
status: string; // nur CONFIRMED, ACTIVE, PROPOSED zaehlen
}
- [ ] **Task 2:** Audit Helper erstellen → `packages/api/src/lib/audit.ts`
- `createAuditEntry(db, params)` — standardisierter Audit-Entry-Creator
- Params: `{ entityType, entityId, entityName, action, userId, before?, after?, source?, summary? }`
- Automatische Diff-Berechnung wenn before + after vorhanden
- Automatische Summary-Generierung aus Diff (z.B. "Updated name, status, budgetCents")
- `computeDiff(before, after)` — gibt nur geaenderte Felder zurueck
interface DuplicateCheckResult {
isDuplicate: boolean;
conflictingAssignment?: ExistingAssignment;
message?: string; // z.B. "Resource Wong Wong is already assigned to Porsche Taycan (2026-03-01 to 2026-06-30)"
}
- [ ] **Task 3:** Query Router erstellen → `packages/api/src/router/audit-log.ts`
- `list` query (controllerProcedure): paginiert, filterbar nach entityType, entityId, userId, action, dateRange, source
- `getByEntity` query: alle Entries fuer eine Entity, chronologisch
- `getTimeline` query: globale Timeline aller Aenderungen, gruppierbar nach Tag
- `getActivitySummary` query: Zusammenfassung (counts pro entityType, pro action, pro User) fuer einen Zeitraum
- Registrieren in `router/index.ts`
export function checkDuplicateAssignment(
resourceId: string,
projectId: string,
startDate: Date,
endDate: Date,
existingAssignments: ExistingAssignment[],
excludeAssignmentId?: string, // fuer Updates: eigene ID ausschliessen
): DuplicateCheckResult
```
- Prueft: Gibt es in `existingAssignments` eine Zuweisung mit **gleicher resourceId + gleicher projectId** deren Zeitraum sich mit [startDate, endDate] ueberschneidet?
- Ignoriert: CANCELLED Status, eigene ID (bei Updates)
- Overlap-Logik: `existingStart <= newEnd && existingEnd >= newStart`
### Phase 2: Audit-Abdeckung erweitern
- [ ] **Task 2:** Unit Tests → `packages/engine/src/__tests__/duplicate-check.test.ts`
- Kein Duplikat: verschiedene Projekte
- Kein Duplikat: gleicher Projekt, aber nicht ueberlappend (vor/nach)
- Duplikat: gleicher Projekt, vollstaendig ueberlappend
- Duplikat: gleicher Projekt, teilweise ueberlappend
- Kein Duplikat: gleicher Projekt, aber CANCELLED
- Kein Duplikat: Update der eigenen Zuweisung (excludeAssignmentId)
- [ ] **Task 4:** Kritische Router nachruesteen (Parallel-fähig, 4 Agents)
- **Agent A:** `vacation.ts` (8 Mutations), `entitlement.ts` (2), `user.ts` (9)
- **Agent B:** `client.ts` (5), `org-unit.ts` (3), `country.ts` (5), `management-level.ts` (5)
- **Agent C:** `rate-card.ts` (7), `blueprint.ts` (6), `settings.ts` (3), `calculation-rules.ts` (3)
- **Agent D:** `webhook.ts` (4), `comment.ts` (3), `notification.ts` (nur create/task), `dispo.ts` (4)
- Jeder Agent: `import { createAuditEntry } from "../lib/audit.js"` verwenden
- userId immer aus `ctx.dbUser?.id` nehmen
- [ ] **Task 3:** Export → `packages/engine/src/index.ts`
- [ ] **Task 5:** Bestehende Audit-Calls standardisieren
- Alle 37 existierenden `auditLog.create` Calls auf `createAuditEntry()` Helper umstellen
- userId konsistent aus Context nehmen
- before/after Snapshots wo fehlend ergaenzen
- `source: "ui"` als Default setzen
### Phase 2: Application Layer — Integration in Create-Flows
### Phase 3: UI
- [ ] **Task 4:** `createAssignment` erweitern → `packages/application/src/use-cases/allocation/create-assignment.ts`
- Nach dem Laden von `existingBookings` (Zeile 101-106): `checkDuplicateAssignment()` aufrufen
- Bei `isDuplicate: true`: `throw new TRPCError({ code: "CONFLICT", message: result.message })`
- Bestehende Bookings bereits vorhanden — nur filtern auf gleichen `projectId`
- [ ] **Task 6:** Activity Log Admin-Seite → `ActivityLogClient.tsx`
- Globale, suchbare Timeline aller Aenderungen
- Filter: Entity-Typ (Project/Resource/Allocation/...), User, Action, Datum
- Jeder Eintrag zeigt: Zeitstempel, User (Avatar + Name), Entity (verlinkt), Action-Badge, Summary
- Expandierbares Detail: before/after Diff-View (JSON oder tabellarisch)
- Pagination (50 pro Seite)
- Sidebar Nav-Link unter Admin: "Activity Log"
- [ ] **Task 5:** `fillDemandRequirement` erweitern`packages/application/src/use-cases/allocation/fill-demand-requirement.ts`
- Vor dem Assignment-Create: gleicher Check
- DemandRequirement hat bereits `projectId` — diesen nutzen
- [ ] **Task 7:** Entity History Komponente → `EntityHistory.tsx`
- Wiederverwendbar fuer Project/Resource/Allocation Detail-Seiten
- Props: `entityType: string, entityId: string`
- Chronologische Liste der Aenderungen fuer diese Entity
- Kompakte Darstellung: User, Action, Summary, Zeitstempel
- Optional: als Tab oder Drawer auf Detail-Seiten einbinden
### Phase 3: API + AI Assistant
- [ ] **Task 8:** History-Tab auf Detail-Seiten integrieren
- `/projects/[id]` → "History" Tab mit `<EntityHistory entityType="project" entityId={id} />`
- `/resources/[id]` → "History" Tab
- Optional spaeter: Allocation Detail, Estimate Detail
- [ ] **Task 6:** AI Assistant Tools erweitern → `packages/api/src/router/assistant-tools.ts`
- `create_allocation` Tool: Vor `createAssignment` Call, bestehende Assignments pruefen
- `fill_demand` Tool: Gleicher Check
- Bei Duplikat: Tool gibt klare Fehlermeldung zurueck statt Exception:
`"Cannot assign: Wong Wong is already assigned to Porsche Taycan Sport Film from 2026-01-15 to 2026-06-30. Use update_allocation_status to modify the existing assignment instead."`
### Phase 4: AI Assistant Integration
### Phase 4: UI Warnungen
- [ ] **Task 9:** AI Tool erstellen → `assistant-tools.ts`
- `query_change_history` Tool:
- Input: `{ entityType?, entityId?, userId?, search?, daysBack?, limit? }`
- Ruft `auditLog.list` mit Filtern auf
- Formatiert Ergebnis menschenlesbar:
```
[2026-03-22 14:30] admin@planarchy.dev UPDATED Project "Porsche Taycan"
→ Changed status from DRAFT to ACTIVE
→ Changed budgetCents from 500000 to 750000
```
- `get_entity_timeline` Tool:
- Input: `{ entityType, entityId, limit? }`
- Gibt chronologische History fuer eine Entity zurueck
- Beide Tools mit Permission `VIEW_PROJECTS` oder `VIEW_RESOURCES` je nach entityType
- [ ] **Task 7:** AllocationModal Warning → `apps/web/src/components/allocations/AllocationModal.tsx`
- Wenn User Resource + Project + Dates auswaehlt: pruefen ob Duplikat existiert
- Query: `trpc.allocation.listView({ projectId })` — bereits geladen
- Gelbe Warning-Box: "This resource is already assigned to this project from X to Y"
- Submit-Button nicht blockieren (Warning, nicht Error) — User kann bewusst doppelt buchen
- [ ] **Task 8:** StaffingPanel Assign Warning → `apps/web/src/components/staffing/StaffingPanel.tsx`
- Im AssignForm: nach Project-Auswahl pruefen ob Resource bereits dort zugewiesen
- Gleiche Warning-Box wie AllocationModal
### Phase 5: Bereinigung bestehender Duplikate
- [ ] **Task 9:** Cleanup-Script → `packages/db/scripts/deduplicate-assignments.ts`
- Findet alle Duplikate: gleiche resourceId + projectId mit ueberlappenden Dates
- Merged sie: behaelt die aeltere Zuweisung, entfernt die neuere (oder merged Zeitraeume)
- Dry-run Modus: zeigt was geaendert wuerde ohne zu aendern
- Kann via `pnpm --filter @planarchy/db exec tsx scripts/deduplicate-assignments.ts` ausgefuehrt werden
---
## Abhaengigkeiten
```
Task 1 (Schema) ──► Task 2 (Helper) ──► Task 3 (Query Router)
└──► Task 4a-d (Parallel: 26 Router)
└──► Task 5 (Bestehende Calls)
Task 3 ──► Task 6 (UI: Activity Log)
──► Task 7 (UI: Entity History)
──► Task 9 (AI Tools)
Task 7 ──► Task 8 (Integration in Detail-Seiten)
Task 1 (Engine Funktion) → Task 2 (Tests) → Task 3 (Export)
Task 3 → Task 4 + Task 5 (parallel, Application Layer)
Task 3 Task 6 (AI Assistant)
Task 3 → Task 7 + Task 8 (parallel, UI Warnungen)
Task 9 (Cleanup) ist unabhaengig, kann jederzeit ausgefuehrt werden
```
- Tasks 4a-d koennen **parallel** ausgefuehrt werden (unterschiedliche Dateien)
- Tasks 6, 7, 9 koennen **parallel** nach Task 3
- Task 8 benoetigt Task 7
- Tasks 4+5 koennen **parallel** (verschiedene Dateien)
- Tasks 6, 7, 8 koennen **parallel** (verschiedene Dateien)
- Task 9 sollte **nach** den anderen Tasks laufen (damit neue Duplikate verhindert werden)
---
## Akzeptanzkriterien
- [ ] `pnpm test:unit` laeuft gruen (inkl. neue duplicate-check Tests)
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
- [ ] `pnpm test:unit` — alle Tests gruen
- [ ] **100% Mutation-Abdeckung:** Jede Mutation in jedem Router erzeugt einen AuditLog-Entry
- [ ] **Konsistente userId:** Jeder Entry hat den ausfuehrenden User
- [ ] **before/after:** UPDATE-Actions haben immer before + after Snapshots
- [ ] **Query-API:** `trpc.auditLog.list` liefert paginierte, filterbare Ergebnisse
- [ ] **Admin UI:** `/admin/activity-log` zeigt globale Timeline mit Filtern
- [ ] **Entity History:** Project/Resource Detail-Seiten zeigen Aenderungs-Historie
- [ ] **AI Assistant:** "Wer hat die Buchung von Person X geaendert?" wird korrekt beantwortet
- [ ] **AI Assistant:** "Was ist bei Projekt Y in den letzten Tagen passiert?" liefert Ergebnis
- [ ] **API:** `createAssignment` wirft CONFLICT wenn Resource bereits zum gleichen Projekt zugewiesen
- [ ] **API:** `fillDemandRequirement` wirft CONFLICT bei Duplikat
- [ ] **AI Assistant:** `create_allocation` gibt klare Fehlermeldung bei Duplikat
- [ ] **AI Assistant:** `fill_demand` gibt klare Fehlermeldung bei Duplikat
- [ ] **UI:** AllocationModal zeigt gelbe Warning bei erkanntem Duplikat
- [ ] **UI:** StaffingPanel AssignForm zeigt Warning bei Duplikat
- [ ] **Cleanup:** Bestehende Duplikate in der DB bereinigt
- [ ] **Timeline:** Wong Wong hat keine doppelten Strips mehr am 15. April
---
## Risiken & offene Fragen
### Risiken
- **Performance:** Audit-Middleware auf jeder Mutation koennte Latenz erhoehen
→ Mitigation: Audit-Writes fire-and-forget (non-blocking), oder nach Response
- **Storage:** JSONB Snapshots koennen gross werden
→ Mitigation: Nur geaenderte Felder in `diff` speichern, nicht volle Snapshots
- **Migration:** 37 bestehende Calls umstellen birgt Regressions-Risiko
→ Mitigation: Schrittweise, mit Tests pro Router
- **False Positives:** Legitime Doppelbuchungen (z.B. verschiedene Rollen auf demselben Projekt) werden blockiert
→ Mitigation: Warning im UI, Error nur im API — User kann override-en, AI Assistant gibt Hinweis
- **Race Condition:** Zwei gleichzeitige Requests koennten beide den Check passieren
→ Mitigation: DB-Level unique constraint ist nicht moeglich (flexible Zeitraeume), aber Transaction-Isolation schuetzt
### Offene Fragen
1. **Retention:** Wie lange sollen Audit-Logs aufbewahrt werden? (Vorschlag: 2 Jahre)
2. **Granularitaet:** Sollen READ-Zugriffe geloggt werden? (Vorschlag: Nein, nur Mutations)
3. **DSGVO:** Muessen Audit-Logs bei User-Loeschung anonymisiert werden?
4. **Notifications:** Sollen bestimmte Aenderungen (z.B. Projekt-Status) automatisch Notifications ausloesen?
5. **Middleware vs Manual:** Soll der Audit-Helper manuell oder als tRPC-Middleware eingebaut werden?
Empfehlung: Manuell mit Helper-Funktion, da Middleware die Entity-Snapshots nicht automatisch kennt
---
## Geschaetzter Aufwand
| Phase | Aufwand | Parallelisierbar |
|-------|---------|-----------------|
| Phase 1: Infrastruktur | 1 Tag | Nein (sequenziell) |
| Phase 2: Audit-Abdeckung | 1 Tag | Ja (4 Agents parallel) |
| Phase 3: UI | 1 Tag | Ja (2 Agents parallel) |
| Phase 4: AI Integration | 0.5 Tag | Ja (mit Phase 3) |
| **Gesamt** | **~3.5 Tage** | |
1. **Soll der Check nur warnen oder blockieren?**
→ Empfehlung: API blockiert (CONFLICT), UI warnt (gelbe Box, Submit moeglich), AI blockiert
2. **Was passiert bei Updates/Shifts?**
→ excludeAssignmentId nutzen um die eigene Zuweisung auszuschliessen
3. **Welche Status zaehlen als "aktiv"?**
CONFIRMED, ACTIVE, PROPOSED — nicht CANCELLED, DRAFT
4. **Sollen verschiedene Rollen erlaubt sein?**
→ Vorschlag: Ja, aber mit Warning. Gleiche Rolle + gleiches Projekt = Block, verschiedene Rolle = Warning only