package calendar import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) var ErrNotFound = errors.New("calendar: not found") // ErrConflict: im Zielkalender liegt bereits ein Termin mit dieser UID. Zwei // Objekte gleicher UID in einer Sammlung wären für CalDAV-Clients nicht // unterscheidbar – deshalb bleibt der Termin dann lieber, wo er ist. var ErrConflict = errors.New("calendar: uid already exists in target calendar") type Store struct { db *pgxpool.Pool } func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} } func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) { rows, err := s.db.Query(ctx, `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`, ownerID, ) if err != nil { return nil, fmt.Errorf("query calendars: %w", err) } defer rows.Close() var out []Calendar for rows.Next() { var c Calendar if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt); err != nil { return nil, fmt.Errorf("scan calendar: %w", err) } out = append(out, c) } return out, rows.Err() } func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) { var c Calendar err := s.db.QueryRow(ctx, `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("query calendar: %w", err) } return &c, nil } // GetCalendarByRef findet einen Kalender über sein Pfadsegment: entweder die // vom Client gewählte Adresse oder – wenn keine gewählt wurde – die UUID. // // Beides in einer Abfrage, damit der Aufruf nicht raten muss, welche Art // Adresse er gerade in der Hand hält. Die gewählte Adresse hat Vorrang: Nur // so bleibt eine Adresse eindeutig, selbst wenn sie zufällig aussieht wie // die UUID eines anderen Kalenders. func (s *Store) GetCalendarByRef(ctx context.Context, ownerID uuid.UUID, ref string) (*Calendar, error) { var c Calendar err := s.db.QueryRow(ctx, `SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at FROM cal_calendars WHERE owner_id = $1 AND (path_segment = $2 OR (path_segment IS NULL AND id::text = $2)) ORDER BY path_segment NULLS LAST LIMIT 1`, ownerID, ref, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("query calendar by ref: %w", err) } return &c, nil } // CreateCalendarAt legt einen Kalender unter einer **vom Client gewählten** // Adresse an – der Fall MKCALENDAR/MKCOL. Ist die Adresse schon vergeben, // kommt ErrConflict: In WebDAV ist das ein 405, kein stilles Überschreiben. func (s *Store) CreateCalendarAt(ctx context.Context, ownerID uuid.UUID, segment, name, color, description string) (*Calendar, error) { if color == "" { color = "#4285f4" } var c Calendar err := s.db.QueryRow(ctx, `INSERT INTO cal_calendars (owner_id, name, color, description, path_segment) VALUES ($1, $2, $3, $4, $5) RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`, ownerID, name, color, description, segment, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if isUniqueViolation(err) { return nil, ErrConflict } if err != nil { return nil, fmt.Errorf("insert calendar at %q: %w", segment, err) } return &c, nil } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } // EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei // Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort // ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder // CalDAV-Client kann das. func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) { cals, err := s.ListCalendars(ctx, ownerID) if err != nil { return nil, err } if len(cals) > 0 { return &cals[0], nil } return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "") } func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) { if color == "" { color = "#4285f4" } var c Calendar err := s.db.QueryRow(ctx, `INSERT INTO cal_calendars (owner_id, name, color, description) VALUES ($1, $2, $3, $4) RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`, ownerID, name, color, description, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if err != nil { return nil, fmt.Errorf("insert calendar: %w", err) } return &c, nil } // UpdateCalendar ändert Name/Farbe/Beschreibung/Zeitzone. Ein nil-Feld lässt // die bestehende Spalte unangetastet – so kann der Aufrufer nur das Feld // schicken, das sich wirklich ändert, ohne vorher den ganzen Datensatz zu // laden. func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name, color, description, timezone *string) (*Calendar, error) { var c Calendar err := s.db.QueryRow(ctx, `UPDATE cal_calendars SET name = COALESCE($3, name), color = COALESCE($4, color), description = COALESCE($5, description), timezone = COALESCE($6, timezone) WHERE owner_id = $1 AND id = $2 RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, sync_floor, created_at`, ownerID, id, name, color, description, timezone, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.SyncFloor, &c.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("update calendar: %w", err) } return &c, nil } func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error { tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id) if err != nil { return fmt.Errorf("delete calendar: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) { rows, err := s.db.Query(ctx, `SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at FROM cal_objects WHERE calendar_id = $1 ORDER BY uid`, calendarID, ) if err != nil { return nil, fmt.Errorf("query objects: %w", err) } defer rows.Close() var out []Object for rows.Next() { o, err := scanObject(rows) if err != nil { return nil, err } out = append(out, o) } return out, rows.Err() } func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) { row := s.db.QueryRow(ctx, `SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid, ) o, err := scanObject(row) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &o, nil } type rowScanner interface { Scan(dest ...any) error } func scanObject(row rowScanner) (Object, error) { var o Object err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt) if err != nil { return Object{}, fmt.Errorf("scan object: %w", err) } return o, nil } // PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben // Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der // Aufrufer vorher – hier zählt nur noch das Schreiben selbst. func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) { tx, err := s.db.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback(ctx) // Die Sequenz **vor** dem Schreiben ziehen: Das Objekt soll genau den // Stand tragen, auf den der Kalender damit springt. seq, err := bumpSeq(ctx, tx, calendarID) if err != nil { return nil, err } var o Object err = tx.QueryRow(ctx, `INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, sync_seq, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) ON CONFLICT (calendar_id, uid) DO UPDATE SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag, dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, sync_seq = EXCLUDED.sync_seq, updated_at = now() RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`, calendarID, uid, component, data, etag, dtstart, dtend, seq, ).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt) if err != nil { return nil, fmt.Errorf("upsert object: %w", err) } // Auferstehung: Wurde dieselbe UID vorher geloescht, steht noch ein // Grabstein da. Bliebe er liegen, bekaeme ein Client beim naechsten // Abgleich beides zu sehen -- die Datei und ihre Todesmeldung. if _, err := tx.Exec(ctx, `DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, calendarID, uid, ); err != nil { return nil, fmt.Errorf("clear tombstone: %w", err) } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit tx: %w", err) } return &o, nil } // MoveObject hängt einen Termin an einen anderen Kalender um. Die UID und der // gesamte .ics-Inhalt bleiben dabei unangetastet – es wechselt nur die // Sammlung, so wie ein MOVE in WebDAV. // // Für CalDAV-Clients ändert sich damit die Adresse des Termins: Sie sehen ihn // in der Quelle verschwinden und im Ziel auftauchen. Deshalb bekommen // **beide** Kalender ein neues ctag – mit nur einem holte der Client die // andere Seite erst beim nächsten Vollabgleich, und der Termin stünde // zwischenzeitlich doppelt oder gar nicht im Telefon. func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid string) (*Object, error) { if fromCal == toCal { return s.GetObjectByUID(ctx, fromCal, uid) } tx, err := s.db.Begin(ctx) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback(ctx) var taken bool if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM cal_objects WHERE calendar_id = $1 AND uid = $2)`, toCal, uid, ).Scan(&taken); err != nil { return nil, fmt.Errorf("check target: %w", err) } if taken { return nil, ErrConflict } // Beide Seiten bekommen einen neuen Stand: In der Quelle ist der Termin // fort (Grabstein), im Ziel ist er neu. fromSeq, err := bumpSeq(ctx, tx, fromCal) if err != nil { return nil, err } toSeq, err := bumpSeq(ctx, tx, toCal) if err != nil { return nil, err } row := tx.QueryRow(ctx, `UPDATE cal_objects SET calendar_id = $1, sync_seq = $2, updated_at = now() WHERE calendar_id = $3 AND uid = $4 RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`, toCal, toSeq, fromCal, uid, ) o, err := scanObject(row) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if err := setTombstone(ctx, tx, fromCal, uid, fromSeq); err != nil { return nil, err } // Im Ziel darf kein alter Grabstein derselben UID stehen bleiben. if _, err := tx.Exec(ctx, `DELETE FROM cal_tombstones WHERE calendar_id = $1 AND uid = $2`, toCal, uid, ); err != nil { return nil, fmt.Errorf("clear tombstone: %w", err) } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit tx: %w", err) } return &o, nil } func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error { tx, err := s.db.Begin(ctx) if err != nil { return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback(ctx) tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid) if err != nil { return fmt.Errorf("delete object: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } seq, err := bumpSeq(ctx, tx, calendarID) if err != nil { return err } if err := setTombstone(ctx, tx, calendarID, uid, seq); err != nil { return err } if err := pruneTombstones(ctx, tx, calendarID); err != nil { return err } return tx.Commit(ctx) } // ── Abgleich nur der Änderungen (RFC 6578) ─────────────────────────────────── // Wie lange ein Grabstein stehen bleibt. // // Er ist die einzige Spur eines gelöschten Termins. Solange er da ist, kann // ein Gerät, das ein halbes Jahr aus war, die Löschung noch nachvollziehen; // danach macht es einen Vollabgleich und kommt auch dorthin – nur teurer. // Ein halbes Jahr deckt einen Urlaub, ein defektes Telefon und eine // Elternzeit ab; wer länger fort war, hat ohnehin ein neues Gerät. const tombstoneTTL = 180 * 24 * time.Hour type execer interface { Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } // bumpSeq zählt den Kalender einen Stand weiter und liefert den neuen. // // Es ist **dasselbe** ctag, das Clients schon zum Erkennen von Änderungen // abfragen – ein zweiter Zähler daneben wäre eine zweite Wahrheit über // denselben Sachverhalt, und zwei Wahrheiten laufen auseinander. func bumpSeq(ctx context.Context, tx execer, calendarID uuid.UUID) (int64, error) { var seq int64 if err := tx.QueryRow(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 RETURNING ctag`, calendarID, ).Scan(&seq); err != nil { return 0, fmt.Errorf("bump ctag: %w", err) } return seq, nil } func setTombstone(ctx context.Context, tx execer, calendarID uuid.UUID, uid string, seq int64) error { _, err := tx.Exec(ctx, `INSERT INTO cal_tombstones (calendar_id, uid, sync_seq, deleted_at) VALUES ($1, $2, $3, now()) ON CONFLICT (calendar_id, uid) DO UPDATE SET sync_seq = EXCLUDED.sync_seq, deleted_at = now()`, calendarID, uid, seq) if err != nil { return fmt.Errorf("set tombstone: %w", err) } return nil } // pruneTombstones räumt alte Grabsteine weg und hebt dabei den Boden an. // // Beides gehört zusammen: Einen Grabstein zu löschen, ohne festzuhalten, bis // wohin geräumt wurde, wäre genau der stille Datenverlust, gegen den es die // Grabsteine überhaupt gibt – ein Client mit altem Token bekäme eine Antwort, // in der eine Löschung einfach fehlt, und behielte den Termin für immer. // // Läuft beim Löschen mit, statt in einem eigenen Auftrag: Nur dort entstehen // Grabsteine, also ist es genau der Moment, in dem es etwas zu räumen gibt. func pruneTombstones(ctx context.Context, tx execer, calendarID uuid.UUID) error { // Das Maximum muss **in der Abfrage** entstehen, nicht beim Lesen der // ersten Zeile: `DELETE … RETURNING` hat keine Reihenfolge. Wer sich die // erste Zeile greift, setzt den Boden auf irgendeinen der geräumten // Stände – und ein Client mit einem Token dazwischen bekäme dann eine // Antwort, in der Löschungen fehlen, ohne dass es jemandem auffiele. var hoechste *int64 if err := tx.QueryRow(ctx, `WITH geraeumt AS ( DELETE FROM cal_tombstones WHERE calendar_id = $1 AND deleted_at < now() - $2::interval RETURNING sync_seq ) SELECT max(sync_seq) FROM geraeumt`, calendarID, tombstoneTTL.String(), ).Scan(&hoechste); err != nil { return fmt.Errorf("prune tombstones: %w", err) } if hoechste == nil { return nil } if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET sync_floor = GREATEST(sync_floor, $2) WHERE id = $1`, calendarID, *hoechste, ); err != nil { return fmt.Errorf("raise sync floor: %w", err) } return nil } // Changes ist die Antwort auf „was hat sich seit Stand N getan". type Changes struct { // Angelegt oder geändert – beides sieht für den Client gleich aus, er // holt das Objekt in beiden Fällen neu. Changed []Object // UIDs, die es nicht mehr gibt. Deleted []string // Der Stand, auf dem der Client danach steht. Token int64 } // ChangesSince liefert alles, was sich in einem Kalender seit `since` getan // hat. `since` = 0 heißt „ich fange bei null an" und liefert den vollen // Bestand – genau das, was ein Client beim ersten Abgleich braucht. // // Der Rückgabewert `ok` ist falsch, wenn das Token zu alt ist: Dann sind // Grabsteine aus diesem Zeitraum schon weggeräumt, und eine Antwort wäre // unvollständig, ohne dass es jemandem auffiele. func (s *Store) ChangesSince(ctx context.Context, cal *Calendar, since int64) (*Changes, bool, error) { if since < 0 || since > cal.CTag { // Ein Token aus der Zukunft gibt es nicht – vermutlich stammt es von // einer anderen Instanz oder aus einer wiederhergestellten Sicherung. return nil, false, nil } if since > 0 && since < cal.SyncFloor { return nil, false, nil } out := &Changes{Token: cal.CTag, Changed: []Object{}, Deleted: []string{}} rows, err := s.db.Query(ctx, `SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at FROM cal_objects WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`, cal.ID, since) if err != nil { return nil, false, fmt.Errorf("query changed objects: %w", err) } defer rows.Close() for rows.Next() { o, err := scanObject(rows) if err != nil { return nil, false, err } out.Changed = append(out.Changed, o) } if err := rows.Err(); err != nil { return nil, false, err } // Beim ersten Abgleich gibt es nichts zu melden, was es nicht mehr gibt: // Der Client kennt den Kalender ja noch gar nicht. Grabsteine würden ihm // nur Löschungen für Termine schicken, die er nie gesehen hat. if since == 0 { return out, true, nil } trows, err := s.db.Query(ctx, `SELECT uid FROM cal_tombstones WHERE calendar_id = $1 AND sync_seq > $2 ORDER BY sync_seq`, cal.ID, since) if err != nil { return nil, false, fmt.Errorf("query tombstones: %w", err) } defer trows.Close() for trows.Next() { var uid string if err := trows.Scan(&uid); err != nil { return nil, false, err } out.Deleted = append(out.Deleted, uid) } return out, true, trows.Err() }