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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 13:07:36 +02:00
1 parent 688bb13bde
commit 77bf16b27f
9 files changed
+470 -26

No files matched your search

+1
View File
@@ -538,6 +538,7 @@ func main() {
r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent)
r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent)
r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent)
r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent)
r.Get("/app-passwords", authHandler.ListAppPasswords)
r.Post("/app-passwords", authHandler.CreateAppPassword)
+58
View File
@@ -629,6 +629,64 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, dto)
}
// POST /api/v1/calendars/{id}/events/{uid}/move {"calendar_id": "…"}
//
// Umhängen in einen anderen Kalender. Bewusst ein eigener Endpunkt und nicht
// ein weiteres Feld im PATCH: Ein Umzug lässt sich auch **ohne** jede
// Änderung am Termin auslösen (Ziehen mit der Maus, langes Drücken am Handy),
// und dafür den ganzen Termin mitschicken zu müssen wäre eine Einladung,
// nebenbei etwas zu überschreiben.
//
// Bei einer Reihe zieht die ganze Reihe um – sie ist eine einzige Datei, kein
// Bündel von Einzelterminen.
func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
fromCal, ok := h.calendarOf(r)
if !ok {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
var body struct {
CalendarID string `json:"calendar_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "calendar_id is required")
return
}
toCal, err := uuid.Parse(strings.TrimSpace(body.CalendarID))
if err != nil {
response.Error(w, http.StatusBadRequest, "calendar_id is required")
return
}
// Der Zielkalender muss demselben Konto gehören. Ohne diese Prüfung
// schöbe man Termine in fremde Kalender – und ein fremder Kalender darf
// sich nicht einmal als vorhanden verraten, deshalb 404 statt 403.
if _, err := h.store.GetCalendar(r.Context(), user.ID, toCal); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
obj, err := h.store.MoveObject(r.Context(), fromCal, toCal, chi.URLParam(r, "uid"))
switch {
case err == ErrNotFound:
response.Error(w, http.StatusNotFound, "event not found")
return
case err == ErrConflict:
response.Error(w, http.StatusConflict, "an event with this uid already exists in the target calendar")
return
case err != nil:
response.Error(w, http.StatusInternalServerError, "move event failed")
return
}
dto, err := eventFromObject(obj)
if err != nil {
response.Error(w, http.StatusInternalServerError, "move event failed")
return
}
response.JSON(w, http.StatusOK, dto)
}
var errNotRecurring = &icalError{"calendar: event has no recurrence"}
// excludeOccurrence streicht einen einzelnen Termin aus einer Reihe, indem
+62
View File
@@ -13,6 +13,11 @@ import (
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
}
@@ -212,6 +217,63 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon
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 {