Files

490 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CapaKraken Projekt-Learnings
## Format
**Datum | Kategorie | Problem → Lösung**
---
## Learnings
### 2026-04-03 | Auth | Session expiry redirect — Auth.js v5 + Edge runtime split
**Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
**Solution — split config pattern:**
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
- `auth-edge.ts``NextAuth(authConfig)` with the lean config; used only by middleware
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
**Middleware wrapping:**
```ts
import { auth } from "./server/auth-edge.js";
export default auth(function middleware(request) {
if (!isPublicPath(pathname) && !request.auth) {
return NextResponse.redirect(new URL("/auth/signin", request.url));
}
// CSP logic...
});
```
**Three-layer defence:**
1. Middleware — server-side redirect before page renders
2. `SessionGuard` client component — `useSession()``router.replace()` on SPA navigation
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
**Test mock pattern for middleware tests:**
```ts
vi.mock("./server/auth-edge.js", () => ({
auth: (handler) => (req) =>
handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
}));
```
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
---
### 2026-04-02 | DevOps | Gitea API token location
**Token:** `~/.gitea-token` (chmod 600, never committed to repo)
**API base:** `https://gitea.hartmut-noerenberg.com/api/v1`
**Repo path:** `Hartmut/plANARCHY`
Usage example (list open issues):
```bash
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
```
Close an issue with a comment:
```bash
TOKEN=$(cat ~/.gitea-token)
REPO="Hartmut/plANARCHY"
BASE="https://gitea.hartmut-noerenberg.com/api/v1"
# Add comment
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"$BASE/repos/$REPO/issues/42/comments" -d '{"body": "Fixed in commit abc1234."}'
# Close issue
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"$BASE/repos/$REPO/issues/42" -d '{"state": "closed"}'
```
---
### 2026-04-02 | DevOps | Prisma schema changes require container restart
**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
**Solution:** Always restart the app container after Prisma schema changes:
```
docker compose --profile full restart app
```
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
**Rule:** Prisma schema change checklist:
1. Edit `packages/db/prisma/schema.prisma`
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
3. Apply migration to the running DB directly (for dev speed): `docker exec capakraken-postgres-1 psql -U capakraken -d capakraken < migration.sql`
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
- Assignment slices derived from `countWorkingDaysInOverlap()` per month, category code from project's utilization category.
- tRPC `client` is a reserved router key — renamed to `clientEntity` to avoid collision with built-in `.client` property.
- React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
### 2026-03-12 | Build | NextAuth portable export typing
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
- `packages/application` depends on `@capakraken/db`, `@capakraken/engine`, `@capakraken/shared`; `packages/api` depends on `@capakraken/application`
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
### 2026-03-12 | Architecture | Dashboard query extraction into application layer
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
- Added `packages/application/src/__tests__/dashboard.test.ts` so the extraction is covered at the package boundary rather than only indirectly through API procedures.
- While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
### 2026-03-12 | Architecture | Estimating foundation slice
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
- Shared estimate enums/types/schemas now live in `@capakraken/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@capakraken/application`.
- Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
- Virtual scroll for table rows is complex: absolute positioning breaks `<table>` column widths. Cursor-based infinite scroll (50 rows/page) is sufficient for typical resource counts (<500). Skip virtualizer for HTML tables.
- JSONB field name in Prisma schema is `dynamicFields` (not `customFields`). Always verify the actual Prisma model field name before writing filter/update code.
- CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
### 2026-03-11 | Security | Phase 0 critical fixes
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@capakraken/db` is not enough for TS resolution.
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
- `@capakraken/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
- `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
### 2026-03-09 | Performance | recomputeValueScores sequential updates
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
**Fix:** Use `ctx.dbUser?.id` directly.
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
---
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren TypeScript erzwingt das ohnehin.
---
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
---
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
---
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/capakraken`) verschieben.
**Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
---
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
---
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
---
### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` tRPC leitet Typen rekursiv ab.
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
---
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@capakraken/shared`-Enums kompatibel sind.
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
- `project as unknown as Project`
- `form.orderType as unknown as OrderType`
- `resource.skills as unknown as SkillEntry[]`
---
### 2026-02-xx | Architektur | SSE statt WebSockets
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
---
### 2026-03-06 | Refactoring | 1863-Zeilen-Komponente aufteilen
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen schwer wartbar, kaum testbar.
**Lösung:** Schrittweise Extraktion:
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
2. Heatmap-Utilities → `heatmapUtils.ts`
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
4. Header-JSX → `TimelineHeader.tsx`
5. Toolbar-JSX → `TimelineToolbar.tsx`
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
---
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
**Lösung:** `ioredis` in `@capakraken/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `capakraken:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
**Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) notwendig mit `moduleResolution: NodeNext` + ioredis v5.
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
---
### 2026-03-06 | Auth | Argon2 statt SHA-256
**Problem:** SHA-256 ohne Salt ist dev-only und nicht produktionstauglich.
**Lösung:** `@node-rs/argon2` als Drop-in für Auth.js v5 Credentials Provider. `verify(hash, password)` ersetzt den SHA-256-Vergleich. Seed nutzt `await hash("password")` (Salt wird automatisch generiert).
**Seed-Bug entdeckt:** `prisma.vacation.deleteMany({})` fehlte im Cleanup-Block → FK-Constraint-Fehler. Behoben.
---
### 2026-03-06 | Performance | @tanstack/react-virtual für Timeline
**Problem:** Timeline renderte alle Resource-Rows auf einmal (auch off-screen).
**Lösung:** `useVirtualizer` mit `getScrollElement: () => scrollContainerRef.current`. Virtualisierter Container hat `position: relative; height: getTotalSize()`, jede Row `position: absolute; transform: translateY(...)`.
**Sticky Labels:** Funktionieren trotz `position: absolute` Sticky arbeitet gegen den horizontalen Scroll-Container (nicht den vertikalen).
**Nicht virtualisiert:** Project-View (typisch 20-40 Gruppen, kein Performance-Problem).
---
### 2026-03-06 | Build | Laufender Dev-Server überlebt Prisma-Schema-Änderung nicht
**Problem:** Nach `prisma generate` + `rm -rf .next` durch einen Hintergrund-Agenten lief der Dev-Server noch mit dem alten Node.js-Prozess. Der gecachte Prisma-Client kannte das neue `Notification`-Model nicht → `ctx.db.notification` war `undefined` → 500 Internal Server Error auf allen Seiten die den neuen Router nutzten (NotificationBell pollt beim Start).
**Root Cause:** `rm -rf .next` löscht den Build-Cache für neue Requests. Aber der **laufende Node.js-Prozess** hat den alten PrismaClient bereits im Heap. Dieser wird erst bei Prozess-Neustart neu initialisiert.
**Lösung:** Dev-Server-Prozess töten und neu starten: `fuser -k 3100/tcp && pnpm dev`
**Regel:** Nach jeder Prisma-Schema-Änderung die durch einen Agenten im Hintergrund gemacht wird: Dev-Server immer manuell neu starten. Symptom: 500 auf allen Seiten obwohl tsc sauber ist.
---
### 2026-03-06 | Playwright | Falsche API für page.click() mit hasText
**Problem:** `page.click("button", { hasText: "..." })` ist keine valide Playwright-API.
**Lösung:** `page.locator("button", { hasText: "..." }).click()` zuerst Locator erstellen, dann `.click()` aufrufen.
---
### 2026-03-06 | Role-Migration | Namens-Inkonsistenz bei Legacy-Daten
**Problem:** 61 von 133 Allocations mit `role`-String konnten nicht auf `roleId` gemappt werden (z.B. "Unreal Dev" vs. "Unreal Developer"). Ursache: Seed wurde überarbeitet, alte Allocation-Daten nicht migriert.
**Lösung:** Migration-Script erstellt, das case-insensitiv matched. 72 Allocations korrekt backgefüllt. Für die 61 Unmatched bleibt der `role`-String als Fallback.
**Für künftige Projekte:** Bei Seed-Überarbeitungen auch Migrations-Script für bestehende Produktionsdaten vorbereiten.
---
### 2026-03-06 | Architektur | Parallele Agenten auf demselben File — Race Condition vermeidbar
**Problem:** P7.2 und P7.3 modifizierten beide `useTimelineDrag.ts` in parallel gestarteten Agenten.
**Ergebnis:** Kein Konflikt, weil P7.3 später abschloss und den von P7.2 bereits geänderten File las, bevor er seine eigenen Änderungen hinzufügte.
**Risiko:** Wenn beide Agenten exakt gleichzeitig fertig würden, könnte einer die Änderungen des anderen überschreiben.
**Regel:** Bei parallelen Agenten, die dieselbe Datei modifizieren könnten, immer prüfen ob die Änderungen unabhängig sind — oder den zweiten Agenten anweisen, den File nach Start des ersten Agenten zu lesen.
---
### 2026-03-06 | UX | Touch-Drag Disambiguierung
**Pattern:** `touchStartRef = useRef({ x, y, decided })` — beim ersten Touchstart Position merken. Im `touchmove`: wenn `dy > dx` und noch nicht decided → vertical scroll, kein Drag. Wenn `dx > dy` → horizontal drag, `decided = true`, `preventDefault()`.
**Wichtig:** `e.changedTouches[0]` statt `e.touches[0]` bei `touchend` verwenden (touches ist bei touchend leer).
**iOS Safari:** `touch-action: none` (CSS) oder `style={{ touchAction: "none" }}` auf dem Canvas-Div verhindert Browser-Scroll-Interception.
---
### 2026-03-06 | Architektur | Undo/Redo ohne Hook-Kopplung
**Pattern:** Optionaler `onAllocationMoved?: (snapshot) => void` Callback in `useTimelineDrag`, der im `onSuccess` der Mutation aufgerufen wird. Konsumenten können History-Stack führen ohne den Hook zu kennen.
**Vorteil:** Klar getrennte Verantwortlichkeiten — der Drag-Hook kennt keine History-Logik.
**Keyboard-Shortcut:** Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y im `useEffect` mit `window.addEventListener("keydown", ...)`.
---
### 2026-03-06 | Auth | Prisma upsert mit `update: {}` updatet Passwort nicht
**Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
```typescript
const adminHash = await hash("admin123");
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
```
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
---
### 2026-03-06 | Feature | Multi-Format-Export über einen Route Handler
**Pattern:** Einen Route Handler (`/api/reports/allocations`) für mehrere Exportformate nutzen via `?format=xlsx|pdf`. Die Datenbankabfrage wird einmal ausgeführt, danach teilt ein `if (format === "xlsx")` Block die Verarbeitung auf.
**XLS-Generierung server-seitig:** `xlsx` Package (bereits im Projekt für Import vorhanden) — `XLSX.utils.json_to_sheet(data)` + `XLSX.utils.book_new()` + `XLSX.write(wb, { type: "buffer", bookType: "xlsx" })` gibt einen `Buffer` zurück.
**MIME-Type XLS:** `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` + `filename="*.xlsx"`.
**UI:** `<a href="...?format=xlsx" download>``download` Attribut erzwingt Download statt Browser-Öffnung.
**Wiederverwendung:** Da `xlsx` bereits für den Import installiert ist, kein neues Package nötig. Import als `import * as XLSX from "xlsx"`.
---
### 2026-03-06 | Next.js | "use client" in @react-pdf/renderer Komponente bricht renderToBuffer
**Problem:** `AllocationReport.tsx` hatte `"use client"` am Anfang. Wenn diese Komponente in einem API Route Handler (Server Context / RSC) importiert und mit `renderToBuffer` gerendert wird, wickelt Next.js sie in einen `__nextjs-internal-proxy.mjs`-Wrapper. `renderToBuffer` bekommt nicht das echte React Component sondern den Proxy → 500 Internal Server Error.
**Fehlersymptom:** `at eval (...AllocationReport.tsx/__nextjs-internal-proxy.mjs:1:1)` im Stack Trace.
**Lösung:** `"use client"` aus `AllocationReport.tsx` entfernen. `@react-pdf/renderer` (`Document`, `Page`, `Text`, `View`) ist eine rein server-seitige Bibliothek — sie verwendet keine Browser-APIs, kein DOM, kein `window`. `renderToBuffer` läuft im Node.js-Prozess. Server Components sind hier korrekt.
**Regel:** Komponenten für `@react-pdf/renderer` sind immer **Server Components**. `"use client"` dort ist falsch und bricht `renderToBuffer` in Route Handlers.
**Merkhilfe:** `"use client"` nur dort, wo echte Browser-APIs (`useState`, `useEffect`, DOM-Events, `window`, `localStorage`) gebraucht werden. Reine Darstellungskomponenten für PDF/Email/Export sind Server Components.
---
### 2026-03-06 | Seed | Seed löscht Daten ohne sie neu anzulegen → stille Datenverluste
**Problem:** `seed.ts` führt `prisma.vacation.deleteMany({})` im Cleanup-Block aus, aber erstellt danach keine Vacation-Einträge. Jedes Re-Seeding (z.B. nach Passwort-Hash-Migration) löscht alle Vacations dauerhaft. Die App zeigt danach leere Listen, ohne Fehlermeldung — "No vacations found" ist korrekt aber irreführend.
**Symptom:** Vacation-Tab zeigt "No vacations found" obwohl die API 200 zurückgibt.
**Lösung:** Vacation-Seed-Daten in `seed.ts` hinzugefügt (10 Einträge: ANNUAL, SICK, PUBLIC_HOLIDAY, OTHER; Status APPROVED + PENDING). Seed gibt jetzt `Vacations: N created` aus.
**Regel:** Jedes Model das im `deleteMany`-Cleanup-Block steht, **muss** auch im Seed-Daten-Block befüllt werden. Nach jedem Seed-Lauf: alle Models auf erwartete Counts prüfen.
**Für künftige Projekte:** Nach `db:seed` immer spot-check: `prisma.vacation.count()`, `prisma.allocation.count()` etc. — nicht nur "Seed complete!" als Erfolg werten.
---
### 2026-03-06 | Next-Auth | useSession ohne SessionProvider → Runtime Error
**Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
**Lösung (zweistufig):**
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
---
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
**Kontext:** CapaKraken hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
**Lösung:** Zweigeteiltes System:
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
4. **`requirePermission(ctx, key)`** — wirft `TRPCError FORBIDDEN` wenn Permission fehlt.
5. **`controllerProcedure`** — neues Procedure-Level für ADMIN+MANAGER+CONTROLLER (lesend + Export).
**Neue Dateien:** `packages/shared/src/types/permissions.ts`
**Geändert:** `packages/api/src/trpc.ts`, `packages/api/src/router/user.ts` (+ 3 neue Procedures), 6 Router mit Permission-Checks, `AppShell.tsx`, `usePermissions.ts` Hook, 4 UI-Komponenten.
**Parallelisierungserfolg:** 4 Wellen, je 2-3 parallele Agenten. Welle 1 (Shared+Prisma) → Welle 2 (Middleware+AppShell) → Welle 3 (Router+AdminPage+UI) → Welle 4 (Users-Page). Kein Konflikt, da saubere Datei-Grenzen.
**Pre-existing Bug gefunden:** `packages/shared/src/schemas/project.schema.ts` Zeile 5: `crypto.randomUUID()` ist nicht im `lib: ["ES2022"]` ohne DOM — hat nil Auswirkung auf Laufzeit (Node 18+ hat `crypto` global), aber `tsc --noEmit` auf shared direkt schlägt fehl. Fixbar mit `import { randomUUID } from "node:crypto"`.
---
### 2026-03-06 | Architektur | permissionOverrides als JSON statt eigenem Modell
**Entscheidung:** Permission-Overrides als `Json?` auf User-Modell statt separatem `Permission`/`RolePermission`-Datenbankmodell.
**Begründung:** Kein db:push mit Tabellenmigrationen, keine Join-Queries, direkt serialisierbar in JWT/Session. Für teams < 100 User ausreichend.
**Vorteil:** `Prisma.DbNull` setzt JSON-Feld auf NULL (kein `undefined` übergeben).
**Trade-off:** Keine DB-level Constraints auf Permission-Keys — Validierung liegt komplett in der API-Middleware.
**Für künftige Projekte:** JSON-Overrides bis ~100 User ideal; bei größeren Systemen separate Tabellen für auditierbare History.
---
## Offene Fragen
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
- [x] P7.2 Touch-Support → umgesetzt
- [x] P7.3 Undo/Redo → umgesetzt
- [ ] GitHub MCP braucht einen `GITHUB_TOKEN` in `~/.claude.json` noch nicht gesetzt.
---
### 2026-03-07 | Architektur | Resource Value Score kontext-freie Composite-Metrik
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
**Tiebreaker:** In `staffing.ts getSuggestions` sort-Tiebreaker: wenn zwei Resourcen ≤2 Punkte auseinander, bevorzuge höheren valueScore.
**Tests:** 8 neue Unit-Tests in `value-scorer.test.ts` (edge cases: empty skills, maxLcrCents=0, clamp 0-100).
---
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
**Merkregel:** `db:push``.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
**Lösung:**
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
- **`DraggableTableRow`** neue Komponente: `<tr>` mit erstem `<td>` als Drag-Handle (⠿). `dragRef` (shared ref) hält die aktuell gezogene Row-ID. `onDrop(draggedId)` gibt die gezogene ID weiter (nicht die Ziel-ID) — Pattern: `onDrop={(draggedId) => reorder(draggedId, currentRow.id)}`.
- **Per-User-Isolation:** Alle Prefs (sort + rowOrder) landen im `User.columnPreferences` JSONB keyed by User-ID — vollständige Isolation zwischen Benutzern.
- **Blueprints ausgelassen:** Card-Grid-Layout, keine Tabelle → sorting/drag nicht anwendbar.
- **exactOptionalPropertyTypes-Falle:** `writeLocal(view, { sort: sort ?? undefined })` ist ungültig. Fix: Funktionssignatur auf `{ sort?: SavedSort | null; rowOrder?: string[] | null }` ändern, dann `{ sort: sort ?? null }` übergeben. `null` wird dann per `if (next.sort == null) delete next.sort` bereinigt.
- **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
**Lösung:**
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
**Context (Ticket #30):** The OWASP audit surfaced the absence of a programmatic API key system. A number of potential approaches were considered.
**Decision: No code is written until the product decision is made.**
The core trade-off is:
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
- **Short-lived API tokens (OAuth-style):** Suitable if CapaKraken exposes a public API. Over-engineered for an internal tool with no current integration story.
**Engineering guidance for when the decision is made:**
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
2. Enforce per-key scopes aligned with the `SystemRole` permission model.
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
4. Rate-limit key-authenticated requests via `apiRateLimiter` (already exists in `packages/api/src/middleware/rate-limit.ts`).
5. Expose revocation via `DELETE /api/keys/:id` (protected procedure, owner or ADMIN only).
6. Log all key-authenticated requests to `AuditLog` with `source: "api-key"`.
**Action required:** Product owner must confirm whether external integrations are in scope before this ticket can proceed.