Files
tessera-ctl/.planning/phases/05-dashboard-calendar/05-03-PLAN.md
T
schalli c8852d2014 docs(05): create dashboard & calendar phase plan
4 plans across 4 waves covering DASH-01..07 + CAL-01..03.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:42:11 +02:00

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous user_setup must_haves
05-dashboard-calendar 03 execute 3
05-01
05-02
apps/api/prisma/schema.prisma
apps/api/src/app.module.ts
apps/api/src/calendar/calendar.module.ts
apps/api/src/calendar/calendar.controller.ts
apps/api/src/calendar/calendar.service.ts
apps/api/src/calendar/crypto.service.ts
apps/api/src/calendar/providers/caldav.provider.ts
apps/api/src/calendar/providers/ics.provider.ts
apps/api/src/calendar/providers/exchange.provider.ts
apps/api/src/calendar/dto/create-calendar-source.dto.ts
apps/api/src/calendar/dto/update-calendar-source.dto.ts
apps/api/src/calendar/dto/calendar-events-query.dto.ts
apps/api/package.json
apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx
apps/web/src/components/settings/calendar-source-form.tsx
apps/web/src/components/settings/calendar-settings-panel.tsx
apps/web/src/components/dashboard/widgets/calendar-widget.tsx
apps/web/src/components/dashboard/widget-registry.ts
apps/web/src/lib/calendar-api.ts
apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx
true
service why env_vars
calendar-encryption AES-256-GCM key required to encrypt calendar credentials at rest
name source
CALENDAR_ENCRYPTION_KEY Generate a 32-byte hex key: openssl rand -hex 32 — add to apps/api .env and docker-compose api environment
truths artifacts key_links
User can configure CalDAV, Exchange, and ICS calendar sources in Settings > Dashboard > Kalender
User can toggle which calendar sources are visible in the widget
Calendar widget shows upcoming events aggregated from selected sources
Calendar credentials are encrypted at rest and never returned in GET responses
path provides contains
apps/api/prisma/schema.prisma CalendarSource model model CalendarSource
path provides exports
apps/api/src/calendar/calendar.service.ts Event aggregation across provider types
CalendarService
path provides
apps/api/src/calendar/providers/ics.provider.ts ICS event fetch + parse
path provides
apps/web/src/components/dashboard/widgets/calendar-widget.tsx Upcoming-events list widget
path provides
apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx Calendar source management UI
from to via pattern
apps/web/src/components/dashboard/widgets/calendar-widget.tsx /api/calendar/events fetch in effect calendar/events
from to via pattern
apps/api/src/calendar/calendar.service.ts prisma.calendarSource user-scoped query with credential decryption prisma.calendarSource
from to via pattern
apps/api/src/calendar/calendar.service.ts ICSProvider/CalDAVProvider/ExchangeProvider provider dispatch by source.type type === 'ics'
Deliver the calendar vertical slice end-to-end: a user configures one or more calendar sources (CalDAV via tsdav, ICS via node-ical, Exchange via ews/Graph) in Settings > Dashboard > Kalender, toggles which sources are visible, and the Calendar widget on the dashboard shows upcoming events aggregated and normalized across all visible sources. Credentials are encrypted at rest (AES-256-GCM) and never leave the backend.

Purpose: Implements CAL-01 (source integration), CAL-02 (visibility selection), CAL-03 (event previews for selected sources), and DASH-05 (calendar widget). This is the heaviest slice — calendar protocols carry the highest hidden complexity per RESEARCH.

Output: Working calendar widget backed by a multi-protocol aggregation service and a source-management settings page.

<artifacts_this_phase_produces> Symbols created by THIS plan (exclude from drift verification — they are new):

