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" "aether/internal/auth" "aether/pkg/response" ) // 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 – reicht für Anlegen/Ändern eines einfachen Termins im Browser. Ein // über einen echten CalDAV-Client angelegter Termin mit Wiederholung bleibt // über /caldav/ voll erhalten, zeigt sich hier nur mit Recurring=true und // lässt sich in der Web-Oberfläche bewusst nicht bearbeiten (siehe Update) – // eine RRULE über dieses vereinfachte Formular zu verändern, würde sie eher // beschädigen als korrekt anpassen. 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"` Recurring bool `json:"recurring"` ETag string `json:"etag"` } func eventFromObject(o *Object) (*EventDTO, error) { dec := ical.NewDecoder(strings.NewReader(o.Data)) cal, err := dec.Decode() if err != nil { return nil, err } var ev *ical.Component for _, child := range cal.Children { if child.Name == "VEVENT" || child.Name == "VTODO" { ev = child break } } if ev == nil { return nil, errNoEventComponent } summary, _ := ev.Props.Text(ical.PropSummary) description, _ := ev.Props.Text(ical.PropDescription) location, _ := ev.Props.Text(ical.PropLocation) _, recurring := ev.Props["RRULE"] dto := &EventDTO{ UID: o.UID, Summary: summary, Description: description, Location: location, Recurring: recurring, 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 } return dto, 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 errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"} type icalError struct{ msg string } func (e *icalError) Error() string { return e.msg } // GET /api/v1/calendars/{id}/events 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 } out := make([]EventDTO, 0, len(objs)) for i := range objs { dto, err := eventFromObject(&objs[i]) if err != nil { continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen } out = append(out, *dto) } 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"` } 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 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()) } } root.Children = append(root.Children, ev.Component) return root } // POST /api/v1/calendars/{id}/events func (h *APIHandler) CreateEvent(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 } 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() cal := buildEventICal(uid, body) data, err := encodeICal(cal) if err != nil { response.Error(w, http.StatusInternalServerError, "encode event failed") return } start, end := timeRange(cal) obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end) if err != nil { response.Error(w, http.StatusInternalServerError, "create event failed") return } dto, err := eventFromObject(obj) if err != nil { response.Error(w, http.StatusInternalServerError, "read back event failed") return } response.JSON(w, http.StatusCreated, dto) } // PATCH /api/v1/calendars/{id}/events/{uid} func (h *APIHandler) UpdateEvent(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 } 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 } existingDTO, err := eventFromObject(existing) if err == nil && existingDTO.Recurring { // Diese Ansicht kennt keine RRULE-Bearbeitung – ein Formular mit nur // "Titel/Start/Ende" würde die Wiederholungsregel beim Speichern // stillschweigend verwerfen. Lieber ablehnen als Daten beschädigen. response.Error(w, http.StatusConflict, "recurring events 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 } cal := buildEventICal(uid, body) data, err := encodeICal(cal) if err != nil { response.Error(w, http.StatusInternalServerError, "encode event failed") return } start, end := timeRange(cal) obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end) if err != nil { response.Error(w, http.StatusInternalServerError, "update event failed") return } dto, err := eventFromObject(obj) if err != nil { response.Error(w, http.StatusInternalServerError, "read back event failed") return } response.JSON(w, http.StatusOK, dto) } // DELETE /api/v1/calendars/{id}/events/{uid} func (h *APIHandler) DeleteEvent(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 } uid := chi.URLParam(r, "uid") 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) }