feat(calendar): Kalender anlegen, umbenennen, loeschen -- vom Client aus
Bisher konnte ein CalDAV-Client Termine verwalten, aber keine Kalender:
MKCALENDAR -> 405 anlegen
PROPPATCH -> 501 umbenennen, umfaerben
DELETE -> 404 loeschen
go-webdav deckt davon nur MKCOL ab, und selbst da verwarf unser Backend
die vom Client gewaehlte Adresse. Alle drei Verben faengt der Handler
jetzt vorher ab (collections.go).
Der Kern steckt aber nicht in den Verben, sondern in der Adresse: Bei
MKCALENDAR sucht *der Client* aus, wo der Kalender liegen soll
("…/calendars/max/arbeit/"). Unsere Kalender lagen bisher zwingend unter
ihrer UUID -- der Client haette angelegt und auf seine eigene Adresse
sofort ein 404 bekommen.
Deshalb Migration 23 mit einer Spalte path_segment: gesetzt = die
gewaehlte Adresse, NULL = es gilt weiterhin die UUID. Bestehende
Kalender bleiben damit unter genau der Adresse erreichbar, unter der die
eingerichteten Geraete sie kennen -- ein Wechsel haette jedes Telefon
dazu gebracht, seine Termine fuer geloescht zu halten. Aufgeloest wird
beides in einer Abfrage (GetCalendarByRef), die gewaehlte Adresse hat
Vorrang.
PROPPATCH antwortet mit einem Status je Eigenschaft, auch wenn alles
geklappt hat: Es kann teilweise gelingen, und ein nacktes 200
verschwiege, welcher Teil angekommen ist. Was wir nicht aendern koennen,
bekommt 403 statt stillem Schlucken -- ein Client, der seine Aenderung
fuer angekommen haelt, ist schlimmer dran als einer mit klarer Absage.
Ebenso eine unlesbare Farbe: absagen statt raten.
DELETE auf eine Sammlung loescht den Kalender mitsamt allen Terminen --
das ist in WebDAV seine Bedeutung.
CalDAV-Selbsttest von 45 auf 62 Pruefungen: anlegen, unter der
gewaehlten Adresse erreichbar, Farbe aus dem MKCALENDAR uebernommen,
Termin darin ablegen und abrufen, zweites Anlegen auf dieselbe Adresse
405, umbenennen und umfaerben mit Status je Eigenschaft, loeschen, danach
404 -- und dasselbe noch einmal ueber MKCOL, das Verb, das DAVx5 nimmt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
820ffdde6a
commit
f71ab710e1
13 files changed
+551
-53
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -28,7 +29,7 @@ func NewStore(db *pgxpool.Pool) *Store {
|
||||
|
||||
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
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), ctag, created_at
|
||||
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
|
||||
ownerID,
|
||||
)
|
||||
@@ -40,7 +41,7 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
|
||||
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 {
|
||||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
@@ -51,10 +52,10 @@ func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calenda
|
||||
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
|
||||
`SELECT id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), 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)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -64,6 +65,60 @@ func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calend
|
||||
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, 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.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, 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.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
|
||||
@@ -87,9 +142,9 @@ func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, col
|
||||
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`,
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), 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)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert calendar: %w", err)
|
||||
}
|
||||
@@ -109,9 +164,9 @@ func (s *Store) UpdateCalendar(ctx context.Context, ownerID, id uuid.UUID, name,
|
||||
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`,
|
||||
RETURNING id, owner_id, name, color, description, supports_todo, timezone, COALESCE(path_segment, ''), 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)
|
||||
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.TimeZone, &c.PathSegment, &c.CTag, &c.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user