Prisma models: CalendarSource NestJS: CalendarModule, CalendarController, CalendarService, CalendarCryptoService, CalDAVProvider, ICSProvider, ExchangeProvider, CreateCalendarSourceDto, UpdateCalendarSourceDto, CalendarEventsQueryDto Interfaces: CalendarEvent, CalendarProvider API endpoints: GET /api/calendar/sources, POST /api/calendar/sources, PATCH /api/calendar/sources/:id, DELETE /api/calendar/sources/:id, POST /api/calendar/sources/:id/test, GET /api/calendar/events Frontend components: CalendarWidget, CalendarSettingsPanel, CalendarSourceForm Frontend modules: calendar-api.ts (fetchSources/addSource/updateSource/deleteSource/testSource/fetchEvents); registry wiring of real CalendarWidget npm: tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-client env var: CALENDAR_ENCRYPTION_KEY </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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 @.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md @.planning/phases/05-dashboard-calendar/05-02-SUMMARY.md Task 1: Calendar backend — model, crypto, source CRUD module apps/api/prisma/schema.prisma, apps/api/src/app.module.ts, apps/api/src/calendar/calendar.module.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/crypto.service.ts, apps/api/src/calendar/dto/create-calendar-source.dto.ts, apps/api/src/calendar/dto/update-calendar-source.dto.ts, apps/api/src/calendar/dto/calendar-events-query.dto.ts, apps/api/package.json - apps/api/prisma/schema.prisma (CalendarSource model follows existing conventions; see CalendarSource example in RESEARCH lines 562-581) - apps/api/src/module-registry/module-registry.module.ts (module pattern) - apps/api/src/module-registry/module-registry.controller.ts (userId/tenant extraction) - apps/api/src/module-registry/module-registry.service.ts (PrismaService injection + CRUD) - apps/api/src/domaincheck/dto/check-domain.dto.ts (DTO pattern) - apps/api/src/app.module.ts (register CalendarModule) - .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 364-412 (anti-patterns: never fetch calendar in browser, encrypt credentials, never return passwords), lines 673-681 (encryption recommendation), lines 743-761 (Security Domain: SSRF, credential exposure) - .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 161-166 (source list + form fields) Install calendar libs: `cd apps/api && pnpm add tsdav@2.2.2 node-ical@0.26.1 ews-javascript-api@0.15.3 @microsoft/microsoft-graph-client@3.0.7` (all Approved in RESEARCH Legitimacy Audit — no checkpoint).
Add Prisma model `CalendarSource`: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `name String`, `type String` ('caldav'|'ics'|'exchange'), `exchangeMode String?` ('ews'|'graph' — only for exchange, per RESEARCH open question 2), `url String`, `username String?`, `encryptedPassword String?` (AES-256-GCM ciphertext), `color String? @default("#3B82F6")`, `isVisible Boolean @default(true)`, `syncIntervalMin Int @default(15)`, `lastSyncAt DateTime?`, `lastSyncError String?`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([userId])`, `@@index([tenantId])`. Source config is per-user (D-09), not per-tenant.

Create `crypto.service.ts` (`CalendarCryptoService`, injectable): AES-256-GCM using `CALENDAR_ENCRYPTION_KEY` (32-byte hex) from ConfigService. `encrypt(plaintext)` returns `iv:authTag:ciphertext` (hex-joined); `decrypt(stored)` reverses it. Throw a clear error at startup if the key is missing/wrong length.

Create CalendarModule (controllers: [CalendarController], providers: [CalendarService, CalendarCryptoService, CalDAVProvider, ICSProvider, ExchangeProvider]); register in app.module.ts imports. Providers added in Task 2 — declare them in providers array now (stub provider files created in Task 2, but module references them; create minimal stubs here if needed so the module compiles, or sequence so Task 2 fills bodies).

CalendarController `@Controller('calendar')`: `@Get('sources')` (returns sources WITHOUT password fields — use Prisma `select` excluding encryptedPassword, Pitfall 3), `@Post('sources')`, `@Patch('sources/:id')`, `@Delete('sources/:id')`, `@Post('sources/:id/test')` (testConnection), `@Get('events')` (aggregated events — implemented in Task 3). Every handler extracts userId + tenantId; all source mutations verify userId ownership.

CalendarService (this task: source CRUD only): inject PrismaService + CalendarCryptoService. `getSources(userId)` → findMany with `select` that NEVER includes encryptedPassword (return a boolean `hasCredentials` instead). `addSource(userId, tenantId, dto)` → encrypt dto.password into encryptedPassword before create. `updateSource(id, userId, dto)` → ownership check, re-encrypt if password provided. `deleteSource(id, userId)` → ownership check + delete.

DTOs: CreateCalendarSourceDto — `@IsString() @IsNotEmpty() name`, `@IsIn(['caldav','ics','exchange']) type`, `@IsUrl({ protocols: ['https'], require_protocol: true }) url` (https-only — SSRF mitigation, Security Domain), `@IsOptional() @IsString() username`, `@IsOptional() @IsString() password`, `@IsOptional() @IsIn(['ews','graph']) exchangeMode`, `@IsOptional() @IsHexColor() color`. UpdateCalendarSourceDto — all optional incl. `@IsOptional() @IsBoolean() isVisible` (CAL-02 toggle). CalendarEventsQueryDto — `@IsOptional() @IsDateString() from`, `@IsOptional() @IsDateString() to`.

SSRF guard: in addSource/test, reject URLs resolving to private IP ranges (10.x, 192.168.x, 127.x, 169.254.x) in addition to https-only scheme.
cd apps/api && npx prisma validate && npx tsc --noEmit - apps/api/package.json contains tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-client - schema.prisma contains `model CalendarSource` with `encryptedPassword` and `isVisible` - calendar.controller.ts GET /sources uses Prisma `select` that excludes encryptedPassword (grep: no `encryptedPassword: true` in the sources select) - crypto.service.ts uses `aes-256-gcm` - create-calendar-source.dto.ts restricts url to https (`protocols: ['https']`) - app.module.ts imports CalendarModule - `npx prisma validate` and `npx tsc --noEmit` exit 0 Calendar source CRUD with encrypted credentials, https-only URLs, no password leakage in GET responses. Task 2: Calendar providers (CalDAV, ICS, Exchange) apps/api/src/calendar/providers/caldav.provider.ts, apps/api/src/calendar/providers/ics.provider.ts, apps/api/src/calendar/providers/exchange.provider.ts - apps/api/src/calendar/calendar.service.ts (from Task 1 — providers are injected here; match the CalendarProvider interface) - apps/api/src/calendar/crypto.service.ts (decrypt credentials before use) - .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 287-310 (CalendarEvent + CalendarProvider interface), lines 96-101 (library purposes), Assumptions A2/A3/A6 (date-range filter, exchange OAuth, recurring expansion) - .planning/phases/05-dashboard-calendar/05-PATTERNS.md lines 353-361 (no-analog — use RESEARCH library patterns) Define a shared `CalendarProvider` interface (in calendar.service.ts or a types file): `fetchEvents(source, from, to): Promise` and `testConnection(source): Promise`. `CalendarEvent { id, sourceId, title, start: Date, end: Date, allDay: boolean, location?, description? }`.
`ics.provider.ts` (ICSProvider, injectable) — the simplest, implement fully: HTTPS GET the .ics URL (timeout ~8s, abort on timeout — Pitfall 4), parse with node-ical `async.fromURL` or `sync.parseICS`. Filter VEVENTs to the [from,to] window. Expand recurring events via node-ical RRULE support (Assumption A6 — if node-ical's expansion is insufficient, note the ical.js fallback but do not block). Map to CalendarEvent. testConnection: fetch + parse, return true if at least the request succeeds.

`caldav.provider.ts` (CalDAVProvider, injectable): use tsdav `createDAVClient` with Basic auth (username + decrypted password). Login, fetch calendars, `fetchCalendarObjects` with the time-range filter (Assumption A2 — pass start/end so only the window is fetched). Parse returned iCal data with node-ical, map to CalendarEvent. testConnection: attempt login + list calendars.

`exchange.provider.ts` (ExchangeProvider, injectable): dispatch on `source.exchangeMode`. For 'graph' use @microsoft/microsoft-graph-client to query `/me/calendarView` with start/end (Exchange Online, RESEARCH State of the Art prefers Graph). For 'ews' use ews-javascript-api FindAppointments over a CalendarView (on-premise). Map to CalendarEvent. Wrap both in try/catch; on auth failure surface a generic error (no credential details — Security V7). If Exchange integration cannot be fully realized for a given mode, return an empty array and set lastSyncError rather than throwing (graceful degradation; D-08 requires the source TYPE to be configurable and attempted — the widget must not crash).

All providers: never log decrypted credentials; respect per-request timeouts.
cd apps/api && npx tsc --noEmit - ics.provider.ts imports node-ical and filters events by date window - caldav.provider.ts imports tsdav and passes a time-range to fetchCalendarObjects - exchange.provider.ts branches on exchangeMode ('graph' vs 'ews') - no provider logs decrypted passwords (grep: no `console.log` of password/credential vars) - all three implement fetchEvents + testConnection returning the CalendarEvent shape - `npx tsc --noEmit` exits 0 Three providers fetch + normalize events; Exchange degrades gracefully; no credential leakage. Task 3: Event aggregation + caching + calendar widget apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widget-registry.ts, apps/web/src/lib/calendar-api.ts, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx - apps/api/src/calendar/calendar.service.ts (from Task 1/2 — add aggregateEvents) - apps/api/src/calendar/providers/*.provider.ts (from Task 2 — dispatch by type) - apps/web/src/components/dashboard/widget-registry.ts (replace calendar placeholder with real CalendarWidget) - apps/web/src/components/dashboard/widgets/clock-widget.tsx (WidgetProps pattern) - .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 408-413 (caching with TTL Pitfall 4) - .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 110, 206-209 (calendar widget spec: time/title/source color dot; empty-state copy) - Test (calendar-widget.test.tsx): given a mocked `/api/calendar/events` response with two events, the widget renders both titles, their times, and a source color dot per event; given an empty response renders t('widgets.calendarEmptyNoEvents'); given a no-sources response renders t('widgets.calendarEmptyNoSources') Backend — add `aggregateEvents(userId, from, to)` to CalendarService: load the user's `isVisible: true` sources (CAL-02/CAL-03), dispatch each to its provider by `source.type` (ics/caldav/exchange), decrypt credentials per source, fetch in parallel (`Promise.allSettled` so one failing source doesn't break others — set lastSyncError on failures), merge + sort by start ascending, return normalized CalendarEvent[] including each event's source color. Cache results per user in an in-memory Map with a 5-minute TTL (Pitfall 4) — serve cached immediately, refresh in background. Implement `GET /calendar/events` in the controller using CalendarEventsQueryDto (default window: now → now+30 days). Implement `POST /calendar/sources/:id/test` → provider.testConnection, update lastSyncAt/lastSyncError.
Frontend — `calendar-api.ts`: fetchSources, addSource, updateSource (incl. isVisible toggle), deleteSource, testSource, fetchEvents — all `credentials:'include'`.

`calendar-widget.tsx` (DASH-05, D-10 read-only): `'use client'`. On mount fetch /api/calendar/events. Render an upcoming-events list: each row shows time (locale-formatted), title, and an 8px source color dot (UI-SPEC color palette). Three states per UI-SPEC copy: no sources configured → t('widgets.calendarEmptyNoSources'); sources but no events → t('widgets.calendarEmptyNoEvents'); events → list. Loading state while fetching. Refresh periodically (e.g. every 5 min) to match backend cache. Never fetch external calendars directly from the browser (RESEARCH anti-pattern — always via /api/calendar/events).

Update `widget-registry.ts`: replace the calendar placeholder component with the real CalendarWidget. Keep WIDGET_CONSTRAINTS.

Write calendar-widget.test.tsx per <behavior> with mocked fetch.
cd apps/web && pnpm vitest run src/components/dashboard/widgets/calendar-widget.test.tsx && cd apps/api && npx tsc --noEmit - calendar.service.ts aggregateEvents uses `Promise.allSettled` and filters `isVisible` - calendar.service.ts caches events with a TTL (grep: a Map + timestamp/expiry check) - calendar.controller.ts contains `@Get('events')` and `@Post('sources/:id/test')` - calendar-widget.tsx fetches `/api/calendar/events` and renders three distinct empty/list states - widget-registry.ts references the real `CalendarWidget` - calendar-widget.test.tsx exits 0; apps/api `tsc --noEmit` exits 0 Calendar widget shows aggregated upcoming events from visible sources, cached, read-only, with correct empty states. Task 4: Calendar settings page (source management + visibility) apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-source-form.tsx, apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx - apps/web/src/app/(portal)/settings/layout.tsx (from 05-01 — nested settings layout) - apps/web/src/components/settings/settings-sidebar.tsx (from 05-01 — this page is the "Kalender" sub-item target) - apps/web/src/lib/calendar-api.ts (from 05-03 Task 3) - .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 120, 161-166, 204-209, 216-220, 256-261 (calendar settings panel spec, source form fields, connection test, delete confirmation, copy) - Test (calendar-settings.test.tsx): rendering CalendarSettingsPanel with two mocked sources lists both with name + type badge + visibility toggle; toggling a source calls updateSource with the new isVisible; the add form requires name+type+url before enabling submit Create `settings/dashboard/calendar/page.tsx` (D-11): `'use client'`, renders CalendarSettingsPanel. Title t('settings.categoryCalendar').
Create `calendar-settings-panel.tsx`: fetch sources (calendar-api.fetchSources). Render a source list — each row: color dot, name, type badge (CalDAV/Exchange/ICS), visibility toggle switch (CAL-02 — calls updateSource({isVisible})), connection-status indicator (green check / orange warning from lastSyncError per UI-SPEC), edit + delete actions. Delete uses a confirmation dialog (UI-SPEC destructive: heading + body + "Quelle loeschen"/"Abbrechen"). "Quelle hinzufuegen" button reveals CalendarSourceForm. Empty state: t('settings.calendarEmpty') (UI-SPEC copy).

Create `calendar-source-form.tsx`: fields Name (required), Type (select CalDAV/Exchange/ICS, required), when Exchange show an Exchange-mode select (Exchange Online=graph / Exchange Server=ews, RESEARCH open question 2), URL (required, https validation client-side), Username (optional, hidden for ICS), Password (password input, optional, hidden for ICS), Color (from the 8-color palette in UI-SPEC). On save call addSource (or updateSource when editing); then auto-run testSource and show connection-success/error toast (UI-SPEC copy). Validate URL is https before submit.

Write calendar-settings.test.tsx per <behavior> with mocked calendar-api.
cd apps/web && pnpm vitest run "src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx" && pnpm exec tsc --noEmit - settings/dashboard/calendar/page.tsx renders CalendarSettingsPanel - calendar-settings-panel.tsx has a visibility toggle calling updateSource with isVisible - calendar-source-form.tsx shows an Exchange-mode select only when type is exchange, and hides username/password for ICS - calendar-source-form.tsx validates https before submit - delete uses a confirmation dialog (contains the "Quelle loeschen"/"Delete source" CTA) - calendar-settings.test.tsx exits 0; `pnpm exec tsc --noEmit` exits 0 Users manage CalDAV/Exchange/ICS sources, toggle widget visibility, test connections, and delete with confirmation. Task 5: [BLOCKING] Prisma schema push + encryption key check apps/api/prisma/schema.prisma - apps/api/prisma/schema.prisma (CalendarSource model from Task 1 must exist) - apps/api/src/calendar/crypto.service.ts (CALENDAR_ENCRYPTION_KEY consumer) Ensure `CALENDAR_ENCRYPTION_KEY` (32-byte hex, generate via `openssl rand -hex 32`) is present in apps/api `.env` and the docker-compose api service environment so the crypto service can start. Then push the schema to the running PostgreSQL container: `npx prisma db push` from apps/api, then `npx prisma generate`. MANDATORY — type checks pass without the push (false-positive). Only the new CalendarSource table is added; if data loss is reported, STOP and flag for manual review rather than passing `--accept-data-loss`. cd apps/api && test -n "$(grep -s CALENDAR_ENCRYPTION_KEY .env)" && npx prisma db push --skip-generate && npx prisma generate - apps/api/.env contains CALENDAR_ENCRYPTION_KEY - `npx prisma db push` exits 0 and reports in sync on a second run - live DB contains the CalendarSource table - `npx prisma generate` exits 0 Live schema includes CalendarSource; encryption key configured; client regenerated.

<threat_model>

Trust Boundaries

Boundary Description
Browser → Calendar API User submits source config incl. credentials
API → External calendar servers Backend fetches events from user-supplied URLs
API → PostgreSQL Encrypted credentials stored at rest

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-09 Information Disclosure GET /calendar/sources mitigate Prisma select excludes encryptedPassword; return hasCredentials boolean only (Pitfall 3)
T-05-10 Information Disclosure CalendarSource at rest mitigate AES-256-GCM encryption via CalendarCryptoService + CALENDAR_ENCRYPTION_KEY
T-05-11 Tampering / SSRF source URL fetch (ics/caldav) mitigate DTO https-only (protocols:['https']) + block private IP ranges (10/192.168/127/169.254) + request timeouts
T-05-12 Elevation of Privilege source mutations + /events mitigate All endpoints scoped to userId from JWT; ownership check before update/delete; events per-user not per-tenant (D-09)
T-05-13 Information Disclosure connection error responses mitigate Generic error messages; no credential details in errors (ASVS V7)
T-05-SC Tampering npm installs (tsdav/node-ical/ews/graph) mitigate All Approved in RESEARCH Legitimacy Audit; no [ASSUMED]/[SUS] → no blocking checkpoint
</threat_model>
- `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/widgets/calendar-widget.test.tsx "src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx"` green - `npx prisma db push` reports in sync; CALENDAR_ENCRYPTION_KEY present

<success_criteria>

  • User configures CalDAV, ICS, and Exchange sources in Settings > Dashboard > Kalender
  • User toggles source visibility; only visible sources feed the widget (CAL-02/CAL-03)
  • Calendar widget shows aggregated upcoming events with source color dots, read-only
  • Credentials encrypted at rest, never returned in GET, https-only URLs enforced
  • One failing source does not break the others (Promise.allSettled) </success_criteria>
Create `.planning/phases/05-dashboard-calendar/05-03-SUMMARY.md` when done