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.
384 lines
9.7 KiB
Go
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")
|
|
}
|