Neuer APIHandler unter /api/v1/calendars (Session-Auth, wie der Rest
der API) neben dem bestehenden CalDAV-Handler (App-Passwörter,
/caldav/) – zwei Handler, zwei Auth-Modelle, wie schon bei /dav.
- GET/POST /api/v1/calendars – Liste, Anlegen
- PATCH/DELETE /api/v1/calendars/{id} – Umbenennen, Farbe, Zeitzone,
Löschen (go-webdav kennt PROPPATCH und Kalender-Löschen nicht – dafür
gibt's im CalDAV-Protokoll selbst keinen Weg)
- GET .../export – alle Termine eines Kalenders als eine .ics-Datei,
VTIMEZONE-Blöcke dedupliziert
- POST .../import – .ics mit mehreren VEVENT/VTODO einlesen, jede
Komponente wird ein eigenes Objekt wie bei einem einzelnen CalDAV-PUT
Gegen den laufenden Container getestet (Session-Login, CSRF-Token):
Anlegen, Umbenennen, zwei Termine importieren, sauberer Re-Export,
Löschen, und dass ohne Login 401 statt Daten kommt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
234 lines
7.4 KiB
Go
234 lines
7.4 KiB
Go
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)
|
||
}
|