Files
tessera-ctl/.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
T

741 lines
36 KiB
Markdown
Raw 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.
# Phase 8: Dashboard Widgets Vollimplementierung - Research
**Researched:** 2026-07-01
**Domain:** React Widget-Komponenten, NestJS-Modulerweiterung, Prisma-Schema, SSRF-Schutz
**Confidence:** HIGH (eigene Codebase direkt gelesen; Referenzimplementierungen vollständig verfügbar)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**D-01 (DASH-11):** Kein universeller Einheitswert. Jedes Widget bekommt Constraints, die es bei Mindestgröße erkennbar und nutzbar halten. Claude legt konkrete Werte fest.
**D-02 (Favorites Backend):** Neue Prisma-Tabelle `FavoriteLink` mit Feldern: `id`, `userId`, `tenantId`, `widgetId`, `title`, `url`, `iconUrl`, `position`. Eigene Tabelle — sauber abfragbar und sortierbar.
**D-03 (Favorites Ansicht):** Standard-Ansicht: Liste (Icon + Titel untereinander). Grid-Ansicht (Kacheln) umschaltbar. Bei Grid-Ansicht saubere Formatierung.
**D-04 (Favorites CRUD):** Hinzufügen/Bearbeiten/Löschen direkt im Widget im Dashboard-Edit-Modus ('+'-Button + Inline-Formular). Kein separater Einstellungen-Tab.
**D-05 (Icon-Discovery):** Server-seitig beim Anlegen. NestJS fetcht Seite, parst `<link rel="apple-touch-icon">`, `<link rel="icon">`, `<link rel="shortcut icon">`, OG-Image. Fallback: `/favicon.ico`. SSRF-Schutz gegen private IPs/localhost. `iconUrl` in `FavoriteLink`. Client rendert `<img src={iconUrl}>` mit Buchstaben-Fallback.
**D-06 (Link-Widget):** Separater WidgetType `link`. Zeigt genau einen Link. Ansicht umschaltbar: Liste oder Kachel. Teilt FavoriteLink-Tabelle mit FavoritesWidget (widgetId als Zuordnung).
**D-07 (Stopwatch-Persistenz):** Startzeit (Unix-Timestamp/ISO-String) + Zustand (running/paused/elapsed) in `WidgetInstance.config`. Kein separater API-Endpunkt — `PATCH /dashboard/widgets/:id/config`.
**D-08 (Stopwatch-Funktionen):** Start, Stop/Pause, Reset. Rundenzeiten optional — Claude entscheidet.
**D-09 (Calculator):** Grundrechenarten (+, −, ×, ÷), Tastatureingabe. Kein Persist. Logik aus personal-dashboard portieren, CSS-Module durch Tailwind ersetzen.
### Claude's Discretion
- Konkrete minW/minH/defaultW/defaultH-Werte für neue Widgets
- Lap-Timer in Stoppuhr: im ersten Schritt oder später
- Reihenfolge der Kacheln im Favoriten-Grid
- Keyboard-Shortcuts im Calculator-Widget (aus personal-dashboard übernehmen)
- SSRF-Timeout und max-redirect-Werte (aus personal-dashboard: 4000ms / 2 redirects)
### Deferred Ideas (OUT OF SCOPE)
- Stoppuhr-Anzeige im Browser-Seitentitel + Taskleiste
- DomainCheck-Widget
- Lap-Timer (falls nicht in Phase 8 umgesetzt: separater Task späterer Phase)
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| DASH-08 | Taschenrechner-Widget (Grundrechenarten, Tastatureingabe) | Calculator-Logik aus personal-dashboard vollständig portierbar; keine neuen Pakete; Tailwind-Styling nach Tessera-Muster |
| DASH-09 | Favoriten-Widget mit Backend-Persistenz (Links mit Titel, URL, Icon) | FavoriteLink-Prisma-Modell definiert; NestJS FavoritesModule mit Controller/Service/DTOs; Icon-Discovery aus personal-dashboard portierbar |
| DASH-10 | Stoppuhr-Widget (Start/Stop/Reset, Rundenzeiten) | Zustand in WidgetInstance.config; bestehende updateWidgetConfig()-Funktion direkt verwendbar; kein neuer API-Endpunkt |
| DASH-11 | Alle Widgets haben einheitliche Grid-Constraints (gleiche Feldstruktur, sinnvolle Werte) | WIDGET_CONSTRAINTS-Record in widget-registry.tsx erweitern; D-01 klärt: gleiche Feldstruktur, individuelle Werte |
</phase_requirements>
---
## Summary
Phase 8 implementiert vier neue Dashboard-Widgets (Calculator, Favorites, Link, Stopwatch) und normalisiert die Grid-Constraints aller Widgets. Die Codebase ist vollständig gelesen und die Referenzimplementierungen aus dem personal-dashboard sind verfügbar.
**Keine neuen npm-Pakete erforderlich.** Alle benötigten Funktionen sind in der bestehenden Stack vorhanden: React 19 + Tailwind für die Frontend-Widgets, NestJS 11 + Prisma 6 + class-validator für das Backend, Node.js 24-Built-ins (`dns/promises`, `net`, globales `fetch`) für die SSRF-geschützte Icon-Discovery. Dies eliminiert Installations- und Kompatibilitätsrisiken.
Die größte Komplexität liegt beim Favorites-Widget: Es braucht ein neues NestJS-Modul (`FavoritesModule`), ein neues Prisma-Modell (`FavoriteLink`), serverseitiges Icon-Discovery mit SSRF-Schutz und eine zweistufige Widget-Ansicht (Liste/Grid). Der Code für alle diese Teile existiert bereits im personal-dashboard und muss nur portiert werden.
**Primary recommendation:** Sequenziell vorgehen: zuerst FavoriteLink-Schema + Prisma-Migration, dann FavoritesModule, dann die vier Widget-Komponenten (Calculator zuerst — keine Abhängigkeiten), dann widget-registry/catalog/i18n-Updates als letzter Schritt.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Calculator-Logik | Browser / Client | — | Rein clientseitig; kein Persist; State in React useState |
| Stoppuhr-Logik | Browser / Client | API / Backend | Timer läuft im Browser; Startzeit wird via PATCH an Backend persistiert |
| Favoriten-CRUD UI | Browser / Client | API / Backend | UI im Widget; Daten von NestJS API |
| Icon-Discovery | API / Backend | — | Serverseitig (SSRF-Schutz); Client darf keine externen URLs direkt fetchen |
| FavoriteLink-Persistenz | Database / Storage | — | PostgreSQL via Prisma; userId+tenantId-Scope |
| Widget-Registry / Constraints | Frontend Server (SSR) | Browser / Client | widget-registry.tsx wird beim Bundling eingebunden; Typen + Constraints wirken build-time |
| i18n-Strings | Frontend Server (SSR) | — | next-intl Server Component native; Keys in messages/de.json + messages/en.json |
---
## Standard Stack
### Core (bereits installiert — keine neuen Pakete)
| Library | Version | Purpose | Relevanz für Phase 8 |
|---------|---------|---------|----------------------|
| React | 19.x | UI-Komponenten | Alle vier Widget-Komponenten |
| Next.js | 15.3.x | Framework | App Router, 'use client' Widgets |
| Tailwind CSS | 4.x | Styling | CSS-Module aus personal-dashboard durch Tailwind ersetzen |
| next-intl | 4.13.x | i18n | useTranslations() für alle Widget-Strings |
| NestJS | 11.x | API-Framework | Neues FavoritesModule |
| Prisma | 6.x | ORM | Neues FavoriteLink-Modell; db push |
| class-validator | 0.15.x | DTO-Validierung | DTOs für FavoriteLink-CRUD |
**Hinweis zur Prisma-Version:** Das Projekt nutzt Prisma 6.x (package.json: `^6.0.0`), nicht 7.x wie in der CLAUDE.md-Empfehlung. [VERIFIED: apps/api/package.json]
### Node.js Built-ins für Icon-Discovery (kein npm install nötig)
| Modul | Zweck | Node.js-Version |
|-------|-------|-----------------|
| `dns/promises` (lookup) | Hostname → IP-Auflösung für SSRF-Check | Node 18+ (Node 24 installiert) |
| `net` (isIP) | IPv4/IPv6-Erkennung | Built-in |
| `fetch` (global) | HTTP-Fetch für HTML-Parsing | Node 18+ native |
[VERIFIED: node --version = v24.16.0]
### Keine neuen Pakete — Package Legitimacy Audit entfällt
Alle benötigten Funktionen sind in bestehenden Paketen oder Node.js-Built-ins verfügbar.
---
## Package Legitimacy Audit
**Keine neuen externen Pakete für diese Phase.** [VERIFIED: Analyse aller Anforderungen — reine Portierung + bestehender Stack]
| Package | Registry | Verdict | Disposition |
|---------|----------|---------|-------------|
| (keine neuen Pakete) | — | — | — |
---
## Architecture Patterns
### System Architecture Diagram
```
Browser (React)
├── CalculatorWidget → useState (in-memory, kein API-Call)
├── StopwatchWidget → useState + updateWidgetConfig()
│ PATCH /dashboard/widgets/:id/config
├── FavoritesWidget (liste/grid)→ fetch() → GET /favorites?widgetId=X
│ + Inline-Formular (editMode) POST/PATCH/DELETE /favorites/:id
└── LinkWidget (1 Eintrag) → fetch() → GET /favorites?widgetId=X (limit 1)
NestJS API
├── DashboardController (bestehend)
│ PATCH /dashboard/widgets/:id/config → StopwatchWidget-Persist
└── FavoritesController (neu)
GET /favorites?widgetId= → FavoritesService.list()
POST /favorites → FavoritesService.create() + IconDiscoveryService
PATCH /favorites/:id → FavoritesService.update()
DELETE /favorites/:id → FavoritesService.remove()
↓
IconDiscoveryService (portiert aus personal-dashboard)
↓ DNS-Lookup → IP-SSRF-Check → fetch(url, {redirect:'manual'}) → HTML-Parse
↓ Fallback: /favicon.ico
PostgreSQL (Prisma)
├── WidgetInstance.config (JSON) ← StopwatchWidget state
└── FavoriteLink ← FavoritesWidget + LinkWidget Daten
```
### Recommended Project Structure (neue Dateien)
```
apps/
├── api/
│ ├── prisma/
│ │ └── schema.prisma UPDATE: FavoriteLink-Modell hinzufügen
│ └── src/
│ ├── app.module.ts UPDATE: FavoritesModule importieren
│ └── favorites/ NEU
│ ├── favorites.module.ts
│ ├── favorites.controller.ts
│ ├── favorites.service.ts
│ ├── icon-discovery.service.ts (portiert aus personal-dashboard)
│ └── dto/
│ ├── create-favorite.dto.ts
│ └── update-favorite.dto.ts
└── web/
├── src/
│ ├── components/dashboard/
│ │ ├── widget-registry.tsx UPDATE: 4 neue Typen + Constraints + wireXWidget()
│ │ ├── widget-catalog-modal.tsx UPDATE: WIDGET_TYPES Array
│ │ └── widgets/
│ │ ├── calculator-widget.tsx NEU
│ │ ├── calculator-widget.test.tsx NEU
│ │ ├── favorites-widget.tsx NEU
│ │ ├── favorites-widget.test.tsx NEU
│ │ ├── link-widget.tsx NEU
│ │ ├── link-widget.test.tsx NEU
│ │ ├── stopwatch-widget.tsx NEU
│ │ └── stopwatch-widget.test.tsx NEU
│ ├── lib/
│ │ └── favorites-api.ts NEU (analog zu dashboard-api.ts)
│ └── messages/
│ ├── de.json UPDATE: neue Widget-Keys
│ └── en.json UPDATE: neue Widget-Keys
└── src/app/api/favorites/ OPTIONAL: Next.js API-Route als Proxy (falls nötig)
```
### Pattern 1: Neues Widget registrieren (wireXWidget-Pattern)
Jedes neue Widget muss in `widget-registry.tsx` exakt nach diesem Muster registriert werden:
```typescript
// Source: apps/web/src/components/dashboard/widget-registry.tsx (bestehend)
// 1. WidgetType-Union erweitern
export type WidgetType = 'clock' | 'search' | 'calendar' | 'note'
| 'calculator' | 'favorites' | 'link' | 'stopwatch';
// 2. WIDGET_CONSTRAINTS eintragen
export const WIDGET_CONSTRAINTS: Record<WidgetType, { minW: number; minH: number; defaultW: number; defaultH: number }> = {
// bestehend:
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
// neu (Claude-Discretion D-01):
calculator: { minW: 2, minH: 4, defaultW: 3, defaultH: 5 },
favorites: { minW: 2, minH: 3, defaultW: 3, defaultH: 5 },
link: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
stopwatch: { minW: 2, minH: 2, defaultW: 3, defaultH: 3 },
};
// 3. PlaceholderWidget im WIDGET_REGISTRY eintragen
// 4. wireXWidget()-Funktion hinzufügen (analog zu wireClockWidget)
let calculatorWired = false;
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
if (!calculatorWired) {
WIDGET_REGISTRY.calculator.component = component;
calculatorWired = true;
}
}
// ... analog für favorites, link, stopwatch
```
[VERIFIED: apps/web/src/components/dashboard/widget-registry.tsx]
### Pattern 2: Widget-Komponente (Tessera-Standard)
```typescript
// Source: apps/web/src/components/dashboard/widgets/clock-widget.tsx (bestehend)
'use client';
import type { WidgetProps } from '../widget-registry';
import { useTranslations } from 'next-intl';
export function CalculatorWidget({ instanceId, config, isEditMode }: WidgetProps) {
// Alle Styles via Tailwind (keine CSS-Module)
// useTranslations('widgets') für alle UI-Strings
// updateWidgetConfig(instanceId, {...}) für Persistenz (Stopwatch)
return (
<div className="flex h-full flex-col ...">
{/* Tailwind-Styling */}
</div>
);
}
```
[VERIFIED: apps/web/src/components/dashboard/widgets/clock-widget.tsx, note-widget.tsx]
### Pattern 3: FavoriteLink Prisma-Schema
```prisma
// Zu ergänzen in apps/api/prisma/schema.prisma
model FavoriteLink {
id String @id @default(uuid())
userId String
tenantId String
widgetId String // WidgetInstance.id — Zuordnung zum konkreten Widget
title String
url String
iconUrl String?
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([tenantId])
@@index([widgetId])
}
```
[ASSUMED: Feldstruktur aus D-02 und personal-dashboard FavoriteLink-Typ ableitet; Prisma-Syntax verifiziert an bestehendem Schema]
### Pattern 4: NestJS FavoritesModule
```typescript
// apps/api/src/favorites/favorites.module.ts
import { Module } from '@nestjs/common';
import { FavoritesController } from './favorites.controller';
import { FavoritesService } from './favorites.service';
import { IconDiscoveryService } from './icon-discovery.service';
@Module({
controllers: [FavoritesController],
providers: [FavoritesService, IconDiscoveryService],
})
export class FavoritesModule {}
// In app.module.ts: FavoritesModule zu imports: [...] hinzufügen
// PrismaService ist global via PrismaModule — kein Re-Import nötig
```
[VERIFIED: apps/api/src/app.module.ts — PrismaModule in imports, global; MEDIUM confidence via context7-Digest]
### Pattern 5: Stoppuhr-Persistenz in WidgetInstance.config
```typescript
// Gespeichertes Config-Objekt in WidgetInstance.config (Json):
type StopwatchConfig = {
state: 'running' | 'paused' | 'stopped';
startedAt: string | null; // ISO-8601-String wenn running
elapsed: number; // akkumulierte Zeit in ms (wenn paused/stopped)
};
// Beim Laden aus config:
const elapsed = config.state === 'running' && config.startedAt
? (Date.now() - new Date(config.startedAt as string).getTime()) + (config.elapsed as number ?? 0)
: (config.elapsed as number ?? 0);
// Beim Start:
await updateWidgetConfig(instanceId, {
state: 'running',
startedAt: new Date().toISOString(),
elapsed: currentElapsed,
});
```
[VERIFIED: updateWidgetConfig() in apps/web/src/lib/dashboard-api.ts — Merge-Semantik im Service bestätigt]
### Pattern 6: Calculator-Tastaturhandler
```typescript
// Portiert aus personal-dashboard/src/components/CalculatorWidget.tsx
// CSS-Module → Tailwind; CalculatorWidget() → export function CalculatorWidget({ instanceId, config, isEditMode }: WidgetProps)
function handleKeyboard(event: React.KeyboardEvent<HTMLDivElement>) {
const key = event.key;
const code = event.code;
let handled = true;
if (/^[0-9]$/.test(key)) { inputDigit(key); }
else if (key === ',' || key === '.' || code === 'NumpadDecimal') { inputDecimal(); }
else if (key === '+' || code === 'NumpadAdd') { chooseOperator('add'); }
else if (key === '-' || code === 'NumpadSubtract') { chooseOperator('subtract'); }
else if (key === '*' || code === 'NumpadMultiply') { chooseOperator('multiply'); }
else if (key === '/' || code === 'NumpadDivide') { chooseOperator('divide'); }
else if (key === 'Enter' || key === '=' || code === 'NumpadEnter') { applyEquals(); }
else if (key === 'Backspace') { backspace(); }
else if (key === 'Escape') { clearAll(); }
else if (key === 'Delete') { clearEntry(); }
else if (key === '%') { applyUnary('percent'); }
else if (key === 'F9') { toggleSign(); }
else { handled = false; }
if (handled) {
event.preventDefault();
event.stopPropagation(); // Verhindert react-grid-layout Interferenz
}
}
// Auf Container-div:
<div tabIndex={0} onKeyDown={handleKeyboard} role="application" aria-label="Taschenrechner">
```
[VERIFIED: /home/vicolab/Schreibtisch/personal-dashboard/src/components/CalculatorWidget.tsx — 434 Zeilen, vollständige Logik]
### Anti-Patterns to Avoid
- **CSS-Module verwenden:** Tessera nutzt ausschließlich Tailwind. Das personal-dashboard verwendet `CalculatorWidget.module.css` — diese Klassen müssen in Tailwind-Klassen übersetzt werden. Niemals `styles.xyz` im Tessera-Projekt.
- **Global `dns` importieren ohne `dns/promises`:** Der synchrone `dns`-Modul hat keine Promise-API. Immer `import { lookup } from 'dns/promises'` verwenden.
- **Icon-URL direkt vom Client fetchen:** Die SSRF-Schutz-Logik muss serverseitig in NestJS laufen. Der Client darf niemals direkt externe URLs für Icon-Discovery fetchen.
- **`fetch()` ohne `redirect: 'manual'`:** Automatisches Redirect-Folgen umgeht den SSRF-Check für Redirect-Ziele. Immer manuell weiterleiten und jedes Redirect-Ziel prüfen.
- **FavoriteLink ohne userId-Scope zurückgeben:** Ownership-Check (`WHERE userId = :userId`) ist Pflicht in jedem FavoritesService-Query.
- **WidgetType in CreateWidgetDto nicht aktualisieren:** `@IsIn(['clock', 'search', 'calendar', 'note'])` muss die 4 neuen Typen enthalten — sonst wirft die API 400 beim Widget-Erstellen.
- **WIDGET_TYPES-Array im Catalog-Modal nicht aktualisieren:** `widget-catalog-modal.tsx` hat ein hardcodiertes `WIDGET_TYPES`-Array — es muss ebenfalls erweitert werden.
---
## Don't Hand-Roll
| Problem | Nicht selbst bauen | Verwenden | Warum |
|---------|-------------------|-----------|-------|
| Arithmetik-Logik | Eigene Rechenfunktionen | `parseDisplay`, `formatNumber`, `calculate` aus personal-dashboard | Bewährt, edge-case-behandelt (NaN, -0, 1e15-Grenze, Dezimalkomma) |
| Icon-Discovery | Eigenen HTML-Parser | `extractIconFromHtml` + `fetchHtml` aus personal-dashboard | SSRF-Schutz, Redirect-Handling, Timeout, 200k-HTML-Limit |
| SSRF-IP-Check | Eigene Regex | `isPrivateIpv4` + `isPrivateIpv6` + `isBlockedHostname` aus personal-dashboard | RFC1918 komplett, CGNAT, IPv6-Sonderfälle |
| Widget-Config-Update | Neuen API-Endpunkt | `updateWidgetConfig()` in `dashboard-api.ts` | Bereits implementiert, PATCH /dashboard/widgets/:id/config |
| Favorites-Sortierung | Drag-Library | Native HTML5 Drag & Drop (wie in personal-dashboard) | Ausreichend für Liste; kein neues Paket nötig |
---
## Common Pitfalls
### Pitfall 1: Tastaturevents + react-grid-layout Konflikt
**What goes wrong:** Der Calculator fängt `/`-Taste ab (Divide) — react-grid-layout oder der Browser könnte ebenfalls darauf reagieren. Ohne `stopPropagation()` "bockt" die Seite oder navigiert.
**Root cause:** react-grid-layout ist ein DOM-Eventlistener; Keydown-Events bubblen nach oben.
**How to avoid:** `event.stopPropagation()` in allen handled-Fällen im `handleKeyboard`-Handler. Der Container braucht `tabIndex={0}` für Fokus.
**Warning signs:** Division-Taste öffnet browser-interne Suche; Seite scrollt beim Drücken von Pfeiltasten.
### Pitfall 2: Stoppuhr-Drift nach Seiten-Reload
**What goes wrong:** Wenn die Stoppuhr läuft und die Seite neu geladen wird, muss der gespeicherte `startedAt`-Timestamp korrekt interpretiert werden. Bei falscher Zeitzonenbehandlung läuft die Uhr falsch.
**Root cause:** `new Date(startedAt)` interpretiert ISO-8601 korrekt in UTC. `Date.now()` gibt ebenfalls UTC-Millisekunden. Die Differenz ist korrekt — solange `startedAt` als ISO-String (`new Date().toISOString()`) gespeichert wird.
**How to avoid:** Immer `new Date().toISOString()` für startedAt; `Date.now() - new Date(startedAt).getTime()` für Elapsed.
**Warning signs:** Stoppuhr springt nach Reload auf falschen Wert.
### Pitfall 3: FavoriteLink ohne widgetId-Scope
**What goes wrong:** Wenn `GET /favorites` keine `widgetId`-Filterung hat, sieht ein FavoritesWidget die Favoriten eines anderen FavoritesWidgets desselben Nutzers.
**Root cause:** Ein User kann mehrere FavoritesWidget-Instanzen auf dem Dashboard haben.
**How to avoid:** `WHERE widgetId = :widgetId` in jedem FavoritesService-Query. `widgetId` aus Query-Parameter validieren (class-validator `@IsUUID()`).
**Warning signs:** Alle Widgets zeigen dieselben Favoriten.
### Pitfall 4: Prisma-Client nach Schema-Change veraltet
**What goes wrong:** Nach `prisma db push` muss der Prisma-Client neu generiert werden (`prisma generate`). Ohne dies kennt TypeScript das neue `FavoriteLink`-Modell nicht.
**Root cause:** `prisma db push` pusht Schema zur DB, aber generiert den Client nicht automatisch (im Gegensatz zu `prisma migrate dev`).
**How to avoid:** Nach `prisma db push` immer `npx prisma generate` ausführen (oder beides via `prisma db push && prisma generate`).
**Warning signs:** TypeScript-Fehler `Property 'favoriteLink' does not exist on type 'PrismaClient'`.
### Pitfall 5: SSRF-Bypass via DNS-Rebinding
**What goes wrong:** Ein Angreifer registriert eine Domain, die zuerst auf eine öffentliche IP zeigt (SSRF-Check besteht), dann auf `192.168.x.x` rebindet.
**Root cause:** DNS-TTL-Ablauf zwischen Check und Fetch.
**How to avoid:** Die bestehende SSRF-Logik aus personal-dashboard macht den DNS-Lookup und den HTTP-Fetch in derselben Event-Loop-Iteration mit einem kurzen Timeout. Das reduziert das Fenster erheblich. Keine vollständige Immunität, aber ausreichend für diesen Use Case.
**Warning signs:** Kein direktes Warning-Zeichen — Review des SSRF-Codes beim Portieren.
### Pitfall 6: widget-catalog-modal.tsx WIDGET_TYPES Array vergessen
**What goes wrong:** Neue Widgets erscheinen nicht im Katalog, obwohl sie in der Registry registriert sind.
**Root cause:** `WIDGET_TYPES` in `widget-catalog-modal.tsx` ist ein hardcodiertes Array — kein dynamisches `Object.keys(WIDGET_REGISTRY)`.
**How to avoid:** Bei jedem neuen Widget auch `WIDGET_TYPES` in `widget-catalog-modal.tsx` erweitern.
### Pitfall 7: CreateWidgetDto @IsIn() nicht aktualisiert
**What goes wrong:** POST /dashboard/widgets mit `widgetType: 'calculator'` gibt 400 zurück.
**Root cause:** `@IsIn(['clock', 'search', 'calendar', 'note'])` in `create-widget.dto.ts` kennt die neuen Typen nicht.
**How to avoid:** `@IsIn([...alle WidgetTypes...])` aktualisieren, sobald neue Typen in `WidgetType`-Union aufgenommen werden.
---
## Grid-Constraints Entscheidung (Claude's Discretion — D-01)
Begründung der Werte basierend auf Widget-Inhalt:
| Widget | minW | minH | defaultW | defaultH | Begründung |
|--------|------|------|----------|----------|------------|
| clock (bestehend) | 2 | 2 | 2 | 2 | Einfache Zeitanzeige |
| search (bestehend) | 3 | 2 | 6 | 2 | Breite Suchleiste |
| calendar (bestehend) | 3 | 3 | 4 | 6 | Terminliste braucht Höhe |
| note (bestehend) | 2 | 3 | 3 | 4 | Textfeld braucht Mindesthöhe |
| **calculator (neu)** | **2** | **4** | **3** | **5** | 4×5 Tastenfeld + Display + Memory-Reihe; bei minH:4 sind alle Tasten bedienbar |
| **favorites (neu)** | **2** | **3** | **3** | **5** | Mindestens 2-3 Links sichtbar; defaultH:5 für komfortable Liste |
| **link (neu)** | **2** | **2** | **2** | **2** | Einzelner Link: Icon + Titel, eine Zeile reicht |
| **stopwatch (neu)** | **2** | **2** | **3** | **3** | Display + 3 Buttons; defaultW:3 für bessere Lesbarkeit |
---
## Stoppuhr: Lap-Timer Entscheidung (Claude's Discretion — D-08)
**Entscheidung: Lap-Timer in Phase 8 implementieren** — die Komplexität ist überschaubar (zusätzliches Array `laps: number[]` im Config, Button "Runde"). Wenn im ersten Plan zu aufwändig, wird es als optionaler Task am Ende des Plans eingeplant.
Config-Extension für Laps:
```typescript
type StopwatchConfig = {
state: 'running' | 'paused' | 'stopped';
startedAt: string | null;
elapsed: number;
laps: number[]; // Lap-Zeiten in ms, neueste zuerst
};
```
---
## Stopwatch Icon-Discovery: SSRF-Konfiguration (Claude's Discretion — D-05)
Werte aus personal-dashboard direkt übernehmen:
- `HTML_FETCH_TIMEOUT_MS = 4000` — ausreichend für normale Webseiten
- `MAX_REDIRECTS = 2` — verhindert Redirect-Loops
- `MAX_HTML_CHARS = 200_000` — ca. 200 KB HTML reichen für Icon-Tags im `<head>`
- User-Agent: `tessera/1.0` (statt `personal-dashboard/0.1.0`)
---
## Code Examples
### Icon-Discovery-Service (NestJS, portiert)
```typescript
// apps/api/src/favorites/icon-discovery.service.ts
// Portiert aus personal-dashboard/src/lib/favorite-icons.ts
// Änderungen: 'personal-dashboard/0.1.0' → 'tessera/1.0' im User-Agent
import { Injectable } from '@nestjs/common';
import { lookup } from 'dns/promises';
import { isIP } from 'net';
@Injectable()
export class IconDiscoveryService {
async discoverFavoriteIconUrl(pageUrl: string): Promise<string> {
// ... (vollständige Logik aus personal-dashboard portieren)
// SSRF-Check → HTML-Fetch → Icon-Parse → Fallback /favicon.ico
}
}
```
### FavoritesController (NestJS-Pattern)
```typescript
// apps/api/src/favorites/favorites.controller.ts
@Controller('favorites')
export class FavoritesController {
constructor(private readonly favoritesService: FavoritesService) {}
private extractContext(req: Request) {
// Gleiche extractContext()-Logik wie DashboardController
}
@Get()
async list(@Req() req: Request, @Query('widgetId') widgetId: string) {
const { userId } = this.extractContext(req);
return this.favoritesService.list(userId, widgetId);
}
@Post()
async create(@Req() req: Request, @Body() dto: CreateFavoriteDto) { ... }
@Patch(':id')
async update(@Param('id') id: string, @Req() req: Request, @Body() dto: UpdateFavoriteDto) { ... }
@Delete(':id')
async remove(@Param('id') id: string, @Req() req: Request) { ... }
}
```
[VERIFIED: Muster aus apps/api/src/dashboard/dashboard.controller.ts]
### Favorites-API-Client (Frontend)
```typescript
// apps/web/src/lib/favorites-api.ts (analog zu dashboard-api.ts)
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export async function fetchFavorites(widgetId: string) {
const res = await fetch(`${API_URL}/favorites?widgetId=${encodeURIComponent(widgetId)}`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to fetch favorites');
return res.json() as Promise<FavoriteLink[]>;
}
// ... create, update, remove analog
```
[VERIFIED: Muster aus apps/web/src/lib/dashboard-api.ts]
### i18n-Keys für neue Widgets
```json
// In messages/de.json, Abschnitt "widgets", zu ergänzen:
"calculator": {
"name": "Taschenrechner",
"description": "Grundrechenarten mit Tastatureingabe"
},
"favorites": {
"name": "Favoriten",
"description": "Schnellzugriff auf Links",
"loading": "Favoriten werden geladen...",
"empty": "Noch keine Favoriten.",
"addTitle": "Titel",
"addUrl": "URL",
"addButton": "Hinzufügen",
"listView": "Liste",
"gridView": "Kacheln",
"editButton": "Favorit bearbeiten",
"deleteButton": "Favorit loeschen",
"saveButton": "Speichern",
"cancelButton": "Abbrechen",
"error": "Fehler beim Laden der Favoriten"
},
"link": {
"name": "Link",
"description": "Einzelner Schnellzugriff-Link"
},
"stopwatch": {
"name": "Stoppuhr",
"description": "Zeitmessung mit Rundenzeiten",
"start": "Start",
"stop": "Stop",
"reset": "Reset",
"lap": "Runde"
}
```
[ASSUMED: Key-Namen nach Tessera-Konvention (bestehende Keys als Vorlage); en.json analog]
---
## State of the Art
| Alter Ansatz | Aktueller Ansatz | Geaendert | Impact |
|--------------|------------------|-----------|--------|
| CSS-Module (personal-dashboard) | Tailwind (Tessera) | Phase 5 | Alle Widget-Klassen → Tailwind-Utility-Klassen |
| fetch() mit redirect:'follow' | redirect:'manual' + manuelles Folgen | SSRF-Erkenntnis | Kein SSRF-Bypass via Redirects |
| Prisma migrate dev | prisma db push | Projekt-Konvention | Schneller für iterative Entwicklung ohne Migration-History |
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | FavoriteLink-Prisma-Feldnamen (id, userId, tenantId, widgetId, title, url, iconUrl, position) entsprechen D-02 aus CONTEXT | Schema-Pattern | Geringes Risiko: D-02 nennt exakt diese Felder |
| A2 | `prisma db push` ist die bevorzugte Methode (kein `migrate dev`) | Pattern 4 | Risiko: Falls Migrations-History erwünscht ist. Bestehendes Projekt nutzt db push (Jun 29, 2026 beobachtet) |
| A3 | i18n-Key-Namen folgen dem bestehenden Muster (z.B. `calculator.name`, `favorites.loading`) | i18n-Sektion | Gering: Falsche Key-Namen führen zu fehlenden Strings, leicht korrigierbar |
| A4 | FavoritesController unter `/favorites` (nicht `/dashboard/favorites`) | Architecture | Wenn der User `/dashboard/favorites` erwartet, müssen Routen angepasst werden |
| A5 | Lap-Timer wird in Phase 8 umgesetzt (Claude's Discretion D-08) | Stopwatch-Entscheidung | Kann als optionaler Task eingeplant werden |
---
## Open Questions (RESOLVED)
1. **FavoritesController-Route: `/favorites` oder `/dashboard/favorites`?**
- Was bekannt: DashboardController ist unter `/dashboard`; SearchProviders ebenfalls.
- Was unklar: Ob Favorites konzeptuell zum Dashboard-Modul gehören oder ein eigenes Top-Level-Modul sind.
- RESOLVED: `/favorites` (eigenes Modul, eigener Controller) — saubere Trennung. In Plan 08-03 umgesetzt.
2. **Braucht Next.js eine API-Route als Proxy für `/favorites`?**
- Was bekannt: `dashboard-api.ts` ruft NestJS direkt via `NEXT_PUBLIC_API_URL` auf (mit `credentials: 'include'`). Das funktioniert für alle bestehenden Widgets.
- Was unklar: Ob CORS-Konfiguration für `/favorites` bereits durch bestehende NestJS-CORS-Config abgedeckt ist.
- RESOLVED: Kein Next.js API-Proxy nötig — gleiche Fetch-Pattern wie `dashboard-api.ts` verwenden. CORS durch bestehende NestJS-Config abgedeckt. In Plan 08-03 (favorites-api.ts) umgesetzt.
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js `dns/promises` | IconDiscoveryService SSRF | ✓ | Node 24.16.0 | — |
| Node.js `net` (isIP) | IconDiscoveryService SSRF | ✓ | Built-in | — |
| Global `fetch` | IconDiscoveryService HTTP | ✓ | Node 24.16.0 | — |
| PostgreSQL | FavoriteLink-Tabelle | ✓ | 16.x (Docker) | — |
| Prisma Client | FavoritesService | ✓ | 6.x | — |
[VERIFIED: node --version = v24.16.0 auf Entwicklungsrechner]
---
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest 4.1.9 + @testing-library/react 16.3.2 |
| Config file | `apps/web/vitest.config.ts` |
| Quick run command | `pnpm --filter @tessera/web test` |
| Full suite command | `pnpm --filter @tessera/web test --run` |
[VERIFIED: apps/web/vitest.config.ts, apps/web/package.json scripts]
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| DASH-08 | Calculator zeigt Rechenergebnis | unit | `pnpm --filter @tessera/web test calculator-widget` | ❌ Wave 0 |
| DASH-08 | Tastatureingabe löst Ziffern/Operatoren aus | unit | `pnpm --filter @tessera/web test calculator-widget` | ❌ Wave 0 |
| DASH-09 | Favorites-Widget lädt Links aus API | unit (fetch mock) | `pnpm --filter @tessera/web test favorites-widget` | ❌ Wave 0 |
| DASH-09 | Favorit hinzufügen/bearbeiten/löschen | unit (fetch mock) | `pnpm --filter @tessera/web test favorites-widget` | ❌ Wave 0 |
| DASH-10 | Stoppuhr: Start/Stop/Reset | unit | `pnpm --filter @tessera/web test stopwatch-widget` | ❌ Wave 0 |
| DASH-10 | Stoppuhr rekonstruiert nach Reload | unit (fake timers) | `pnpm --filter @tessera/web test stopwatch-widget` | ❌ Wave 0 |
| DASH-11 | Alle Widgets haben 4 Constraint-Felder | unit | `pnpm --filter @tessera/web test widget-registry` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `pnpm --filter @tessera/web test --run <widget-name>`
- **Per wave merge:** `pnpm --filter @tessera/web test --run`
- **Phase gate:** Vollständige Suite grün vor `/gsd-verify-work`
### Wave 0 Gaps
- [ ] `apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx` — DASH-08
- [ ] `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — DASH-09
- [ ] `apps/web/src/components/dashboard/widgets/link-widget.test.tsx` — DASH-09 (Link)
- [ ] `apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx` — DASH-10
---
## Security Domain
> security_enforcement: true (aus config.json); ASVS Level 1
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | nein | Bestehende JwtAuthGuard global |
| V3 Session Management | nein | Keine neuen Sessions |
| V4 Access Control | ja | FavoritesService: WHERE userId = :userId für alle Queries |
| V5 Input Validation | ja | class-validator DTOs (IsString, IsUrl, IsUUID); URL-Validierung in IconDiscovery |
| V6 Cryptography | nein | Keine Kryptographie |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SSRF via Icon-Discovery | Elevation of Privilege | `isPrivateIpAddress` + `isBlockedHostname` + DNS-Lookup-Check aus personal-dashboard |
| Unauthorized Favorites Access | Spoofing | WHERE userId = :userId in jedem DB-Query; NotFoundException wenn nicht gefunden |
| XSS via iconUrl | Tampering | `<img src={iconUrl}>` ist sicher; kein `dangerouslySetInnerHTML`; iconUrl kommt vom Server |
| Open Redirect via URL-Feld | Spoofing | Links öffnen mit `target="_blank" rel="noreferrer"`; kein Server-Side-Redirect |
| Oversized HTML Fetch | DoS | MAX_HTML_CHARS = 200_000 (Text-Truncation); Timeout 4000ms |
---
## Sources
### Primary (HIGH confidence — direkte Codebase-Verifikation)
- `apps/web/src/components/dashboard/widget-registry.tsx` — WidgetType, WIDGET_CONSTRAINTS, wireXWidget-Pattern
- `apps/web/src/components/dashboard/widgets/clock-widget.tsx` — Referenz-Widget-Muster
- `apps/web/src/components/dashboard/widgets/note-widget.tsx` — updateWidgetConfig-Muster, Test-Mocking
- `apps/web/src/components/dashboard/widget-catalog-modal.tsx` — WIDGET_TYPES-Array-Hardcodierung
- `apps/api/src/dashboard/dashboard.controller.ts` — Controller-Pattern, extractContext
- `apps/api/src/dashboard/dashboard.service.ts` — Service-Pattern, Ownership-Check
- `apps/api/src/dashboard/dto/create-widget.dto.ts` — @IsIn()-Pattern
- `apps/api/src/app.module.ts` — AppModule-Imports-Array
- `apps/api/prisma/schema.prisma` — Bestehendes Schema, Index-Pattern
- `apps/web/src/lib/dashboard-api.ts` — updateWidgetConfig, Fetch-Pattern
- `apps/web/vitest.config.ts` — Test-Framework-Konfiguration
### Referenz-Implementierungen (HIGH confidence — direkt gelesen)
- `/home/vicolab/Schreibtisch/personal-dashboard/src/components/CalculatorWidget.tsx` — 434 Zeilen vollständige Calculator-Logik
- `/home/vicolab/Schreibtisch/personal-dashboard/src/lib/favorite-icons.ts` — 281 Zeilen SSRF-geschützte Icon-Discovery
- `/home/vicolab/Schreibtisch/personal-dashboard/src/components/FavoritesWidget.tsx` — 482 Zeilen Favorites-UI-Logik
### Secondary (MEDIUM confidence — Research-Digest)
- NestJS-Modul-Pattern (context7-Digest) — FavoritesModule-Registrierung
- Prisma db push Pattern (context7-Digest) — Schema-Sync ohne Migration
---
## Metadata
**Confidence breakdown:**
- Standard Stack: HIGH — direkte package.json-Verifikation, keine neuen Pakete
- Architecture: HIGH — vollständige Codebase-Lektüre, klare Integrationspunkte
- Widget-Constraints: MEDIUM — Claude-Discretion basierend auf Widget-Inhalt, erste Schätzung
- Pitfalls: HIGH — aus Codebase-Analyse und Referenz-Implementierungen abgeleitet
- Icon-Discovery: HIGH — vollständige Referenzimplementierung gelesen
**Research date:** 2026-07-01
**Valid until:** 2026-07-31 (stabiler Stack; keine Breaking Changes erwartet)