package services import ( "context" "encoding/json" "fmt" "log/slog" "strings" "sync" "time" "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" ) const ( calDAVDomain = "kanzlai.msbls.de" calDAVProdID = "-//KanzlAI//KanzlAI-mGMT//EN" defaultSyncMin = 15 ) // CalDAVConfig holds per-tenant CalDAV configuration from tenants.settings. type CalDAVConfig struct { URL string `json:"url"` Username string `json:"username"` Password string `json:"password"` CalendarPath string `json:"calendar_path"` SyncEnabled bool `json:"sync_enabled"` SyncIntervalMinutes int `json:"sync_interval_minutes"` } // SyncStatus holds the last sync result for a tenant. type SyncStatus struct { TenantID uuid.UUID `json:"tenant_id"` LastSyncAt time.Time `json:"last_sync_at"` ItemsPushed int `json:"items_pushed"` ItemsPulled int `json:"items_pulled"` Errors []string `json:"errors,omitempty"` SyncDuration string `json:"sync_duration"` } // CalDAVService handles bidirectional CalDAV synchronization. type CalDAVService struct { db *sqlx.DB mu sync.RWMutex statuses map[uuid.UUID]*SyncStatus // per-tenant sync status stopCh chan struct{} wg sync.WaitGroup } // NewCalDAVService creates a new CalDAV sync service. func NewCalDAVService(db *sqlx.DB) *CalDAVService { return &CalDAVService{ db: db, statuses: make(map[uuid.UUID]*SyncStatus), stopCh: make(chan struct{}), } } // GetStatus returns the last sync status for a tenant. func (s *CalDAVService) GetStatus(tenantID uuid.UUID) *SyncStatus { s.mu.RLock() defer s.mu.RUnlock() return s.statuses[tenantID] } // setStatus stores the sync status for a tenant. func (s *CalDAVService) setStatus(status *SyncStatus) { s.mu.Lock() defer s.mu.Unlock() s.statuses[status.TenantID] = status } // Start begins the background sync goroutine that polls per-tenant. func (s *CalDAVService) Start() { s.wg.Go(func() { s.backgroundLoop() }) slog.Info("CalDAV sync service started") } // Stop gracefully stops the background sync. func (s *CalDAVService) Stop() { close(s.stopCh) s.wg.Wait() slog.Info("CalDAV sync service stopped") } // backgroundLoop polls tenants at their configured interval. func (s *CalDAVService) backgroundLoop() { // Check every minute, but only sync tenants whose interval has elapsed. ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-s.stopCh: return case <-ticker.C: s.syncAllTenants() } } } // syncAllTenants checks all tenants and syncs those due for a sync. func (s *CalDAVService) syncAllTenants() { configs, err := s.loadAllTenantConfigs() if err != nil { slog.Error("CalDAV: failed to load tenant configs", "error", err) return } for tenantID, cfg := range configs { if !cfg.SyncEnabled { continue } interval := cfg.SyncIntervalMinutes if interval <= 0 { interval = defaultSyncMin } // Check if enough time has passed since last sync status := s.GetStatus(tenantID) if status != nil && time.Since(status.LastSyncAt) < time.Duration(interval)*time.Minute { continue } go func(tid uuid.UUID, c CalDAVConfig) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() if _, err := s.SyncTenant(ctx, tid, c); err != nil { slog.Error("CalDAV: sync failed", "tenant_id", tid, "error", err) } }(tenantID, cfg) } } // loadAllTenantConfigs reads CalDAV configs from all tenants. func (s *CalDAVService) loadAllTenantConfigs() (map[uuid.UUID]CalDAVConfig, error) { type row struct { ID uuid.UUID `db:"id"` Settings json.RawMessage `db:"settings"` } var rows []row if err := s.db.Select(&rows, "SELECT id, settings FROM tenants"); err != nil { return nil, fmt.Errorf("querying tenants: %w", err) } result := make(map[uuid.UUID]CalDAVConfig) for _, r := range rows { cfg, err := parseCalDAVConfig(r.Settings) if err != nil || cfg.URL == "" { continue } result[r.ID] = cfg } return result, nil } // LoadTenantConfig reads CalDAV config for a single tenant. func (s *CalDAVService) LoadTenantConfig(tenantID uuid.UUID) (*CalDAVConfig, error) { var settings json.RawMessage if err := s.db.Get(&settings, "SELECT settings FROM tenants WHERE id = $1", tenantID); err != nil { return nil, fmt.Errorf("loading tenant settings: %w", err) } cfg, err := parseCalDAVConfig(settings) if err != nil { return nil, err } if cfg.URL == "" { return nil, fmt.Errorf("no CalDAV configuration for tenant") } return &cfg, nil } func parseCalDAVConfig(settings json.RawMessage) (CalDAVConfig, error) { if len(settings) == 0 { return CalDAVConfig{}, nil } var wrapper struct { CalDAV CalDAVConfig `json:"caldav"` } if err := json.Unmarshal(settings, &wrapper); err != nil { return CalDAVConfig{}, fmt.Errorf("parsing CalDAV settings: %w", err) } return wrapper.CalDAV, nil } // newCalDAVClient creates a caldav.Client from config. func newCalDAVClient(cfg CalDAVConfig) (*caldav.Client, error) { httpClient := webdav.HTTPClientWithBasicAuth(nil, cfg.Username, cfg.Password) return caldav.NewClient(httpClient, cfg.URL) } // SyncTenant performs a full bidirectional sync for a tenant. func (s *CalDAVService) SyncTenant(ctx context.Context, tenantID uuid.UUID, cfg CalDAVConfig) (*SyncStatus, error) { start := time.Now() status := &SyncStatus{ TenantID: tenantID, } client, err := newCalDAVClient(cfg) if err != nil { status.Errors = append(status.Errors, fmt.Sprintf("creating client: %v", err)) status.LastSyncAt = time.Now() s.setStatus(status) return status, err } // Push local changes to CalDAV pushed, pushErrs := s.pushAll(ctx, client, tenantID, cfg) status.ItemsPushed = pushed status.Errors = append(status.Errors, pushErrs...) // Pull remote changes from CalDAV pulled, pullErrs := s.pullAll(ctx, client, tenantID, cfg) status.ItemsPulled = pulled status.Errors = append(status.Errors, pullErrs...) status.LastSyncAt = time.Now() status.SyncDuration = time.Since(start).String() s.setStatus(status) if len(status.Errors) > 0 { return status, fmt.Errorf("sync completed with %d errors", len(status.Errors)) } return status, nil } // --- Push: Local -> CalDAV --- // pushAll pushes all deadlines and appointments to CalDAV. func (s *CalDAVService) pushAll(ctx context.Context, client *caldav.Client, tenantID uuid.UUID, cfg CalDAVConfig) (int, []string) { var pushed int var errs []string // Push deadlines as VTODO deadlines, err := s.loadDeadlines(tenantID) if err != nil { return 0, []string{fmt.Sprintf("loading deadlines: %v", err)} } for _, d := range deadlines { if err := s.pushDeadline(ctx, client, cfg, &d); err != nil { errs = append(errs, fmt.Sprintf("push deadline %s: %v", d.ID, err)) } else { pushed++ } } // Push appointments as VEVENT appointments, err := s.loadAppointments(ctx, tenantID) if err != nil { errs = append(errs, fmt.Sprintf("loading appointments: %v", err)) return pushed, errs } for _, a := range appointments { if err := s.pushAppointment(ctx, client, cfg, &a); err != nil { errs = append(errs, fmt.Sprintf("push appointment %s: %v", a.ID, err)) } else { pushed++ } } return pushed, errs } // PushDeadline pushes a single deadline to CalDAV (called on create/update). func (s *CalDAVService) PushDeadline(ctx context.Context, tenantID uuid.UUID, deadline *models.Deadline) error { cfg, err := s.LoadTenantConfig(tenantID) if err != nil || !cfg.SyncEnabled { return nil // CalDAV not configured or disabled — silently skip } client, err := newCalDAVClient(*cfg) if err != nil { return fmt.Errorf("creating CalDAV client: %w", err) } return s.pushDeadline(ctx, client, *cfg, deadline) } func (s *CalDAVService) pushDeadline(ctx context.Context, client *caldav.Client, cfg CalDAVConfig, d *models.Deadline) error { uid := deadlineUID(d.ID) cal := ical.NewCalendar() cal.Props.SetText(ical.PropProductID, calDAVProdID) cal.Props.SetText(ical.PropVersion, "2.0") todo := ical.NewComponent(ical.CompToDo) todo.Props.SetText(ical.PropUID, uid) todo.Props.SetText(ical.PropSummary, d.Title) todo.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) if d.Description != nil { todo.Props.SetText(ical.PropDescription, *d.Description) } if d.Notes != nil { desc := "" if d.Description != nil { desc = *d.Description + "\n\n" } todo.Props.SetText(ical.PropDescription, desc+*d.Notes) } // Parse due_date (stored as string "YYYY-MM-DD") if due, err := time.Parse("2006-01-02", d.DueDate); err == nil { todo.Props.SetDate(ical.PropDue, due) } // Map status switch d.Status { case "completed": todo.Props.SetText(ical.PropStatus, "COMPLETED") if d.CompletedAt != nil { todo.Props.SetDateTime(ical.PropCompleted, d.CompletedAt.UTC()) } case "pending": todo.Props.SetText(ical.PropStatus, "NEEDS-ACTION") default: todo.Props.SetText(ical.PropStatus, "IN-PROCESS") } cal.Children = append(cal.Children, todo) path := calendarObjectPath(cfg.CalendarPath, uid) obj, err := client.PutCalendarObject(ctx, path, cal) if err != nil { return fmt.Errorf("putting VTODO: %w", err) } // Update caldav_uid and etag in DB return s.updateDeadlineCalDAV(d.ID, uid, obj.ETag) } // PushAppointment pushes a single appointment to CalDAV (called on create/update). func (s *CalDAVService) PushAppointment(ctx context.Context, tenantID uuid.UUID, appointment *models.Appointment) error { cfg, err := s.LoadTenantConfig(tenantID) if err != nil || !cfg.SyncEnabled { return nil } client, err := newCalDAVClient(*cfg) if err != nil { return fmt.Errorf("creating CalDAV client: %w", err) } return s.pushAppointment(ctx, client, *cfg, appointment) } func (s *CalDAVService) pushAppointment(ctx context.Context, client *caldav.Client, cfg CalDAVConfig, a *models.Appointment) error { uid := appointmentUID(a.ID) cal := ical.NewCalendar() cal.Props.SetText(ical.PropProductID, calDAVProdID) cal.Props.SetText(ical.PropVersion, "2.0") event := ical.NewEvent() event.Props.SetText(ical.PropUID, uid) event.Props.SetText(ical.PropSummary, a.Title) event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) event.Props.SetDateTime(ical.PropDateTimeStart, a.StartAt.UTC()) if a.EndAt != nil { event.Props.SetDateTime(ical.PropDateTimeEnd, a.EndAt.UTC()) } if a.Description != nil { event.Props.SetText(ical.PropDescription, *a.Description) } if a.Location != nil { event.Props.SetText(ical.PropLocation, *a.Location) } cal.Children = append(cal.Children, event.Component) path := calendarObjectPath(cfg.CalendarPath, uid) obj, err := client.PutCalendarObject(ctx, path, cal) if err != nil { return fmt.Errorf("putting VEVENT: %w", err) } return s.updateAppointmentCalDAV(a.ID, uid, obj.ETag) } // DeleteDeadlineCalDAV removes a deadline's VTODO from CalDAV. func (s *CalDAVService) DeleteDeadlineCalDAV(ctx context.Context, tenantID uuid.UUID, deadline *models.Deadline) error { if deadline.CalDAVUID == nil || *deadline.CalDAVUID == "" { return nil } cfg, err := s.LoadTenantConfig(tenantID) if err != nil || !cfg.SyncEnabled { return nil } client, err := newCalDAVClient(*cfg) if err != nil { return fmt.Errorf("creating CalDAV client: %w", err) } path := calendarObjectPath(cfg.CalendarPath, *deadline.CalDAVUID) return client.RemoveAll(ctx, path) } // DeleteAppointmentCalDAV removes an appointment's VEVENT from CalDAV. func (s *CalDAVService) DeleteAppointmentCalDAV(ctx context.Context, tenantID uuid.UUID, appointment *models.Appointment) error { if appointment.CalDAVUID == nil || *appointment.CalDAVUID == "" { return nil } cfg, err := s.LoadTenantConfig(tenantID) if err != nil || !cfg.SyncEnabled { return nil } client, err := newCalDAVClient(*cfg) if err != nil { return fmt.Errorf("creating CalDAV client: %w", err) } path := calendarObjectPath(cfg.CalendarPath, *appointment.CalDAVUID) return client.RemoveAll(ctx, path) } // --- Pull: CalDAV -> Local --- // pullAll fetches all calendar objects from CalDAV and reconciles with local DB. func (s *CalDAVService) pullAll(ctx context.Context, client *caldav.Client, tenantID uuid.UUID, cfg CalDAVConfig) (int, []string) { var pulled int var errs []string query := &caldav.CalendarQuery{ CompFilter: caldav.CompFilter{ Name: ical.CompCalendar, }, } objects, err := client.QueryCalendar(ctx, cfg.CalendarPath, query) if err != nil { return 0, []string{fmt.Sprintf("querying calendar: %v", err)} } for _, obj := range objects { if obj.Data == nil { continue } for _, child := range obj.Data.Children { switch child.Name { case ical.CompToDo: uid, _ := child.Props.Text(ical.PropUID) if uid == "" || !isKanzlAIUID(uid, "deadline") { continue } if err := s.reconcileDeadline(ctx, tenantID, child, obj.ETag); err != nil { errs = append(errs, fmt.Sprintf("reconcile deadline %s: %v", uid, err)) } else { pulled++ } case ical.CompEvent: uid, _ := child.Props.Text(ical.PropUID) if uid == "" || !isKanzlAIUID(uid, "appointment") { continue } if err := s.reconcileAppointment(ctx, tenantID, child, obj.ETag); err != nil { errs = append(errs, fmt.Sprintf("reconcile appointment %s: %v", uid, err)) } else { pulled++ } } } } return pulled, errs } // reconcileDeadline handles conflict resolution for a pulled VTODO. // KanzlAI wins for dates/status, CalDAV wins for notes/description. func (s *CalDAVService) reconcileDeadline(ctx context.Context, tenantID uuid.UUID, comp *ical.Component, remoteEtag string) error { uid, _ := comp.Props.Text(ical.PropUID) deadlineID := extractIDFromUID(uid, "deadline") if deadlineID == uuid.Nil { return fmt.Errorf("invalid UID: %s", uid) } // Load existing deadline var d models.Deadline err := s.db.Get(&d, `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date, warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag, notes, created_at, updated_at FROM deadlines WHERE id = $1 AND tenant_id = $2`, deadlineID, tenantID) if err != nil { return fmt.Errorf("loading deadline: %w", err) } // Check if remote changed (etag mismatch) if d.CalDAVEtag != nil && *d.CalDAVEtag == remoteEtag { return nil // No change } // CalDAV wins for description/notes description, _ := comp.Props.Text(ical.PropDescription) hasConflict := false if description != "" { existingDesc := "" if d.Description != nil { existingDesc = *d.Description } existingNotes := "" if d.Notes != nil { existingNotes = *d.Notes } // CalDAV wins for notes/description if description != existingDesc && description != existingNotes { hasConflict = true _, err = s.db.Exec(`UPDATE deadlines SET notes = $1, caldav_etag = $2, updated_at = NOW() WHERE id = $3 AND tenant_id = $4`, description, remoteEtag, deadlineID, tenantID) if err != nil { return fmt.Errorf("updating deadline notes: %w", err) } } } if !hasConflict { // Just update etag _, err = s.db.Exec(`UPDATE deadlines SET caldav_etag = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3`, remoteEtag, deadlineID, tenantID) if err != nil { return fmt.Errorf("updating deadline etag: %w", err) } } // Log conflict in case_events if detected if hasConflict { s.logConflictEvent(ctx, tenantID, d.CaseID, "deadline", deadlineID, "CalDAV description updated from remote") } return nil } // reconcileAppointment handles conflict resolution for a pulled VEVENT. func (s *CalDAVService) reconcileAppointment(ctx context.Context, tenantID uuid.UUID, comp *ical.Component, remoteEtag string) error { uid, _ := comp.Props.Text(ical.PropUID) appointmentID := extractIDFromUID(uid, "appointment") if appointmentID == uuid.Nil { return fmt.Errorf("invalid UID: %s", uid) } var a models.Appointment err := s.db.GetContext(ctx, &a, `SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2`, appointmentID, tenantID) if err != nil { return fmt.Errorf("loading appointment: %w", err) } if a.CalDAVEtag != nil && *a.CalDAVEtag == remoteEtag { return nil } // CalDAV wins for description description, _ := comp.Props.Text(ical.PropDescription) location, _ := comp.Props.Text(ical.PropLocation) hasConflict := false updates := []string{"caldav_etag = $1", "updated_at = NOW()"} args := []any{remoteEtag} argN := 2 if description != "" { existingDesc := "" if a.Description != nil { existingDesc = *a.Description } if description != existingDesc { hasConflict = true updates = append(updates, fmt.Sprintf("description = $%d", argN)) args = append(args, description) argN++ } } if location != "" { existingLoc := "" if a.Location != nil { existingLoc = *a.Location } if location != existingLoc { hasConflict = true updates = append(updates, fmt.Sprintf("location = $%d", argN)) args = append(args, location) argN++ } } args = append(args, appointmentID, tenantID) query := fmt.Sprintf("UPDATE appointments SET %s WHERE id = $%d AND tenant_id = $%d", strings.Join(updates, ", "), argN, argN+1) if _, err := s.db.ExecContext(ctx, query, args...); err != nil { return fmt.Errorf("updating appointment: %w", err) } if hasConflict { caseID := uuid.Nil if a.CaseID != nil { caseID = *a.CaseID } s.logConflictEvent(ctx, tenantID, caseID, "appointment", appointmentID, "CalDAV description/location updated from remote") } return nil } // --- DB helpers --- func (s *CalDAVService) loadDeadlines(tenantID uuid.UUID) ([]models.Deadline, error) { var deadlines []models.Deadline err := s.db.Select(&deadlines, `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date, warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag, notes, created_at, updated_at FROM deadlines WHERE tenant_id = $1`, tenantID) return deadlines, err } func (s *CalDAVService) loadAppointments(ctx context.Context, tenantID uuid.UUID) ([]models.Appointment, error) { var appointments []models.Appointment err := s.db.SelectContext(ctx, &appointments, "SELECT * FROM appointments WHERE tenant_id = $1", tenantID) return appointments, err } func (s *CalDAVService) updateDeadlineCalDAV(id uuid.UUID, calDAVUID, etag string) error { _, err := s.db.Exec(`UPDATE deadlines SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW() WHERE id = $3`, calDAVUID, etag, id) return err } func (s *CalDAVService) updateAppointmentCalDAV(id uuid.UUID, calDAVUID, etag string) error { _, err := s.db.Exec(`UPDATE appointments SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW() WHERE id = $3`, calDAVUID, etag, id) return err } func (s *CalDAVService) logConflictEvent(ctx context.Context, tenantID, caseID uuid.UUID, objectType string, objectID uuid.UUID, msg string) { if caseID == uuid.Nil { return } metadata, _ := json.Marshal(map[string]string{ "object_type": objectType, "object_id": objectID.String(), "source": "caldav_sync", }) _, err := s.db.ExecContext(ctx, `INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, metadata, created_at, updated_at) VALUES ($1, $2, $3, 'caldav_conflict', $4, $5, $6, NOW(), NOW())`, uuid.New(), tenantID, caseID, "CalDAV sync conflict", msg, metadata) if err != nil { slog.Error("CalDAV: failed to log conflict event", "error", err) } } // --- UID helpers --- func deadlineUID(id uuid.UUID) string { return fmt.Sprintf("kanzlai-deadline-%s@%s", id, calDAVDomain) } func appointmentUID(id uuid.UUID) string { return fmt.Sprintf("kanzlai-appointment-%s@%s", id, calDAVDomain) } func isKanzlAIUID(uid, objectType string) bool { return strings.HasPrefix(uid, "kanzlai-"+objectType+"-") && strings.HasSuffix(uid, "@"+calDAVDomain) } func extractIDFromUID(uid, objectType string) uuid.UUID { prefix := "kanzlai-" + objectType + "-" suffix := "@" + calDAVDomain if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) { return uuid.Nil } idStr := uid[len(prefix) : len(uid)-len(suffix)] id, err := uuid.Parse(idStr) if err != nil { return uuid.Nil } return id } func calendarObjectPath(calendarPath, uid string) string { path := strings.TrimSuffix(calendarPath, "/") return path + "/" + uid + ".ics" }