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

384 lines
9.7 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)
}
// 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")
}