Files
Aether/backend/internal/calendar/store.go
T
marcandClaude Sonnet 5 87788817f8 feat(calendar): Phase 1 – CalDAV lesend und schreibend, ein Kalender pro Nutzer
Neues, eigenständiges Modul internal/calendar: implementiert
caldav.Backend (emersion/go-webdav) gegen zwei neue Tabellen
(cal_calendars, cal_objects). Endpunkt /caldav/ mit eigener Basic Auth
(nur App-Passwörter, wie /dav), plus /.well-known/caldav für die
automatische Einrichtung nach RFC 6764.

Gegen echte Container getestet (nicht nur go build): Discovery-Kette
Principal -> Home-Set -> automatisch angelegter Standardkalender,
PUT/GET/DELETE eines Termins, REPORT mit Zeitraumfilter (Treffer und
Nicht-Treffer), If-Match/If-None-Match-Vorbedingungen (412), und dass
ein fremder Nutzer auf einen Kalender-Pfad mit 404 statt 403 abgewiesen
wird.

Zwei Fallen unterwegs gefunden und dokumentiert:
- go-webdav vergleicht von CurrentUserPrincipal/CalendarHomeSetPath
  zurückgegebene Pfade direkt gegen r.URL.Path (inklusive Prefix) und
  bestimmt den Ressourcentyp allein an der Segmentzahl dahinter – der
  Nutzername beim Principal steht deshalb in einem Segment
  (principals-max), nicht als Unterpfad.
- chi kennt REPORT/MKCALENDAR nicht von Haus aus (nur PROPFIND & Co.
  waren für /dav bereits registriert) und hätte sie sonst mit 405
  abgewiesen, bevor der CalDAV-Handler überhaupt lief.

Migration 000021 nur mit CREATE TABLE IF NOT EXISTS, wie in
kalender-plan.md festgelegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 22:27:33 +02:00

209 lines
6.3 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 (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("calendar: not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
func (s *Store) ListCalendars(ctx context.Context, ownerID uuid.UUID) ([]Calendar, error) {
rows, err := s.db.Query(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, ctag, created_at
FROM cal_calendars WHERE owner_id = $1 ORDER BY created_at`,
ownerID,
)
if err != nil {
return nil, fmt.Errorf("query calendars: %w", err)
}
defer rows.Close()
var out []Calendar
for rows.Next() {
var c Calendar
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.CTag, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("scan calendar: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) GetCalendar(ctx context.Context, ownerID, id uuid.UUID) (*Calendar, error) {
var c Calendar
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, name, color, description, supports_todo, ctag, created_at
FROM cal_calendars WHERE owner_id = $1 AND id = $2`,
ownerID, id,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.CTag, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("query calendar: %w", err)
}
return &c, nil
}
// EnsureDefaultCalendar liefert den ersten Kalender des Nutzers, legt bei
// Bedarf "Persönlich" an. So findet ein frisch eingerichteter Client sofort
// ein Ziel vor, statt erst ein MKCALENDAR schicken zu müssen – nicht jeder
// CalDAV-Client kann das.
func (s *Store) EnsureDefaultCalendar(ctx context.Context, ownerID uuid.UUID) (*Calendar, error) {
cals, err := s.ListCalendars(ctx, ownerID)
if err != nil {
return nil, err
}
if len(cals) > 0 {
return &cals[0], nil
}
return s.CreateCalendar(ctx, ownerID, "Persönlich", "#4285f4", "")
}
func (s *Store) CreateCalendar(ctx context.Context, ownerID uuid.UUID, name, color, description string) (*Calendar, error) {
if color == "" {
color = "#4285f4"
}
var c Calendar
err := s.db.QueryRow(ctx,
`INSERT INTO cal_calendars (owner_id, name, color, description)
VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, name, color, description, supports_todo, ctag, created_at`,
ownerID, name, color, description,
).Scan(&c.ID, &c.OwnerID, &c.Name, &c.Color, &c.Description, &c.SupportsTodo, &c.CTag, &c.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert calendar: %w", err)
}
return &c, nil
}
func (s *Store) DeleteCalendar(ctx context.Context, ownerID, id uuid.UUID) error {
tag, err := s.db.Exec(ctx, `DELETE FROM cal_calendars WHERE owner_id = $1 AND id = $2`, ownerID, id)
if err != nil {
return fmt.Errorf("delete calendar: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListObjects(ctx context.Context, calendarID uuid.UUID) ([]Object, error) {
rows, err := s.db.Query(ctx,
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
FROM cal_objects WHERE calendar_id = $1 ORDER BY uid`,
calendarID,
)
if err != nil {
return nil, fmt.Errorf("query objects: %w", err)
}
defer rows.Close()
var out []Object
for rows.Next() {
o, err := scanObject(rows)
if err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
func (s *Store) GetObjectByUID(ctx context.Context, calendarID uuid.UUID, uid string) (*Object, error) {
row := s.db.QueryRow(ctx,
`SELECT id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at
FROM cal_objects WHERE calendar_id = $1 AND uid = $2`,
calendarID, uid,
)
o, err := scanObject(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &o, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanObject(row rowScanner) (Object, error) {
var o Object
err := row.Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return Object{}, fmt.Errorf("scan object: %w", err)
}
return o, nil
}
// PutObject legt ein Objekt an oder ersetzt es (gleiche UID im selben
// Kalender). Die ETag-Vorbedingungen (If-Match/If-None-Match) prüft der
// Aufrufer vorher – hier zählt nur noch das Schreiben selbst.
func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, component, data, etag string, dtstart, dtend *time.Time) (*Object, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
var o Object
err = tx.QueryRow(ctx,
`INSERT INTO cal_objects (calendar_id, uid, component, data, etag, dtstart, dtend, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (calendar_id, uid) DO UPDATE
SET component = EXCLUDED.component, data = EXCLUDED.data, etag = EXCLUDED.etag,
dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, updated_at = now()
RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`,
calendarID, uid, component, data, etag, dtstart, dtend,
).Scan(&o.ID, &o.CalendarID, &o.UID, &o.Component, &o.Data, &o.ETag, &o.DTStart, &o.DTEnd, &o.CreatedAt, &o.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert object: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); 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 {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `DELETE FROM cal_objects WHERE calendar_id = $1 AND uid = $2`, calendarID, uid)
if err != nil {
return fmt.Errorf("delete object: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1`, calendarID); err != nil {
return fmt.Errorf("bump ctag: %w", err)
}
return tx.Commit(ctx)
}