package calendar import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ErrNotFound = errors.New("calendar: not found") 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, ctag, 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.CTag, &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, ctag, 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.CTag, &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 } // 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, ctag, created_at`, ownerID, name, color, description, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &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, ctag, created_at`, ownerID, id, name, color, description, timezone, ).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.CTag, &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) var o Object err = tx.QueryRow(ctx, `INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) ON CONFLICT (calendar_id, uid) DO UPDATE SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag, dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now() RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`, calendarID, uid, component, data, etag, dtstart, dtend, ).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) } if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil { return nil, fmt.Errorf("bump ctag: %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 } if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil { return fmt.Errorf("bump ctag: %w", err) } return tx.Commit(ctx) }