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.
576 lines
16 KiB
Go
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")
|
|
}
|