feat: add CalDAV bidirectional sync service (Phase 3O)

Implements CalDAV sync using github.com/emersion/go-webdav:

- CalDAVService with background polling (configurable per-tenant interval)
- Push: deadlines -> VTODO, appointments -> VEVENT on create/update/delete
- Pull: periodic fetch from CalDAV, reconcile with local DB
- Conflict resolution: KanzlAI wins dates/status, CalDAV wins notes/description
- Conflicts logged as case_events with caldav_conflict type
- UID pattern: kanzlai-{deadline|appointment}-{uuid}@kanzlai.msbls.de
- CalDAV config per tenant in tenants.settings JSONB

Endpoints:
- POST /api/caldav/sync — trigger full sync for current tenant
- GET /api/caldav/status — last sync time, item counts, errors

8 unit tests for UID generation, parsing, path construction, config parsing.
This commit is contained in:
m
2026-03-25 14:01:30 +01:00
parent 2cf01073a3
commit 785df2ced4
7 changed files with 929 additions and 7 deletions

View File

@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// CalDAVHandler handles CalDAV sync HTTP endpoints.
type CalDAVHandler struct {
svc *services.CalDAVService
}
// NewCalDAVHandler creates a new CalDAV handler.
func NewCalDAVHandler(svc *services.CalDAVService) *CalDAVHandler {
return &CalDAVHandler{svc: svc}
}
// TriggerSync handles POST /api/caldav/sync — triggers a full sync for the current tenant.
func (h *CalDAVHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "no tenant context")
return
}
cfg, err := h.svc.LoadTenantConfig(tenantID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status, err := h.svc.SyncTenant(r.Context(), tenantID, *cfg)
if err != nil {
// Still return the status — it contains partial results + error info
writeJSON(w, http.StatusOK, map[string]any{
"status": "completed_with_errors",
"sync": status,
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"sync": status,
})
}
// GetStatus handles GET /api/caldav/status — returns last sync status.
func (h *CalDAVHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "no tenant context")
return
}
status := h.svc.GetStatus(tenantID)
if status == nil {
writeJSON(w, http.StatusOK, map[string]any{
"status": "no_sync_yet",
"last_sync_at": nil,
})
return
}
writeJSON(w, http.StatusOK, status)
}