package calendar import ( "encoding/json" "net/http" "strings" "time" ical "github.com/emersion/go-ical" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/teambition/rrule-go" "aether/internal/auth" "aether/pkg/response" ) // maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem // Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre // sonst allein genug, um eine Monatsansicht lahmzulegen. const maxOccurrences = 750 // RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche // anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer // Anzahl oder an einem Datum. // // Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im // August"). Solche Regeln werden gelesen und unverändert behalten, aber // nicht in dieses Formular gezwängt – siehe recurrenceFromRule. type RecurrenceDTO struct { Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY Interval int `json:"interval"` // >= 1 Count int `json:"count,omitempty"` Until *time.Time `json:"until,omitempty"` // WEEKLY: an welchen Wochentagen (MO…SU). Leer = Wochentag des Starts. Byday []string `json:"byday,omitempty"` // MONTHLY/YEARLY: "DAY" wiederholt am festen Kalendertag (am 4.), // "WEEKDAY" am n-ten Wochentag (am 1. Freitag). Leer zählt als DAY. Mode string `json:"mode,omitempty"` // Nur bei Mode=WEEKDAY und nur zum **Anzeigen**: der wievielte (−1 = // letzter) und welcher Wochentag. Beim Schreiben rechnet der Server // beides aus dem Startdatum aus – zwei Quellen für dieselbe Angabe wären // eine zu viel, und sie liefen irgendwann auseinander. Nth int `json:"nth,omitempty"` Weekday string `json:"weekday,omitempty"` } var weekdayCodes = []string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"} // weekdayIndex: 0 = Montag. Gos time.Weekday fängt beim Sonntag an, rrule-go // beim Montag – diese eine Zeile ist der Übersetzer dazwischen. func weekdayIndex(t time.Time) int { return (int(t.Weekday()) + 6) % 7 } func weekdayCode(t time.Time) string { return weekdayCodes[weekdayIndex(t)] } func rruleWeekday(code string) (rrule.Weekday, bool) { switch strings.ToUpper(strings.TrimSpace(code)) { case "MO": return rrule.MO, true case "TU": return rrule.TU, true case "WE": return rrule.WE, true case "TH": return rrule.TH, true case "FR": return rrule.FR, true case "SA": return rrule.SA, true case "SU": return rrule.SU, true } return rrule.MO, false } // nthOfMonth: der wievielte Wochentag seiner Art der Tag im Monat ist. // // Der fünfte wird zu −1 ("letzter"), und das ist kein Schönheitsfehler: Einen // fünften Freitag hat nicht jeder Monat. FREQ=MONTHLY;BYDAY=5FR ließe die // Reihe in etwa der Hälfte aller Monate stillschweigend ausfallen – ein // Fehler, den man erst Wochen später bemerkt. func nthOfMonth(t time.Time) int { if n := (t.Day()-1)/7 + 1; n < 5 { return n } return -1 } // EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche. // Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder // ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt // im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur, // was das Formular auch wirklich kennt. type EventDTO struct { UID string `json:"uid"` Summary string `json:"summary"` Description string `json:"description"` Location string `json:"location"` // Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten // belegten** Tages – in beide Richtungen gleich. Die exklusive // DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig // hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag. Start time.Time `json:"start"` End *time.Time `json:"end,omitempty"` AllDay bool `json:"all_day"` // Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur // für diesen einen Termin. Color string `json:"color,omitempty"` // Recurring: es hängt überhaupt eine Wiederholung dran. // Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt. // Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum // Bearbeiten" – dann ist Recurring true und Recurrence nil. Recurring bool `json:"recurring"` Recurrence *RecurrenceDTO `json:"recurrence,omitempty"` // Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen* // Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular // bearbeitet immer die Reihe und braucht daher deren Anfang. SeriesStart *time.Time `json:"series_start,omitempty"` SeriesEnd *time.Time `json:"series_end,omitempty"` ETag string `json:"etag"` } func firstEventComponent(cal *ical.Calendar) *ical.Component { for _, child := range cal.Children { if child.Name == "VEVENT" || child.Name == "VTODO" { return child } } return nil } // isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt // VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig – // eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000"). func isDateOnly(prop *ical.Prop) bool { return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8 } var freqToString = map[rrule.Frequency]string{ rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY", rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY", } var stringToFreq = map[string]rrule.Frequency{ "DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY, "MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY, } // recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder // gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig // ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb // verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das // Speichern die verlorenen Teile still weg. func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO { if opt == nil { return nil } freq, ok := freqToString[opt.Freq] if !ok { return nil // stündlich und feiner bietet die Oberfläche nicht an } // Diese Bestandteile kennt das Formular in keiner Betriebsart. if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 { return nil } dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count} switch freq { case "DAILY": if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 { return nil } case "WEEKLY": if len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 { return nil } for _, wd := range opt.Byweekday { if wd.N() != 0 { return nil // "jeder 2. Dienstag" ist keine Wochenregel } dto.Byday = append(dto.Byday, weekdayCodes[wd.Day()]) } if len(dto.Byday) == 0 { dto.Byday = []string{weekdayCode(start)} } case "MONTHLY", "YEARLY": // Bei jährlich darf der Monat genannt sein – aber nur der des Starts. if freq == "YEARLY" { if len(opt.Bymonth) > 1 || (len(opt.Bymonth) == 1 && opt.Bymonth[0] != int(start.Month())) { return nil } } else if len(opt.Bymonth) > 0 { return nil } switch { case len(opt.Byweekday) == 1 && opt.Byweekday[0].N() != 0: // „am n-ten Wochentag". Nur bearbeitbar, wenn die Regel auch // wirklich auf den Starttag zeigt – sonst zeigte das Formular // etwas anderes an, als gespeichert ist. wd := opt.Byweekday[0] if len(opt.Bymonthday) > 0 || wd.Day() != weekdayIndex(start) || wd.N() != nthOfMonth(start) { return nil } dto.Mode, dto.Nth, dto.Weekday = "WEEKDAY", wd.N(), weekdayCode(start) case len(opt.Byweekday) == 0: // „am festen Kalendertag" – ausgeschrieben oder implizit. if len(opt.Bymonthday) > 1 || (len(opt.Bymonthday) == 1 && opt.Bymonthday[0] != start.Day()) { return nil } dto.Mode = "DAY" default: return nil } } if dto.Interval < 1 { dto.Interval = 1 } if !opt.Until.IsZero() { u := opt.Until.UTC() dto.Until = &u } return dto } func eventFromObject(o *Object) (*EventDTO, error) { dec := ical.NewDecoder(strings.NewReader(o.Data)) cal, err := dec.Decode() if err != nil { return nil, err } ev := firstEventComponent(cal) if ev == nil { return nil, errNoEventComponent } summary, _ := ev.Props.Text(ical.PropSummary) description, _ := ev.Props.Text(ical.PropDescription) location, _ := ev.Props.Text(ical.PropLocation) // Farbe: unser genauer Wert schlägt den benannten Ton, siehe color.go. // Ein Termin, den ein anderer Client eingefärbt hat, bringt nur COLOR // mit – der wird dann übersetzt. color, _ := ev.Props.Text(propColorX) if color == "" { if name, _ := ev.Props.Text(propColor); name != "" { if strings.HasPrefix(strings.TrimSpace(name), "#") { color = normalizeHex(name) } else { color = css3ToHex(name) } } } else { color = normalizeHex(color) } dto := &EventDTO{ UID: o.UID, Summary: summary, Description: description, Location: location, Color: color, ETag: o.ETag, } startProp := ev.Props.Get(ical.PropDateTimeStart) if startProp != nil { dto.AllDay = isDateOnly(startProp) if t, err := startProp.DateTime(time.UTC); err == nil { dto.Start = t } } if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil { if t, err := endProp.DateTime(time.UTC); err == nil { // Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein // eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt // diese Schnittstelle den letzten *belegten* Tag zurück – sonst // müsste jede Oberfläche dieselbe Rechnung noch einmal machen // und eine davon würde sie vergessen. if dto.AllDay { t = t.AddDate(0, 0, -1) if t.Before(dto.Start) { t = dto.Start } } dto.End = &t } } if dto.AllDay && dto.End == nil { // Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag. end := dto.Start dto.End = &end } if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil { dto.Recurring = true dto.Recurrence = recurrenceFromRule(opt, dto.Start) } return dto, nil } // expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne // Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau // **einen** Eintrag – nämlich den ersten. func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO { if !base.Recurring { // Einzeltermin: nur, wenn er das Fenster überhaupt berührt. end := base.Start if base.End != nil { end = *base.End } if end.Before(from) || base.Start.After(to) { return nil } return []EventDTO{*base} } dec := ical.NewDecoder(strings.NewReader(o.Data)) cal, err := dec.Decode() if err != nil { return []EventDTO{*base} } ev := firstEventComponent(cal) if ev == nil { return []EventDTO{*base} } set, err := ev.RecurrenceSet(time.UTC) if err != nil || set == nil { return []EventDTO{*base} } // Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert. var dur time.Duration if base.End != nil { dur = base.End.Sub(base.Start) } seriesStart, seriesEnd := base.Start, base.End out := make([]EventDTO, 0, 16) for i, occ := range set.Between(from, to, true) { if i >= maxOccurrences { break } item := *base item.Start = occ.UTC() if seriesEnd != nil { end := item.Start.Add(dur) item.End = &end } item.SeriesStart = &seriesStart item.SeriesEnd = seriesEnd out = append(out, item) } return out } var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"} type icalError struct{ msg string } func (e *icalError) Error() string { return e.msg } // parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges // Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem // etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird. func parseWindow(r *http.Request) (from, to time.Time) { now := time.Now().UTC() from = now.AddDate(0, -1, 0) to = now.AddDate(1, 0, 0) if v := r.URL.Query().Get("from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { from = t.UTC() } } if v := r.URL.Query().Get("to"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { to = t.UTC() } } if to.Before(from) { to = from } return from, to } // GET /api/v1/calendars/{id}/events?from=…&to=… func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) calID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusNotFound, "calendar not found") return } if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { response.Error(w, http.StatusNotFound, "calendar not found") return } objs, err := h.store.ListObjects(r.Context(), calID) if err != nil { response.Error(w, http.StatusInternalServerError, "list events failed") return } from, to := parseWindow(r) out := make([]EventDTO, 0, len(objs)) for i := range objs { base, err := eventFromObject(&objs[i]) if err != nil { continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen } out = append(out, expand(&objs[i], base, from, to)...) } response.JSON(w, http.StatusOK, out) } type eventBody struct { Summary string `json:"summary"` Description string `json:"description"` Location string `json:"location"` Start time.Time `json:"start"` End *time.Time `json:"end"` AllDay bool `json:"all_day"` Color string `json:"color"` Recurrence *RecurrenceDTO `json:"recurrence"` } func buildEventICal(uid string, body eventBody) *ical.Calendar { root := ical.NewCalendar() root.Props.SetText(ical.PropVersion, "2.0") root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE") ev := ical.NewEvent() ev.Props.SetText(ical.PropUID, uid) ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) if body.Summary != "" { ev.Props.SetText(ical.PropSummary, body.Summary) } if body.Description != "" { ev.Props.SetText(ical.PropDescription, body.Description) } if body.Location != "" { ev.Props.SetText(ical.PropLocation, body.Location) } if hex := normalizeHex(body.Color); hex != "" { // Beides schreiben: den benannten Ton für fremde Clients (DAVx5 // überträgt daraus die Terminfarbe ins Telefon), den genauen Wert // für die eigene Palette. Siehe color.go. if name := nearestCSS3(hex); name != "" { ev.Props.SetText(propColor, name) } // Ohne SetText geschrieben: Das hängte an die unbekannte // X-Eigenschaft ein überflüssiges VALUE=TEXT an. p := ical.NewProp(propColorX) p.Value = hex ev.Props.Set(p) } if body.AllDay { // Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb // hier ausdrücklich .UTC() – der Aufrufer schickt für einen // Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine // Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der // 3. wurde zum 2.). start := body.Start.UTC() ev.Props.SetDate(ical.PropDateTimeStart, start) // DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten // belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen // (siehe eventFromObject), also hier +1. Ohne das verletzt ein // eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und // Clients zeigen ihn unterschiedlich an. last := start if body.End != nil { last = body.End.UTC() if last.Before(start) { last = start } } ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1)) } else { ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC()) if body.End != nil { ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC()) } } if rule := ruleFromRecurrence(body); rule != nil { ev.Props.SetRecurrenceRule(rule) } root.Children = append(root.Children, ev.Component) return root } func ruleFromRecurrence(body eventBody) *rrule.ROption { rec := body.Recurrence if rec == nil { return nil } freq, ok := stringToFreq[strings.ToUpper(rec.Freq)] if !ok { return nil } interval := rec.Interval if interval < 1 { interval = 1 } opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count} // Der Start ist die Bezugsgröße für alle BY-Angaben: „am 4." und „am 1. // Freitag" leiten sich beide aus ihm ab, statt getrennt mitgeschickt zu // werden. So kann die Regel gar nicht erst auf einen anderen Tag zeigen // als der Termin selbst. start := body.Start.UTC() switch strings.ToUpper(rec.Freq) { case "WEEKLY": codes := rec.Byday if len(codes) == 0 { codes = []string{weekdayCode(start)} } seen := map[string]bool{} for _, c := range codes { wd, ok := rruleWeekday(c) if !ok || seen[strings.ToUpper(c)] { continue } seen[strings.ToUpper(c)] = true opt.Byweekday = append(opt.Byweekday, wd) } case "MONTHLY", "YEARLY": if strings.EqualFold(rec.Mode, "WEEKDAY") { wd, _ := rruleWeekday(weekdayCode(start)) opt.Byweekday = []rrule.Weekday{wd.Nth(nthOfMonth(start))} } else { opt.Bymonthday = []int{start.Day()} } if strings.EqualFold(rec.Freq, "YEARLY") { opt.Bymonth = []int{int(start.Month())} } } if rec.Until != nil { // Bis zum **Ende** des genannten Tages, damit ein Termin an diesem // Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte // Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen // später bemerkt. u := rec.Until.UTC() opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC) } return opt } func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) { user := auth.UserFromContext(r.Context()) calID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { return uuid.UUID{}, false } if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { return uuid.UUID{}, false } return calID, true } // POST /api/v1/calendars/{id}/events func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { calID, ok := h.calendarOf(r) if !ok { response.Error(w, http.StatusNotFound, "calendar not found") return } var body eventBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() { response.Error(w, http.StatusBadRequest, "summary and start are required") return } uid := uuid.New().String() dto, err := h.writeEvent(r, calID, uid, body) if err != nil { response.Error(w, http.StatusInternalServerError, "create event failed") return } response.JSON(w, http.StatusCreated, dto) } // PATCH /api/v1/calendars/{id}/events/{uid} // // Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne // Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine // eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne // die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen. func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { calID, ok := h.calendarOf(r) if !ok { response.Error(w, http.StatusNotFound, "calendar not found") return } uid := chi.URLParam(r, "uid") existing, err := h.store.GetObjectByUID(r.Context(), calID, uid) if err == ErrNotFound { response.Error(w, http.StatusNotFound, "event not found") return } if err != nil { response.Error(w, http.StatusInternalServerError, "load event failed") return } if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil { // Wiederholung, die das Formular nicht vollständig abbildet (etwa // „jeden zweiten Dienstag"). Speichern würde die Regel auf das // reduzieren, was das Formular kennt – also lieber ablehnen. response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client") return } var body eventBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() { response.Error(w, http.StatusBadRequest, "summary and start are required") return } dto, err := h.writeEvent(r, calID, uid, body) if err != nil { response.Error(w, http.StatusInternalServerError, "update 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 // er als EXDATE vermerkt wird – die Art, wie das iCalendar-Format eine // Ausnahme ausdrückt. Die Reihe selbst bleibt mit ihrer Regel bestehen, und // jeder CalDAV-Client versteht das ohne Zutun. // // Bewusst **nicht** so gelöst, dass die Reihe in zwei Reihen zerlegt wird: // Das änderte die UID, und alle Geräte hielten den Rest für einen neuen // Termin. func (h *APIHandler) excludeOccurrence(r *http.Request, calID uuid.UUID, uid string, at time.Time) error { obj, err := h.store.GetObjectByUID(r.Context(), calID, uid) if err != nil { return err } cal, err := ical.NewDecoder(strings.NewReader(obj.Data)).Decode() if err != nil { return err } ev := firstEventComponent(cal) if ev == nil { return errNoEventComponent } if rule, err := ev.Props.RecurrenceRule(); err != nil || rule == nil { return errNotRecurring } startProp := ev.Props.Get(ical.PropDateTimeStart) allDay := startProp != nil && isDateOnly(startProp) // Schon ausgetragen? Dann nichts tun – ein zweites EXDATE auf denselben // Zeitpunkt wäre harmlos, aber die Datei liefe mit der Zeit voll. for _, ex := range ev.Props[ical.PropExceptionDates] { if t, err := ex.DateTime(time.UTC); err == nil && t.Equal(at) { return nil } } ex := ical.NewProp(ical.PropExceptionDates) if allDay { ex.SetDate(at) } else { ex.SetDateTime(at) } ev.Props.Add(ex) data, err := encodeICal(cal) if err != nil { return err } start, end := timeRange(cal) _, err = h.store.PutObject(r.Context(), calID, uid, obj.Component, data, etagFor(data), start, end) return err } func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) { cal := buildEventICal(uid, body) data, err := encodeICal(cal) if err != nil { return nil, err } start, end := timeRange(cal) obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end) if err != nil { return nil, err } return eventFromObject(obj) } // DELETE /api/v1/calendars/{id}/events/{uid}[?occurrence=] // // Ohne occurrence verschwindet der Termin ganz – bei einer Reihe also die // **ganze** Reihe. Mit occurrence fällt nur dieser eine Termin aus; die Reihe // bleibt bestehen und behält ihre Regel. func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) { calID, ok := h.calendarOf(r) if !ok { response.Error(w, http.StatusNotFound, "calendar not found") return } uid := chi.URLParam(r, "uid") if occ := r.URL.Query().Get("occurrence"); occ != "" { at, err := time.Parse(time.RFC3339, occ) if err != nil { response.Error(w, http.StatusBadRequest, "invalid occurrence") return } switch err := h.excludeOccurrence(r, calID, uid, at.UTC()); err { case nil: response.NoContent(w) case ErrNotFound: response.Error(w, http.StatusNotFound, "event not found") case errNotRecurring: // Kein Serientermin: Dann ist „nur dieser" dasselbe wie „ganz // weg" – also einfach löschen, statt den Aufrufer mit einer // Unterscheidung zu behelligen, die es hier nicht gibt. if err := h.store.DeleteObject(r.Context(), calID, uid); err != nil && err != ErrNotFound { response.Error(w, http.StatusInternalServerError, "delete event failed") return } response.NoContent(w) default: response.Error(w, http.StatusInternalServerError, "delete occurrence failed") } return } if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound { response.Error(w, http.StatusNotFound, "event not found") return } else if err != nil { response.Error(w, http.StatusInternalServerError, "delete event failed") return } response.NoContent(w) }