Commit Graph

4 Commits

Author SHA1 Message Date
mAi
fbd087e0cd feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:26:23 +02:00
mAi
1fcfab7791 feat(caldav): Slice 2b write APIs + picker UI (t-paliad-212)
User-visible Slice 2 milestone: the /einstellungen/caldav Kalender
section now lets a user pin multiple calendars to Paliad via a
single-step add modal (Q3 of the Slice 2 brief). m greenlit
"all yes / all R" on 2026-05-20, so this lands with: synchronous
first-push on POST (Q5), lazy cleanup on PATCH scope change (Q6),
5-minute server-side cache on /api/caldav-discover (Q4),
calendar_path retained-but-deprecated (Q7).

Backend
- CalDAVService.PushBindingNow — runs one push pass for a single
  binding synchronously; called from POST /api/caldav-bindings so
  the modal closes with events already landed.
- CalDAVService.RemoveBinding — best-effort remote-event DELETE +
  binding row drop (§2.6 of brief). On partial remote failure,
  the binding is disabled instead of dropped and the handler
  surfaces 202 Accepted.
- CalDAVService.EnsureLoop — spawns the per-user sync goroutine
  for users who didn't have one before this request.
- CalDAVService.DiscoverCalendars — walks current-user-principal
  → calendar-home-set → child PROPFIND (RFC 6764 §6 / RFC 6638
  §10). Cached 5 minutes per user; invalidated on SaveConfig /
  DeleteConfig.
- caldav_client.go gains DiscoverCalendars + propfindHrefs +
  listCalendars + supporting multistatus types. VEVENT-only
  filter skips iCloud reminder lists / addr books.

HTTP API
- POST /api/caldav-bindings — create binding + sync first-push;
  201 with binding + initial_pushed count, or 201 with
  initial_sync_error when the push fails after binding creation.
- PATCH /api/caldav-bindings/{id} — partial update.
- DELETE /api/caldav-bindings/{id} — calls RemoveBinding;
  responds 204 (full cleanup) or 202 (partial — binding disabled
  for next-tick retry).
- GET /api/caldav-discover — returns {calendars, calendar_home}
  for the picker.

Frontend
- /einstellungen/caldav Kalender section: list of binding cards
  with enabled toggle / Edit / Remove. "+ Kalender hinzufügen"
  opens the single-step modal.
- Single-step add modal: source picker (discovery dropdown or
  custom URL toggle) + scope radio (all_visible / personal_only
  / project + project picker) + display name. Edit mode reuses
  the modal with the source field hidden.
- 32 new i18n keys under caldav.bindings.* (DE primary, EN
  parallel) covering modal copy, card actions, error messages,
  delete-confirm, scope labels.

Verification
- Live Supabase BEGIN..ROLLBACK: full CRUD flow exercised
  (create → patch display_name → patch scope → second
  all_visible after the first scope-shifts → delete);
  the partial unique index frees correctly when scope moves
  off all_visible, no race or constraint surprise.
- go build ./... + go test ./internal/... + bun run build all
  clean.
2026-05-20 13:18:00 +02:00
mAi
694c7a53ad feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar
calendar_path to the binding-row model introduced in Slice 1
(mig 101). Invisible-but-shippable: existing Phase F users keep
their backfilled all_visible binding, new users hitting the legacy
PUT /api/caldav-config get an auto-created all_visible binding so
the "configure → it just works" UX survives. Slice 2b adds the
picker UI and write APIs on top.

Schema (mig 107)
- paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL
  so audit history survives binding deletes).
- Per-binding index for the read path.
- Idempotent (column-exists DO block) + assertion.

Services
- CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled,
  Get, Create, Update, Delete, SetSyncStatus. Mirrors the table
  CHECK constraints client-side so the API returns useful 400s.
- AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding,
  ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding.
  Replaces SetCalDAVMeta as the authoritative source of per-target
  state; legacy scalar columns still written for back-compat.
