package main import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "github.com/jackc/pgx/v5" ) type CalendarEvent struct { ID string `json:"id"` Title string `json:"title"` Start string `json:"start"` End string `json:"end"` AllDay bool `json:"allDay"` Location string `json:"location"` Description string `json:"description"` Color string `json:"color"` Recurrence string `json:"recurrence"` // recurrenceWeekday verwendet time.Weekday/JavaScript-Date.getDay(): 0=Sonntag. RecurrenceWeekday *int `json:"recurrenceWeekday,omitempty"` RecurrenceOrdinal *int `json:"recurrenceOrdinal,omitempty"` RecurrenceExceptions []string `json:"recurrenceExceptions"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type CalendarEventRequest struct { Title string `json:"title"` Start string `json:"start"` End string `json:"end"` AllDay bool `json:"allDay"` Location string `json:"location"` Description string `json:"description"` Color string `json:"color"` Recurrence string `json:"recurrence"` RecurrenceWeekday *int `json:"recurrenceWeekday"` RecurrenceOrdinal *int `json:"recurrenceOrdinal"` RecurrenceExceptions []string `json:"recurrenceExceptions"` } type CalendarEventOccurrenceRequest struct { OccurrenceStart string `json:"occurrenceStart"` Event CalendarEventRequest `json:"event"` } const calendarEventDateTimeLayout = "2006-01-02T15:04" var allowedCalendarEventColors = map[string]bool{ "indigo": true, "blue": true, "emerald": true, "amber": true, "rose": true, "slate": true, } var allowedCalendarEventRecurrences = map[string]bool{ "none": true, "daily": true, "weekly": true, "monthly": true, "monthlyNthWeekday": true, "yearly": true, } func (s *Server) ensureCalendarEventsTable(ctx context.Context) error { queries := []string{ `CREATE EXTENSION IF NOT EXISTS pgcrypto`, ` CREATE TABLE IF NOT EXISTS calendar_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title TEXT NOT NULL, start_at TIMESTAMP NOT NULL, end_at TIMESTAMP NOT NULL, all_day BOOLEAN NOT NULL DEFAULT false, location TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT 'indigo' CHECK (color IN ('indigo', 'blue', 'emerald', 'amber', 'rose', 'slate')), recurrence TEXT NOT NULL DEFAULT 'none', recurrence_weekday INTEGER, recurrence_ordinal INTEGER, recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB, created_by UUID REFERENCES users(id) ON DELETE SET NULL, updated_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT calendar_events_valid_range CHECK (end_at > start_at) ) `, ` DO $$ DECLARE constraint_name TEXT; BEGIN FOR constraint_name IN SELECT c.conname FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'calendar_events' AND c.contype = 'c' AND pg_get_constraintdef(c.oid) ILIKE '%recurrence%' LOOP EXECUTE format('ALTER TABLE calendar_events DROP CONSTRAINT %I', constraint_name); END LOOP; END $$ `, `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_weekday INTEGER`, `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_ordinal INTEGER`, `ALTER TABLE IF EXISTS calendar_events ADD COLUMN IF NOT EXISTS recurrence_exceptions JSONB NOT NULL DEFAULT '[]'::JSONB`, `UPDATE calendar_events SET recurrence_exceptions = '[]'::JSONB WHERE recurrence_exceptions IS NULL`, `CREATE INDEX IF NOT EXISTS idx_calendar_events_start_at ON calendar_events(start_at)`, } for _, query := range queries { if _, err := s.db.Exec(ctx, query); err != nil { return err } } return nil } func parseCalendarEventDateTime(value string) (time.Time, error) { value = strings.TrimSpace(value) layouts := []string{ calendarEventDateTimeLayout, "2006-01-02T15:04:05", time.RFC3339, } for _, layout := range layouts { parsed, err := time.ParseInLocation(layout, value, time.Local) if err == nil { return parsed, nil } } return time.Time{}, errors.New("invalid datetime") } func cleanCalendarEventExceptions(values []string) ([]string, error) { result := []string{} seen := map[string]bool{} for _, value := range values { parsed, err := parseCalendarEventDateTime(value) if err != nil { return nil, errors.New("Eine Wiederholungs-Ausnahme ist ungültig") } normalized := formatCalendarEventDateTime(parsed) if seen[normalized] { continue } seen[normalized] = true result = append(result, normalized) } return result, nil } func appendCalendarEventException(values []string, occurrenceStart string) ([]string, error) { next := append([]string{}, values...) next = append(next, occurrenceStart) return cleanCalendarEventExceptions(next) } func normalizeCalendarEventRequest(input CalendarEventRequest) (CalendarEventRequest, time.Time, time.Time, error) { input.Title = strings.TrimSpace(input.Title) input.Location = strings.TrimSpace(input.Location) input.Description = strings.TrimSpace(input.Description) input.Color = strings.TrimSpace(input.Color) input.Recurrence = strings.TrimSpace(input.Recurrence) if input.Title == "" { return input, time.Time{}, time.Time{}, errors.New("Bitte gib einen Titel ein") } if len([]rune(input.Title)) > 120 { return input, time.Time{}, time.Time{}, errors.New("Der Titel darf maximal 120 Zeichen lang sein") } if len([]rune(input.Location)) > 160 { return input, time.Time{}, time.Time{}, errors.New("Der Ort darf maximal 160 Zeichen lang sein") } if len([]rune(input.Description)) > 1000 { return input, time.Time{}, time.Time{}, errors.New("Die Beschreibung darf maximal 1000 Zeichen lang sein") } if input.Color == "" { input.Color = "indigo" } if !allowedCalendarEventColors[input.Color] { return input, time.Time{}, time.Time{}, errors.New("Ungültige Terminfarbe") } if input.Recurrence == "" { input.Recurrence = "none" } if !allowedCalendarEventRecurrences[input.Recurrence] { return input, time.Time{}, time.Time{}, errors.New("Ungültige Wiederholung") } if input.Recurrence == "monthlyNthWeekday" { if input.RecurrenceWeekday == nil || *input.RecurrenceWeekday < 0 || *input.RecurrenceWeekday > 6 { return input, time.Time{}, time.Time{}, errors.New("Bitte wähle einen gültigen Wochentag für die Wiederholung") } if input.RecurrenceOrdinal == nil || *input.RecurrenceOrdinal < 1 || *input.RecurrenceOrdinal > 5 { return input, time.Time{}, time.Time{}, errors.New("Bitte wähle eine gültige Woche im Monat") } } else { input.RecurrenceWeekday = nil input.RecurrenceOrdinal = nil } exceptions, err := cleanCalendarEventExceptions(input.RecurrenceExceptions) if err != nil { return input, time.Time{}, time.Time{}, err } input.RecurrenceExceptions = exceptions start, err := parseCalendarEventDateTime(input.Start) if err != nil { return input, time.Time{}, time.Time{}, errors.New("Der Startzeitpunkt ist ungültig") } end, err := parseCalendarEventDateTime(input.End) if err != nil { return input, time.Time{}, time.Time{}, errors.New("Der Endzeitpunkt ist ungültig") } if !end.After(start) { return input, time.Time{}, time.Time{}, errors.New("Das Ende muss nach dem Beginn liegen") } return input, start, end, nil } func formatCalendarEventDateTime(value time.Time) string { return value.Format(calendarEventDateTimeLayout) } func scanCalendarEvent(row pgx.Row) (CalendarEvent, error) { var event CalendarEvent var start time.Time var end time.Time var rawExceptions []byte err := row.Scan( &event.ID, &event.Title, &start, &end, &event.AllDay, &event.Location, &event.Description, &event.Color, &event.Recurrence, &event.RecurrenceWeekday, &event.RecurrenceOrdinal, &rawExceptions, &event.CreatedAt, &event.UpdatedAt, ) if err != nil { return CalendarEvent{}, err } event.Start = formatCalendarEventDateTime(start) event.End = formatCalendarEventDateTime(end) event.RecurrenceExceptions = []string{} if len(rawExceptions) > 0 { _ = json.Unmarshal(rawExceptions, &event.RecurrenceExceptions) } return event, nil } const calendarEventReturningColumns = ` id::TEXT, title, start_at, end_at, all_day, COALESCE(location, ''), COALESCE(description, ''), color, recurrence, recurrence_weekday, recurrence_ordinal, recurrence_exceptions, created_at, updated_at ` type calendarEventRowQueryer interface { QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } func loadCalendarEventByID(ctx context.Context, queryer calendarEventRowQueryer, eventID string) (CalendarEvent, error) { return scanCalendarEvent(queryer.QueryRow( ctx, ` SELECT `+calendarEventReturningColumns+` FROM calendar_events WHERE id = $1 `, eventID, )) } func (s *Server) handleListCalendarEvents(w http.ResponseWriter, r *http.Request) { if err := s.ensureCalendarEventsTable(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") return } rows, err := s.db.Query( r.Context(), ` SELECT `+calendarEventReturningColumns+` FROM calendar_events ORDER BY start_at ASC, created_at ASC `, ) if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht geladen werden") return } defer rows.Close() events := []CalendarEvent{} for rows.Next() { event, err := scanCalendarEvent(rows) if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht gelesen werden") return } events = append(events, event) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vollständig gelesen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "events": events, }) } func (s *Server) handleCreateCalendarEvent(w http.ResponseWriter, r *http.Request) { if err := s.ensureCalendarEventsTable(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") return } var input CalendarEventRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } input, start, end, err := normalizeCalendarEventRequest(input) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } userID, _ := s.currentUserIDFromRequest(r) exceptionsRaw, err := json.Marshal(input.RecurrenceExceptions) if err != nil { writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") return } event, err := scanCalendarEvent(s.db.QueryRow( r.Context(), ` INSERT INTO calendar_events ( title, start_at, end_at, all_day, location, description, color, recurrence, recurrence_weekday, recurrence_ordinal, recurrence_exceptions, created_by, updated_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::JSONB, NULLIF($12, '')::UUID, NULLIF($12, '')::UUID) RETURNING `+calendarEventReturningColumns, input.Title, start, end, input.AllDay, input.Location, input.Description, input.Color, input.Recurrence, input.RecurrenceWeekday, input.RecurrenceOrdinal, string(exceptionsRaw), userID, )) if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht erstellt werden") return } writeJSON(w, http.StatusCreated, map[string]any{ "event": event, }) } func (s *Server) handleUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) { eventID := strings.TrimSpace(r.PathValue("id")) if eventID == "" { writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") return } if err := s.ensureCalendarEventsTable(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") return } var input CalendarEventRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } input, start, end, err := normalizeCalendarEventRequest(input) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } userID, _ := s.currentUserIDFromRequest(r) exceptionsRaw, err := json.Marshal(input.RecurrenceExceptions) if err != nil { writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") return } event, err := scanCalendarEvent(s.db.QueryRow( r.Context(), ` UPDATE calendar_events SET title = $2, start_at = $3, end_at = $4, all_day = $5, location = $6, description = $7, color = $8, recurrence = $9, recurrence_weekday = $10, recurrence_ordinal = $11, recurrence_exceptions = $12::JSONB, updated_by = NULLIF($13, '')::UUID, updated_at = now() WHERE id = $1 RETURNING `+calendarEventReturningColumns, eventID, input.Title, start, end, input.AllDay, input.Location, input.Description, input.Color, input.Recurrence, input.RecurrenceWeekday, input.RecurrenceOrdinal, string(exceptionsRaw), userID, )) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gespeichert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "event": event, }) } func (s *Server) handleUpdateCalendarEventOccurrence(w http.ResponseWriter, r *http.Request) { eventID := strings.TrimSpace(r.PathValue("id")) if eventID == "" { writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") return } if err := s.ensureCalendarEventsTable(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") return } var payload CalendarEventOccurrenceRequest if err := readJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } occurrenceStart, err := parseCalendarEventDateTime(payload.OccurrenceStart) if err != nil { writeError(w, http.StatusBadRequest, "Der gewählte Serientermin ist ungültig") return } occurrenceStartValue := formatCalendarEventDateTime(occurrenceStart) payload.Event.Recurrence = "none" payload.Event.RecurrenceWeekday = nil payload.Event.RecurrenceOrdinal = nil payload.Event.RecurrenceExceptions = nil input, start, end, err := normalizeCalendarEventRequest(payload.Event) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht vorbereitet werden") return } defer tx.Rollback(r.Context()) series, err := loadCalendarEventByID(r.Context(), tx, eventID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht geladen werden") return } if series.Recurrence == "none" { writeError(w, http.StatusBadRequest, "Der Kalendereintrag ist keine Serie") return } nextExceptions, err := appendCalendarEventException(series.RecurrenceExceptions, occurrenceStartValue) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } exceptionsRaw, err := json.Marshal(nextExceptions) if err != nil { writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") return } userID, _ := s.currentUserIDFromRequest(r) updatedSeries, err := scanCalendarEvent(tx.QueryRow( r.Context(), ` UPDATE calendar_events SET recurrence_exceptions = $2::JSONB, updated_by = NULLIF($3, '')::UUID, updated_at = now() WHERE id = $1 RETURNING `+calendarEventReturningColumns, eventID, string(exceptionsRaw), userID, )) if err != nil { writeError(w, http.StatusInternalServerError, "Serientermin konnte nicht aktualisiert werden") return } event, err := scanCalendarEvent(tx.QueryRow( r.Context(), ` INSERT INTO calendar_events ( title, start_at, end_at, all_day, location, description, color, recurrence, recurrence_weekday, recurrence_ordinal, recurrence_exceptions, created_by, updated_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'none', NULL, NULL, '[]'::JSONB, NULLIF($8, '')::UUID, NULLIF($8, '')::UUID) RETURNING `+calendarEventReturningColumns, input.Title, start, end, input.AllDay, input.Location, input.Description, input.Color, userID, )) if err != nil { writeError(w, http.StatusInternalServerError, "Ausgewählter Termin konnte nicht gespeichert werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gespeichert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "series": updatedSeries, "event": event, }) } func (s *Server) handleDeleteCalendarEventOccurrence(w http.ResponseWriter, r *http.Request) { eventID := strings.TrimSpace(r.PathValue("id")) if eventID == "" { writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") return } if err := s.ensureCalendarEventsTable(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") return } var payload struct { OccurrenceStart string `json:"occurrenceStart"` } if err := readJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } occurrenceStart, err := parseCalendarEventDateTime(payload.OccurrenceStart) if err != nil { writeError(w, http.StatusBadRequest, "Der gewählte Serientermin ist ungültig") return } occurrenceStartValue := formatCalendarEventDateTime(occurrenceStart) tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht vorbereitet werden") return } defer tx.Rollback(r.Context()) series, err := loadCalendarEventByID(r.Context(), tx, eventID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht geladen werden") return } if series.Recurrence == "none" { writeError(w, http.StatusBadRequest, "Der Kalendereintrag ist keine Serie") return } nextExceptions, err := appendCalendarEventException(series.RecurrenceExceptions, occurrenceStartValue) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } exceptionsRaw, err := json.Marshal(nextExceptions) if err != nil { writeError(w, http.StatusBadRequest, "Wiederholungs-Ausnahmen sind ungültig") return } userID, _ := s.currentUserIDFromRequest(r) updatedSeries, err := scanCalendarEvent(tx.QueryRow( r.Context(), ` UPDATE calendar_events SET recurrence_exceptions = $2::JSONB, updated_by = NULLIF($3, '')::UUID, updated_at = now() WHERE id = $1 RETURNING `+calendarEventReturningColumns, eventID, string(exceptionsRaw), userID, )) if err != nil { writeError(w, http.StatusInternalServerError, "Serientermin konnte nicht aktualisiert werden") return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gelöscht werden") return } writeJSON(w, http.StatusOK, map[string]any{ "event": updatedSeries, }) } func (s *Server) handleDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) { eventID := strings.TrimSpace(r.PathValue("id")) if eventID == "" { writeError(w, http.StatusBadRequest, "Kalendereintrag fehlt") return } if err := s.ensureCalendarEventsTable(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Kalendereinträge konnten nicht vorbereitet werden") return } result, err := s.db.Exec( r.Context(), `DELETE FROM calendar_events WHERE id = $1`, eventID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Kalendereintrag konnte nicht gelöscht werden") return } if result.RowsAffected() == 0 { writeError(w, http.StatusNotFound, "Kalendereintrag wurde nicht gefunden") return } writeJSON(w, http.StatusOK, map[string]string{ "message": "Kalendereintrag gelöscht", }) }