Files
tessera-ctl/.planning/phases/05-dashboard-calendar/05-03-PLAN.md
T
schalli 6e99ccc2a1 fix(05): clean must_haves, trailing XML, update ROADMAP to 5 plans
- Remove frontend artifacts from 05-03 must_haves (belong to 05-04)
- Remove trailing XML generation artifacts from 05-04 and 05-05
- Update ROADMAP Phase 5: 5 plans across 4 waves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:07:35 +02:00

19 KiB

phase, plan, type, wave, depends_on, requirements, files_modified, autonomous, user_setup, must_haves
phase plan type wave depends_on requirements files_modified autonomous user_setup must_haves
05-dashboard-calendar 03 execute 3
05-01
05-02
CAL-01
CAL-02
CAL-03
DASH-05
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
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
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 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 backend apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts - 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) - .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 408-413 (caching with TTL Pitfall 4) 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. 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')` - `npx tsc --noEmit` exits 0 Event aggregation across providers with caching, test-connection endpoint. Task 4: [BLOCKING] Prisma schema push + encryption key check apps/api/prisma/schema.prisma - apps/api/prisma/schema.prisma (CalendarSource model from Task 1) - 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