- AppointmentService.ForBinding: scope filter implementing
  all_visible, personal_only, project. Hierarchy scopes
  (client/litigation/patent/case) return ErrUnsupportedScope —
  Slice 3 wires them via the existing path-based descendant
  predicate.

Sync engine rewrite
- CalDAVService.Start iterates ListAllEnabled to discover users
  with at least one enabled binding.
- runSyncOnce loops bindings, writes one caldav_sync_log row per
  (user, binding) tick, rolls the worst-case error up onto
  user_caldav_config.last_sync_error so /api/caldav-config still
  shows aggregate status.
- pushBinding pushes the ForBinding() slice + cleans up
  stale-target rows (project unshared, scope PATCHed).
- pullBinding swaps the N×GET pattern for REPORT calendar-multiget
  (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate
  limits) and reconciles via per-target etag comparison.
- Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the
  user's matching bindings using appointmentInBinding() — best
  effort per binding, same 30s timeout as Phase F.
- SaveConfig auto-creates an all_visible binding on first-time
  configure so Phase F "configure → events appear" survives the
  cut-over.

CalDAV client
- New ReportMultiget verb implementing RFC 4791 §7.9
  calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google
  Calendar's per-request cap.

HTTP API
- GET /api/caldav-bindings — read-only list of the authenticated
  user's bindings. Slice 2b adds POST/PATCH/DELETE.

Verification
- BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies
  cleanly + the synthetic two-binding scenario lands the project
  appointment in both bindings while keeping the personal one in
  master only; cascade on appointment-delete drops targets; cascade
  on binding-delete drops targets AND sets sync_log.binding_id NULL.
- go build ./..., go test ./internal/..., bun run build all clean.

Backwards-compat
- paliad.appointments.caldav_uid / caldav_etag still written in
  pushBinding so legacy readers see fresh values. Slice 4 drops
  them after telemetry confirms no path still reads them.
2026-05-20 13:05:27 +02:00
m
b56ef660df feat(termine): Phase F — Termine (appointments) + CalDAV sync
Ship the appointments feature with bidirectional CalDAV synchronisation.
Closes KanzlAI audit §1.3 by encrypting CalDAV passwords at rest with
AES-256-GCM; plaintext credentials never touch the DB or API responses.

Backend
- `internal/services/termin_service.go`: CRUD with per-row visibility.
  Personal Termine (akte_id NULL) visible only to created_by; Akte-attached
  Termine follow AkteService.GetByID. Every Akte-attached mutation appends
  an akten_events row for the audit trail.
- `internal/services/caldav_service.go` (+ caldav_client.go, caldav_ical.go,
  caldav_crypto.go): per-user goroutine, 60s tick, push VEVENT + pull with
  UID/ETag reconciliation. Last-write-wins on conflict; conflicts on
  Akte-attached Termine append to akten_events.
- CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64). Server refuses
  to start with malformed key; unset key leaves CalDAV disabled and all
  /api/caldav-config* endpoints return 501.
- Migration 013: paliad.user_caldav_config (password_encrypted bytea) +
  paliad.caldav_sync_log (last-5 per user). RLS: user owns their row only.
- HTTP handlers: GET/POST/PATCH/DELETE /api/termine, GET
  /api/akten/{id}/termine, /api/caldav-config CRUD + /test + /log.

Frontend
- Termine list / detail / new / kalender pages (Bun TSX + per-page client
  TS), calendar month grid with type-coloured dots and click-popup.
- Einstellungen/CalDAV settings page: URL/user/password (write-only),
  test-connection button, status card, sync log table, delete button that
  purges credentials.
- Akten detail "Termine" tab replaces the Phase D placeholder — inline
  add-termin form + list.
- Sidebar: Termine entry activated; new "Einstellungen" group with CalDAV.
- DE/EN i18n complete for every new surface.

Security posture
- AES-GCM with 12-byte random nonce prepended to ciphertext
- Password field has `json:"-"` on the model; API never returns it
- Frontend always sends password via write-only <input type=password>
- DeleteConfig purges the encrypted blob from the primary row
- TestConnection without stored creds requires explicit password

t-paliad-010
2026-04-17 11:59:49 +02:00