--- phase: 05-dashboard-calendar plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/api/prisma/schema.prisma - apps/api/src/app.module.ts - apps/api/src/dashboard/dashboard.module.ts - apps/api/src/dashboard/dashboard.controller.ts - apps/api/src/dashboard/dashboard.service.ts - apps/api/src/dashboard/dto/save-layout.dto.ts - apps/api/src/dashboard/dto/create-widget.dto.ts - apps/api/src/dashboard/dto/update-widget-config.dto.ts - apps/web/package.json - apps/web/src/app/(portal)/page.tsx - apps/web/src/components/layout/header.tsx - apps/web/src/app/(portal)/settings/layout.tsx - apps/web/src/app/(portal)/settings/page.tsx - apps/web/src/components/settings/settings-sidebar.tsx - apps/web/src/components/dashboard/dashboard-grid.tsx - apps/web/src/components/dashboard/edit-mode-toggle.tsx - apps/web/src/components/dashboard/widget-catalog-modal.tsx - apps/web/src/components/dashboard/widget-registry.ts - apps/web/src/components/dashboard/widgets/widget-wrapper.tsx - apps/web/src/components/dashboard/widgets/clock-widget.tsx - apps/web/src/lib/stores/dashboard-store.ts - apps/web/src/lib/dashboard-api.ts - apps/web/src/messages/de.json - apps/web/src/messages/en.json - apps/web/src/components/dashboard/dashboard-grid.test.tsx - apps/web/src/components/dashboard/widgets/clock-widget.test.tsx autonomous: true requirements: [DASH-01, DASH-02, DASH-03, DASH-07] must_haves: truths: - "User sees a configurable dashboard as their start page with a drag-and-drop grid" - "User can enter edit mode via the pencil icon and add a clock widget" - "User can drag and resize widgets in edit mode" - "Layout persists per user in PostgreSQL and is restored on next login" - "Settings page is reachable via the user avatar menu with a sub-sidebar" artifacts: - path: "apps/api/prisma/schema.prisma" provides: "DashboardLayout + WidgetInstance models" contains: "model DashboardLayout" - path: "apps/api/src/dashboard/dashboard.controller.ts" provides: "Dashboard layout + widget CRUD endpoints" exports: ["DashboardController"] - path: "apps/web/src/components/dashboard/dashboard-grid.tsx" provides: "react-grid-layout Responsive grid wrapper" min_lines: 40 - path: "apps/web/src/components/dashboard/widgets/clock-widget.tsx" provides: "Digital clock widget with timezone support" - path: "apps/web/src/app/(portal)/settings/layout.tsx" provides: "Settings layout with sub-sidebar" key_links: - from: "apps/web/src/lib/stores/dashboard-store.ts" to: "/api/dashboard/layout" via: "fetch in saveLayout/loadLayout" pattern: "dashboard/layout" - from: "apps/web/src/components/layout/header.tsx" to: "/settings" via: "Next.js Link in user dropdown" pattern: "/settings" - from: "apps/api/src/dashboard/dashboard.service.ts" to: "prisma.dashboardLayout" via: "Prisma upsert scoped by userId" pattern: "prisma\\.dashboardLayout" --- Deliver the first end-to-end dashboard slice: a user opens the portal start page, enters edit mode, adds a clock widget, drags/resizes it, exits edit mode, and the layout persists in PostgreSQL — restored on next login. This plan also establishes the shared scaffolding every later widget slice depends on: the widget registry (all 4 types declared with size constraints), the widget catalog modal, the dashboard Zustand store, the dashboard CRUD backend, the settings page shell (layout + sub-sidebar + header link), and all i18n keys. Purpose: Prove the full vertical stack (Prisma → NestJS → grid UI → persistence) works with one real widget (clock) before adding the heavier widgets. Implements DASH-01, DASH-02, DASH-03, DASH-07. Decisions implemented in this plan: D-01 (edit-mode pencil toggle, save on exit), D-02 (new users start with empty grid + empty-state hint), D-03 (edit mode only changes size/position; other config in Settings), D-04 (widgets multi-placeable — keyed by instance UUID), D-05 (layout persisted per-user in PostgreSQL, not LocalStorage), D-06 (per-type min sizes in WIDGET_CONSTRAINTS), D-07 (no reset button — manual delete only), D-19 (settings via avatar menu, not sidebar), D-20 (settings sub-sidebar), D-21 (desktop grid scales proportionally), D-22 (mobile stacks vertically via react-grid-layout breakpoints). Output: Working dashboard grid with clock widget, persisted layout, and settings shell. Symbols created by THIS plan (exclude from drift verification — they are new): **Prisma models:** `DashboardLayout`, `WidgetInstance` **NestJS:** `DashboardModule`, `DashboardController`, `DashboardService`, `SaveLayoutDto`, `CreateWidgetDto`, `UpdateWidgetConfigDto` **API endpoints:** `GET /api/dashboard/layout`, `PUT /api/dashboard/layout`, `GET /api/dashboard/widgets`, `POST /api/dashboard/widgets`, `PATCH /api/dashboard/widgets/:id/config`, `DELETE /api/dashboard/widgets/:id` **Frontend components:** `DashboardGrid`, `EditModeToggle`, `WidgetCatalogModal`, `WidgetWrapper`, `ClockWidget`, `SettingsLayout` (settings/layout.tsx default export), `SettingsSidebar` **Frontend modules:** `useDashboardStore` (Zustand), `widget-registry.ts` exporting `WIDGET_REGISTRY` + `WIDGET_CONSTRAINTS` + `WidgetDefinition` + `WidgetProps` types, `dashboard-api.ts` exporting `fetchLayout`/`saveLayout`/`addWidget`/`removeWidget`/`updateWidgetConfig` **i18n namespaces:** `settings`, `widgets`, and additions to existing `dashboard` namespace **Type:** `WidgetType = 'clock' | 'search' | 'calendar' | 'note'` @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-dashboard-calendar/05-CONTEXT.md @.planning/phases/05-dashboard-calendar/05-RESEARCH.md @.planning/phases/05-dashboard-calendar/05-PATTERNS.md @.planning/phases/05-dashboard-calendar/05-UI-SPEC.md Task 1: Dashboard backend — Prisma models, CRUD API, module wiring apps/api/prisma/schema.prisma, apps/api/src/dashboard/dashboard.module.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dto/save-layout.dto.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/dto/update-widget-config.dto.ts, apps/api/src/app.module.ts - apps/api/prisma/schema.prisma (current models — replicate `@id @default(uuid())`, `tenantId`, `createdAt`/`updatedAt`, `@@index` conventions; see LdapConfig and Module) - apps/api/src/module-registry/module-registry.module.ts (Module pattern — controllers/providers/exports) - apps/api/src/module-registry/module-registry.controller.ts (tenant-context extraction at lines 48-53; replicate user+tenant extraction) - apps/api/src/module-registry/module-registry.service.ts (PrismaService injection + upsert pattern, lines 1-12, 53-82) - apps/api/src/domaincheck/dto/check-domain.dto.ts (class-validator DTO pattern) - apps/api/src/app.module.ts (module registration + global guards — register DashboardModule in imports) - GET /dashboard/layout returns the calling user's saved layout JSON (empty object shape {lg:[],md:[],sm:[],xs:[],xxs:[]} when none exists), never another user's - PUT /dashboard/layout upserts layout scoped by userId, returns saved record - GET /dashboard/widgets returns only the calling user's widget instances - POST /dashboard/widgets creates a WidgetInstance with widgetType + default config, returns it with its UUID - PATCH /dashboard/widgets/:id/config merges config; rejects (404/forbidden) if the widget belongs to another user - DELETE /dashboard/widgets/:id removes only own widget Add two Prisma models to schema.prisma. `DashboardLayout`: fields `id String @id @default(uuid())`, `userId String @unique`, `tenantId String`, `layouts Json @default("{}")`, `updatedAt DateTime @updatedAt`, `createdAt DateTime @default(now())`, `@@index([tenantId])`. `WidgetInstance`: fields `id String @id @default(uuid())`, `userId String`, `tenantId String`, `widgetType String` (values 'clock'|'search'|'calendar'|'note'), `config Json @default("{}")`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([userId])`, `@@index([tenantId])`. Keep layout (position/size) and config (widget settings) in SEPARATE models per RESEARCH anti-pattern — never embed widget config inside the layout JSONB. Create DashboardModule (controllers: [DashboardController], providers: [DashboardService], exports: [DashboardService]) and register it in app.module.ts imports array alongside ModuleRegistryModule. DashboardController: routes `@Controller('dashboard')` with `@Get('layout')`, `@Put('layout')`, `@Get('widgets')`, `@Post('widgets')`, `@Patch('widgets/:id/config')`, `@Delete('widgets/:id')`. In every handler extract `const userId = (req as any).user?.id;` and `const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;` and throw `ForbiddenException('No tenant context')` when missing (replicate module-registry pattern). All endpoints are authenticated by the global JwtAuthGuard — no @Public. DashboardService: inject PrismaService. `getLayout(userId)` → `prisma.dashboardLayout.findUnique({ where: { userId } })` returning `layouts` or default `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`. `saveLayout(userId, tenantId, dto)` → `prisma.dashboardLayout.upsert({ where: { userId }, update: { layouts: dto.layouts }, create: { userId, tenantId, layouts: dto.layouts } })`. `getWidgets(userId)` → findMany scoped by userId. `addWidget(userId, tenantId, dto)` → create with widgetType + config default `{}`. `updateWidgetConfig(id, userId, dto)` → first verify ownership (findUnique, throw NotFoundException if not found or userId mismatch), then update merging config. `removeWidget(id, userId)` → verify ownership then delete. Enforce userId match on ALL widget mutations (security V4 — not just tenantId). DTOs: SaveLayoutDto has `@IsObject() layouts!: Record` (use class-validator IsObject). CreateWidgetDto has `@IsString() @IsIn(['clock','search','calendar','note']) widgetType!: string` and `@IsOptional() @IsObject() config?: Record`. UpdateWidgetConfigDto has `@IsObject() config!: Record`. cd apps/api && npx prisma validate && npx tsc --noEmit -p tsconfig.json - schema.prisma contains `model DashboardLayout` and `model WidgetInstance` - `npx prisma validate` exits 0 - dashboard.controller.ts contains `@Controller('dashboard')` and all six route decorators (`@Get('layout')`, `@Put('layout')`, `@Get('widgets')`, `@Post('widgets')`, `@Patch('widgets/:id/config')`, `@Delete('widgets/:id')`) - dashboard.service.ts contains `prisma.dashboardLayout.upsert` and ownership check on widget mutations (`userId` comparison before update/delete) - app.module.ts imports array contains `DashboardModule` - `npx tsc --noEmit` exits 0 for apps/api Dashboard CRUD backend compiles, Prisma schema validates, all six endpoints scoped to userId. Task 2: Dashboard grid + clock widget + widget registry + store (frontend slice) apps/web/package.json, apps/web/src/components/dashboard/dashboard-grid.tsx, apps/web/src/components/dashboard/edit-mode-toggle.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-registry.ts, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/clock-widget.tsx, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/dashboard-api.ts, apps/web/src/app/(portal)/page.tsx, apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/widgets/clock-widget.test.tsx - apps/web/src/app/(portal)/page.tsx (current dashboard placeholder — replaced entirely) - apps/web/src/lib/stores/marketplace-store.ts (Zustand store WITHOUT persist — dashboard-store follows this, NOT sidebar-store's persist) - apps/web/src/lib/stores/sidebar-store.ts (store shape reference) - apps/web/src/app/(portal)/marketplace/page.tsx (fetch with credentials:'include' pattern) - apps/web/src/components/theme-toggle.tsx (icon-button toggle pattern for edit-mode-toggle) - apps/web/vitest.config.ts (test env jsdom, globals true, @ alias) - apps/web/src/components/layout/sidebar.test.tsx (existing test style — render + assertions) - .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 437-499 (react-grid-layout v2 Responsive setup, ResizeObserver width, CSS imports) and lines 318-341 (WidgetDefinition/WidgetProps/WIDGET_CONSTRAINTS) - .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 94-147, 276-298 (grid spec, widget catalog modal, size constraints, edit-mode flow) - Test (dashboard-grid.test.tsx): rendering DashboardGrid with one clock widget instance renders a node with the widget instance id; passing isEditMode=true renders the edit affordances (drag handle / delete button present in DOM) - Test (clock-widget.test.tsx): ClockWidget with config `{ timezone: 'Europe/Berlin', showDate: true }` renders a time string and a date string; with `showDate: false` renders no date element - Empty grid (no widgets) renders the empty-state heading text key Install grid dependency: `cd apps/web && pnpm add react-grid-layout@2.2.3` (legitimacy: Approved in RESEARCH Package Legitimacy Audit — STRML/react-grid-layout, 3.1M/wk, no checkpoint needed). Create `widget-registry.ts` exporting: type `WidgetType = 'clock' | 'search' | 'calendar' | 'note'`; interface `WidgetProps { instanceId: string; config: Record; isEditMode: boolean }`; interface `WidgetDefinition { type: WidgetType; nameKey: string; descriptionKey: string; icon: React.ComponentType; minW: number; minH: number; defaultW: number; defaultH: number }`; const `WIDGET_CONSTRAINTS` with exact values from UI-SPEC size table — 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}; const `WIDGET_REGISTRY: Record` declaring all four types (clock fully implemented this plan; search/calendar/note components added in later plans — declare them here with placeholder component refs imported lazily or a stub that renders nameKey, so the catalog lists all four now). Use inline SVG icons (established project pattern, per UI-SPEC). Create `dashboard-api.ts` with functions using `fetch` + `credentials: 'include'`: `fetchLayout()` → GET /api/dashboard/layout; `saveLayout(layouts)` → PUT /api/dashboard/layout; `fetchWidgets()` → GET /api/dashboard/widgets; `addWidget(widgetType)` → POST /api/dashboard/widgets; `removeWidget(id)` → DELETE /api/dashboard/widgets/:id; `updateWidgetConfig(id, config)` → PATCH /api/dashboard/widgets/:id/config. Create `dashboard-store.ts` (Zustand, NO persist — layout comes from DB per D-05): state `layouts`, `widgets: {id,widgetType,config}[]`, `isEditMode`, `isDirty`; actions `setEditMode`, `updateLayouts`, `addWidget` (calls api.addWidget then appends), `removeWidget` (calls api.removeWidget then filters), `loadDashboard` (parallel fetchLayout + fetchWidgets on mount), `saveLayout` (calls api.saveLayout with current layouts, clears isDirty). Save only on exiting edit mode (D-01) — do NOT save on every drag (RESEARCH anti-pattern). Create `dashboard-grid.tsx`: `'use client'`. Import `import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css';` (Pitfall 2). Use the `Responsive` component from react-grid-layout with a ResizeObserver-measured container width (v2 requires explicit width — Pitfall 1; never use removed `data-grid` v1 prop). BREAKPOINTS {lg:1200,md:996,sm:768,xs:480,xxs:0}, COLS {lg:12,md:10,sm:6,xs:4,xxs:1}, rowHeight 40, margin [16,16]. `isDraggable`/`isResizable` bound to isEditMode. `draggableHandle=".widget-drag-handle"`. Each child keyed by `widget.id` (instance UUID, NOT widgetType — Pitfall 5). `onLayoutChange(_, allLayouts)` returns ALL breakpoint layouts (Pitfall 6) → store.updateLayouts. Render `WidgetWrapper` per widget which renders the registry component for the widgetType. Create `widget-wrapper.tsx`: card with `bg-card border rounded-lg shadow-sm`. In edit mode show: `.widget-drag-handle` bar (top), delete X button (top-right, `text-destructive` on hover) calling store.removeWidget. `role="article"` + aria-label = widget type. Renders the widget body component via WIDGET_REGISTRY[widgetType].component. Create `clock-widget.tsx`: `'use client'`. Digital clock using `Intl.DateTimeFormat` with `config.timezone` (default 'Europe/Berlin'), ticking via setInterval(1s) cleaned up on unmount. Display size via `clamp(28px,4vw,40px)` per UI-SPEC. If `config.showDate` (default false) render date below using locale-aware format. NEVER compute UTC offsets manually (RESEARCH Don't Hand-Roll — use Intl). Create `edit-mode-toggle.tsx`: pencil/checkmark icon button (top-right), `aria-pressed` + dynamic `aria-label` ('Dashboard bearbeiten'/'Aenderungen speichern' via t()). On toggle to off, calls store.saveLayout. Active state uses `bg-primary`. Create `widget-catalog-modal.tsx`: shadcn-style dialog (`role="dialog" aria-modal="true"`, Escape to close, focus trap), 2x2 grid of the four widget type cards from WIDGET_REGISTRY (icon + nameKey + descriptionKey). Click adds widget via store.addWidget(type) and closes. Only opened from the in-edit-mode "Widget hinzufuegen" button. Rewrite `page.tsx`: `'use client'`. On mount call store.loadDashboard. Render EditModeToggle (top-right), DashboardGrid, and when isEditMode the "Widget hinzufuegen" button (opens catalog modal). When widgets empty render empty state (grid icon + `widgets.emptyHeading` + `widgets.emptyBody` per UI-SPEC copywriting, edit button still visible). Use `useTranslations`. Write the two test files per using @testing-library/react. Mock dashboard-api fetch calls. Mock react-grid-layout's Responsive to a passthrough that renders children if needed for jsdom stability. cd apps/web && pnpm vitest run src/components/dashboard/dashboard-grid.test.tsx src/components/dashboard/widgets/clock-widget.test.tsx - apps/web/package.json dependencies contains `react-grid-layout` - dashboard-grid.tsx contains `import 'react-grid-layout/css/styles.css'` and `import 'react-resizable/css/styles.css'` - dashboard-grid.tsx keys grid children by widget instance id (no `i: 'clock'` literal type key) - clock-widget.tsx contains `Intl.DateTimeFormat` and no manual UTC offset arithmetic - dashboard-store.ts does NOT use `persist` middleware - widget-registry.ts exports `WIDGET_CONSTRAINTS` with all four types and exact min/default sizes from UI-SPEC - `pnpm vitest run` for both test files exits 0 User can render dashboard, add a clock via catalog, see it tick; grid drag/resize gated by edit mode; tests green. Task 3: Settings shell + header link + i18n keys apps/web/src/app/(portal)/settings/layout.tsx, apps/web/src/app/(portal)/settings/page.tsx, apps/web/src/components/settings/settings-sidebar.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json - apps/web/src/app/(portal)/layout.tsx (AppShell wrapper — settings layout nests INSIDE this, adds its own sub-sidebar) - apps/web/src/components/layout/sidebar.tsx (active-item pattern, role=navigation, aria-current) - apps/web/src/components/layout/header.tsx (user dropdown — insert Settings link before the logout `
`, same CSS classes, lines ~130-155) - apps/web/src/messages/de.json and en.json (namespace structure — add `settings` and `widgets` namespaces, extend `dashboard`) - .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 113-120, 154-159, 187-253 (settings layout spec, navigation, full copywriting contract DE+EN) Create `settings/layout.tsx` (D-19/D-20): nested layout rendering `
` with `` (left, 220px, `bg-sidebar` + left border) and a `
{children}
`. The portal AppShell stays (this is a nested route-group layout). Include a "Zurueck zum Dashboard" link with left-arrow icon at top of content per UI-SPEC. Create `settings-sidebar.tsx`: `'use client'` navigation with `role="navigation"` `aria-label` = t('settings.navLabel'). Phase 05 category "Dashboard" (top-level) with sub-items "Widgets" (`/settings/dashboard`) and "Kalender"/"Calendar" (`/settings/dashboard/calendar`). Use `usePathname` for active state with `bg-sidebar-accent text-sidebar-accent-foreground` + `aria-current="page"` (match main sidebar pattern). The actual sub-pages are created in plans 05-02 (search/widgets) and 05-03 (calendar) — link to them now; Next.js renders 404 until they exist, which is acceptable within this wave's scope since 05-02/05-03 create them. Create `settings/page.tsx`: redirect to `/settings/dashboard` (use `redirect` from next/navigation) so the bare /settings entry lands on the dashboard settings category. Modify `header.tsx`: add a Settings `` in the user-avatar dropdown, inserted directly before the existing logout `
`. Use identical CSS classes as the logout button (`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors`), a gear/settings SVG icon, label `tHeader('settings')` or `t('settings.link')`, and `onClick={() => setDropdownOpen(false)}`. Extend i18n: in both de.json and en.json add a `settings` namespace (keys: link, navLabel, backToDashboard, categoryDashboard, categoryWidgets, categoryCalendar, plus calendar/provider delete-confirm strings from UI-SPEC) and a `widgets` namespace (keys: emptyHeading, emptyBody, addWidget, catalogTitle, deleteTooltip, and per-widget nameKey/descriptionKey for clock/search/calendar/note, clock date hints, search placeholder, notes default title, calendar empty-no-sources/empty-no-events/connection-success/connection-error, autosave error, layout-load error, widget-save error). Copy EXACT strings from UI-SPEC Copywriting Contract (German primary lines 189-220, English lines 224-253) — e.g. DE emptyHeading "Keine Widgets aktiv", EN "No active widgets". Also add the header `settings` key ("Einstellungen"/"Settings"). Keep JSON valid (no trailing commas). cd apps/web && node -e "JSON.parse(require('fs').readFileSync('src/messages/de.json','utf8')); JSON.parse(require('fs').readFileSync('src/messages/en.json','utf8')); console.log('json-ok')" && pnpm exec tsc --noEmit - settings/layout.tsx contains `SettingsSidebar` and a flex container with sub-sidebar - header.tsx contains `href="/settings"` Link in the dropdown - de.json contains the key value "Keine Widgets aktiv" and en.json contains "No active widgets" - de.json contains a `settings` namespace and a `widgets` namespace; both files parse as valid JSON (node JSON.parse exits 0) - settings-sidebar.tsx contains `aria-current="page"` and `usePathname` - `tsc --noEmit` exits 0 Settings page reachable via avatar menu with sub-sidebar; all phase i18n keys present in DE+EN. Task 4: [BLOCKING] Prisma schema push apps/api/prisma/schema.prisma - apps/api/prisma/schema.prisma (the models added in Task 1 must exist before push) After Tasks 1-3 are complete and the schema contains DashboardLayout + WidgetInstance, push the schema to the running PostgreSQL container so the live database has the new tables. Run `npx prisma db push` from apps/api. This is MANDATORY — build and type checks pass without it (types come from the generated client, not the live DB), producing a false-positive verification state. If the push reports it would cause data loss on existing tables (it should not — only new tables are added), STOP and flag for manual review rather than passing `--accept-data-loss` blindly. Regenerate the Prisma client (`npx prisma generate`) if not auto-run by push. cd apps/api && npx prisma db push --skip-generate && npx prisma generate - `npx prisma db push` exits 0 - The live database contains tables for DashboardLayout and WidgetInstance (push reports "in sync" on a second run) - `npx prisma generate` exits 0 Live PostgreSQL schema includes DashboardLayout and WidgetInstance tables; Prisma client regenerated. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Browser → Dashboard API | Authenticated user submits layout/widget mutations | | API → PostgreSQL | User-scoped reads/writes of layout + widget config | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-05-01 | Elevation of Privilege | dashboard.controller widget endpoints | mitigate | Every widget mutation verifies `userId` match in service (findUnique → compare → NotFoundException), not just tenantId (ASVS V4) | | T-05-02 | Information Disclosure | GET /dashboard/layout, /widgets | mitigate | Queries scoped by userId from JWT; no userId accepted from request body/params for reads | | T-05-03 | Tampering | SaveLayoutDto / CreateWidgetDto | mitigate | class-validator DTOs: IsObject on layouts/config, IsIn whitelist on widgetType (ASVS V5) | | T-05-04 | Spoofing | All dashboard endpoints | accept | Covered by existing global JwtAuthGuard (Phase 2) — no new auth surface | | T-05-SC | Tampering | npm install react-grid-layout | mitigate | Package is Approved in RESEARCH Legitimacy Audit (STRML, 3.1M/wk); no [ASSUMED]/[SUS] → no blocking checkpoint required | - `cd apps/api && npx prisma validate` exits 0 - `cd apps/api && npx tsc --noEmit` exits 0 - `cd apps/web && pnpm exec tsc --noEmit` exits 0 - `cd apps/web && pnpm vitest run src/components/dashboard` green - de.json and en.json parse as valid JSON - `npx prisma db push` reports schema in sync - User opens portal start page and sees dashboard (empty state when no widgets) - User enters edit mode, opens widget catalog, adds a clock widget - Clock ticks in configured timezone; drag/resize work only in edit mode - Exiting edit mode persists layout to PostgreSQL; reload restores it - Settings page reachable via avatar menu, shows sub-sidebar - All four widget types appear in catalog (clock functional, others scaffolded for later plans) Create `.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md` when done