Files
Aether/backend/internal/calendar/events_api.go
T
marcandClaude Sonnet 5 27a026421f feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht
Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.

Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.

Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 23:14:28 +02:00

271 lines
8.4 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 (
"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 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 = startProp.ValueType() == ical.ValueDate
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 {
dto.End = &t
}
}
return dto, nil
}
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 {
ev.Props.SetDate(ical.PropDateTimeStart, body.Start)
if body.End != nil {
ev.Props.SetDate(ical.PropDateTimeEnd, *body.End)
}
} 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)
}