490 lines
40 KiB
Markdown
490 lines
40 KiB
Markdown
# 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.
|