package calendar import ( "context" "encoding/json" "fmt" "log" "strings" "time" "golang.org/x/oauth2" "wannpassts/internal/config" "wannpassts/internal/crypto" "wannpassts/internal/db" ) const syncStaleAfter = 5 * time.Minute type Syncer struct { DB *db.DB Cfg *config.Config } // NeedsSync meldet, ob eine der Verbindungen älter als syncStaleAfter ist. func NeedsSync(conns []*db.Connection, now time.Time) bool { for _, c := range conns { if !c.LastSyncedTs.Valid || now.Unix()-c.LastSyncedTs.Int64 > int64(syncStaleAfter.Seconds()) { return true } } return false } // SyncUser aktualisiert die busy-Zeiträume aller Verbindungen eines Nutzers // für das Buchungsfenster [jetzt, jetzt+horizon]. Fehler einzelner Provider // werden pro Verbindung gespeichert und werfen den Gesamtsync nicht ab. func (s *Syncer) SyncUser(ctx context.Context, user *db.User) { conns, err := s.DB.ConnectionsForUser(user.ID) if err != nil { log.Printf("sync: Verbindungen von User %d nicht lesbar: %v", user.ID, err) return } if len(conns) == 0 { return } from := time.Now().Add(-time.Hour) to := time.Now().Add(time.Duration(user.HorizonDays) * 24 * time.Hour) for _, conn := range conns { s.syncConnection(ctx, conn, user, from, to) } } func (s *Syncer) syncConnection(ctx context.Context, conn *db.Connection, user *db.User, from, to time.Time) { prov, err := s.provider(conn) if err != nil { _ = s.DB.SetConnectionState(conn.ID, false, err.Error()) return } cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() busy, err := prov.FetchBusy(cctx, from, to) if err != nil { _ = s.DB.SetConnectionState(conn.ID, false, cleanErr(err.Error())) return } slots := make([]db.BusySlot, 0, len(busy)) for _, iv := range busy { slots = append(slots, db.BusySlot{StartTs: iv.Start.Unix(), EndTs: iv.End.Unix()}) } if err := s.DB.ReplaceBusyForConnection(conn.ID, user.ID, slots); err != nil { _ = s.DB.SetConnectionState(conn.ID, false, "Speichern fehlgeschlagen: "+err.Error()) return } _ = s.DB.SetConnectionState(conn.ID, true, "") } // SyncInBackground startet den Sync ohne den Aufrufer zu blocken. func (s *Syncer) SyncInBackground(user *db.User) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() s.SyncUser(ctx, user) }() } func (s *Syncer) provider(conn *db.Connection) (Provider, error) { plain, err := crypto.Decrypt(s.Cfg.EncryptionKey, conn.ConfigEnc) if err != nil { return nil, fmt.Errorf("gespeicherte Verbindung nicht entschlüsselbar (ENCRYPTION_KEY geändert?): %w", err) } switch conn.Provider { case "google": var cfg GoogleConnConfig if err := json.Unmarshal(plain, &cfg); err != nil { return nil, fmt.Errorf("Google-Konfiguration unlesbar: %w", err) } oconf := GoogleOAuthConfig(s.Cfg.GoogleClientID, s.Cfg.GoogleClientSecret, s.Cfg.AppURL+"/api/calendars/google/callback") calendarID := cfg.CalendarID return NewGoogleProvider(oconf, cfg, func(t *oauth2.Token) { s.saveGoogleToken(conn.ID, calendarID, t) }), nil case "caldav": var cfg CalDAVConnConfig if err := json.Unmarshal(plain, &cfg); err != nil { return nil, fmt.Errorf("CalDAV-Konfiguration unlesbar: %w", err) } return NewCalDAVProvider(cfg), nil case "ics": var cfg ICSConnConfig if err := json.Unmarshal(plain, &cfg); err != nil { return nil, fmt.Errorf("ICS-Konfiguration unlesbar: %w", err) } return NewICSProvider(cfg, ""), nil } return nil, fmt.Errorf("unbekannter Provider %q", conn.Provider) } func (s *Syncer) saveGoogleToken(connID int64, calendarID string, t *oauth2.Token) { data, err := json.Marshal(GoogleConnConfig{Token: t, CalendarID: calendarID}) if err != nil { return } enc, err := crypto.Encrypt(s.Cfg.EncryptionKey, data) if err != nil { return } _ = s.DB.UpdateConnectionConfig(connID, enc) } func cleanErr(msg string) string { msg = strings.TrimSpace(msg) if len(msg) > 300 { msg = msg[:300] + "…" } return msg }