Files
Aether/backend/internal/calendar/store.go
T
marcandClaude Opus 5 77bf16b27f feat(calendar): Termin einem Kalender zuordnen und umhaengen
Beim Anlegen war die Kalenderauswahl schon da (der oberste ist die
Vorgabe), beim Bearbeiten war sie festgestellt -- mit der Begruendung,
ein Umhaengen sei Loeschen und Neuanlegen unter neuer Kennung. Das
stimmt so nicht: In CalDAV wechselt dabei nur die Sammlung, UID und
Inhalt bleiben. Genau das macht der neue Endpunkt

    POST /api/v1/calendars/{id}/events/{uid}/move  {"calendar_id": …}

Er aendert nur die Zugehoerigkeit der Zeile und erhoeht das ctag auf
**beiden** Seiten. Mit nur einem holte ein Client die andere Haelfte
erst beim naechsten Vollabgleich, und der Termin stuende
zwischenzeitlich doppelt oder gar nicht im Telefon.

Bewusst ein eigener Endpunkt und kein weiteres Feld im PATCH: Ein Umzug
laesst sich auch ohne jede Aenderung am Termin ausloesen, und dafuer den
ganzen Termin mitschicken zu muessen waere eine Einladung, nebenbei
etwas zu ueberschreiben. Im Formular laeuft deshalb erst das PATCH und
dann der Umzug -- andersherum stuende der Termin nach einem Fehler
verschoben, aber unveraendert da, die verwirrendere Haelfte.

Dazu die Zieh-Geste: einen Termin auf einen Kalender in der
Seitenleiste ziehen, am Handy nach kurzem Gedrueckthalten. Ueber
Pointer-Events statt ueber die Drag-and-Drop-Schnittstelle des
Browsers -- die gibt es auf Android nicht, und genau dort soll die
Geste ja auch funktionieren. Ist die Kalenderliste zugeklappt (am Handy
der Regelfall), erscheint waehrend des Ziehens eine Leiste unten in
Daumennaehe, sonst liefe die Geste ins Leere.

Zwei Faelle, die sonst aergern: Wer in einen ausgeblendeten Kalender
zieht, bekommt ihn eingeblendet -- der Termin waere sonst scheinbar
spurlos verschwunden. Und der Klick, der einem Ziehen hinterherkommt,
wird verschluckt, statt den Termindialog zu oeffnen.

Eine Reihe zieht vollstaendig um; sie ist eine einzige Datei, kein
Buendel von Einzelterminen. Das sagt das Formular auch an.

Selbsttest um elf Pruefungen erweitert (36 gruen): Umzug angenommen,
Kennung bleibt, aus der Quelle weg und im Ziel da, ctag auf beiden
Seiten gestiegen, Umzug auf sich selbst folgenlos, fremder Kalender
404, Reihe vollstaendig umgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:07:36 +02:00

296 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
// 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, 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
}
// 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
}
row := tx.QueryRow(ctx,
`UPDATE cal_objects SET calendar_id = $1, updated_at = now()
WHERE calendar_id = $2 AND uid = $3
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
toCal, fromCal, uid,
)
o, err := scanObject(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx,
`UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal,
); 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)
}