Files
paliad/internal/handlers/appointments.go
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

576 lines
16 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
// requireCalDAV reports the CalDAV-capable service or writes 501. Used by
// every CalDAV endpoint so misconfigured deployments return a clean error
// instead of crashing.
func requireCalDAV(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.caldav == nil || !dbSvc.caldav.Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "CalDAV-Synchronisation derzeit nicht verf\u00fcgbar \u2014 Administrator kontaktieren (CALDAV_ENCRYPTION_KEY nicht gesetzt).",
})
return false
}
return true
}
// GET /api/appointments?project_id=&from=&to=&type=
func handleListAppointments(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
q := r.URL.Query()
filter := services.AppointmentListFilter{}
raw := q.Get("project_id")
if raw == "" {
raw = q.Get("project_id")
}
if raw != "" {
projectID, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
return
}
filter.ProjectID = &projectID
}
if raw := q.Get("from"); raw != "" {
t, err := parseDateOrTime(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid from"})
return
}
filter.From = &t
}
if raw := q.Get("to"); raw != "" {
t, err := parseDateOrTime(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid to"})
return
}
filter.To = &t
}
if raw := q.Get("type"); raw != "" {
filter.Type = &raw
}
rows, err := dbSvc.appointment.ListVisibleForUser(r.Context(), uid, filter)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/appointments/summary
func handleAppointmentsSummary(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
c, err := dbSvc.appointment.SummaryCounts(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, c)
}
// GET /api/projects/{id}/appointments
func handleListAppointmentsForProject(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
directOnly := parseDirectOnly(r.URL.Query().Get("direct_only"))
rows, err := dbSvc.appointment.ListForProject(r.Context(), uid, projectID, directOnly)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/appointments
func handleCreateAppointment(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateAppointmentInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.appointment.Create(r.Context(), uid, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
// GET /api/appointments/{id}
func handleGetAppointment(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
t, err := dbSvc.appointment.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// PATCH /api/appointments/{id}
func handleUpdateAppointment(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.UpdateAppointmentInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := dbSvc.appointment.Update(r.Context(), uid, id, input)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
// DELETE /api/appointments/{id}
func handleDeleteAppointment(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.appointment.Delete(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/caldav-config
func handleGetCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
// 501 only when CalDAV is fully unavailable. GET still works without
// the cipher so the UI can show "no config" without failing — but if
// the cipher is missing the user can't actually do anything, so return
// 501 for consistency with PUT/DELETE.
if dbSvc.caldav == nil || !dbSvc.caldav.Enabled() {
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": "CalDAV-Synchronisation derzeit nicht verf\u00fcgbar \u2014 Administrator kontaktieren (CALDAV_ENCRYPTION_KEY nicht gesetzt).",
"enabled": false,
})
return
}
cfg, err := dbSvc.caldav.GetConfig(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if cfg == nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"url": cfg.URL,
"username": cfg.Username,
"calendar_path": cfg.CalendarPath,
"enabled": cfg.Enabled,
"last_sync_at": cfg.LastSyncAt,
"last_sync_error": cfg.LastSyncError,
"updated_at": cfg.UpdatedAt,
})
}
// PUT /api/caldav-config
func handlePutCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.SaveConfigInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
cfg, err := dbSvc.caldav.SaveConfig(r.Context(), uid, input)
if err != nil {
writeCalDAVError(w, err)
return
}
writeJSON(w, http.StatusOK, cfg)
}
// DELETE /api/caldav-config
func handleDeleteCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if err := dbSvc.caldav.DeleteConfig(r.Context(), uid); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/caldav-config/test — performs PROPFIND with the supplied
// credentials, no persistence.
func handleTestCalDAVConfig(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.SaveConfigInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ctx := services.WithCalDAVUser(r.Context(), uid)
if err := dbSvc.caldav.TestConnection(ctx, input); err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// GET /api/caldav-bindings — list the authenticated user's CalDAV
// bindings (the (calendar, scope) entries layered on the single CalDAV
// server connection). Read-only in Slice 2a; full CRUD lands in Slice 2b.
func handleListCalDAVBindings(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": "CalDAV bindings unavailable (CalDAV service not configured)",
})
return
}
rows, err := dbSvc.caldavBindings.ListForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.UserCalendarBinding{}
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/caldav-bindings — create a new binding for the
// authenticated user and synchronously fire a first push so the modal
// closes with events already landed. Returns 201 with the binding row.
func handleCreateCalDAVBinding(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
return
}
var input services.CreateBindingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Default to enabled=true so the modal "Hinzufügen" button does the
// expected thing without forcing the user to toggle anything.
if !input.Enabled {
input.Enabled = true
}
binding, err := dbSvc.caldavBindings.Create(r.Context(), uid, input)
if err != nil {
writeCalDAVError(w, err)
return
}
// Synchronous first push per Q5 of the Slice 2 design (m's 2026-05-20
// pick): block the request so the user sees events already landed
// when the modal closes. PushBindingNow logs per-event failures and
// returns; we only surface a hard config/cipher error.
pushed, pushErr := dbSvc.caldav.PushBindingNow(r.Context(), uid, binding)
if pushErr != nil {
// Binding was created; sync failed. Tell the UI both bits so it
// can show "binding added, initial sync had a problem".
writeJSON(w, http.StatusCreated, map[string]any{
"binding": binding,
"initial_pushed": pushed,
"initial_sync_error": pushErr.Error(),
})
return
}
// Ensure the per-user goroutine is running so future ticks happen.
dbSvc.caldav.EnsureLoop(uid)
writeJSON(w, http.StatusCreated, map[string]any{
"binding": binding,
"initial_pushed": pushed,
})
}
// PATCH /api/caldav-bindings/{id} — partial update. Lazy scope cleanup
// per Q6: stale targets get dropped on the next sync tick, not here.
func handlePatchCalDAVBinding(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var input services.UpdateBindingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
binding, err := dbSvc.caldavBindings.Update(r.Context(), uid, id, input)
if err != nil {
writeCalDAVError(w, err)
return
}
writeJSON(w, http.StatusOK, binding)
}
// DELETE /api/caldav-bindings/{id} — best-effort remote cleanup of every
// .ics this binding pushed, then drop the binding row. On partial remote
// failure the binding is disabled (not deleted) so the next sync tick
// can retry; the response is 202 Accepted in that case.
func handleDeleteCalDAVBinding(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldav == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
fully, err := dbSvc.caldav.RemoveBinding(r.Context(), uid, id)
if err != nil {
writeCalDAVError(w, err)
return
}
if !fully {
writeJSON(w, http.StatusAccepted, map[string]any{
"status": "partial",
"message": "Binding disabled; some remote events could not be deleted. Retry on next sync tick.",
})
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/caldav-mkcalendar — creates a new calendar on the user's
// CalDAV server via MKCALENDAR + a matching binding row in one logical
// transaction. Slice 2c only — visible when /api/caldav-discover
// reports supports_mkcalendar=true. Errors:
// - 501 when supports_mkcalendar=false (caller should show the
// Google-degrade UX with the manual-URL input).
// - 409 when the slugified name + 3 retries all collide on the
// server. UI should ask the user to type their own name.
func handleCalDAVMakeCalendar(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateCalendarInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
result, err := dbSvc.caldav.MakeCalendar(r.Context(), uid, input)
if err != nil {
switch {
case errors.Is(err, services.ErrMKCalendarUnsupported):
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": err.Error(),
"supports_mkcalendar": false,
})
case errors.Is(err, services.ErrCalendarNameTaken):
writeJSON(w, http.StatusConflict, map[string]any{
"error": err.Error(),
})
default:
// Binding-create / push errors carry the partial result so
// the UI can surface "created remotely but binding failed".
if result != nil {
writeJSON(w, http.StatusCreated, map[string]any{
"calendar_path": result.CalendarPath,
"binding": result.Binding,
"initial_pushed": result.InitialPushed,
"initial_sync_error": err.Error(),
})
return
}
writeCalDAVError(w, err)
}
return
}
writeJSON(w, http.StatusCreated, result)
}
// GET /api/caldav-discover — walks the calendar-home-set chain on the
// user's CalDAV server and returns the calendars they own. Cached
// server-side for 5 minutes per user (Q4 of Slice 2 brief).
func handleCalDAVDiscover(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
result, err := dbSvc.caldav.DiscoverCalendars(r.Context(), uid)
if err != nil {
writeCalDAVError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
// GET /api/caldav-config/log — last 5 sync attempts.
func handleCalDAVSyncLog(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbSvc.caldav.SyncLog(r.Context(), uid, 5)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// writeCalDAVError maps services.ErrCalDAVNoKey + invalid input to the
// right HTTP status; defers everything else to writeServiceError.
func writeCalDAVError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrCalDAVNoKey):
writeJSON(w, http.StatusNotImplemented, map[string]string{
"error": "CalDAV-Synchronisation derzeit nicht verf\u00fcgbar \u2014 Administrator kontaktieren.",
})
default:
writeServiceError(w, err)
}
}
// parseDateOrTime accepts "2026-04-16" (interpreted as midnight UTC) or
// any RFC3339 timestamp.
func parseDateOrTime(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UTC(), nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.UTC(), nil
}
return time.Time{}, errors.New("expected RFC3339 or YYYY-MM-DD")
}