Merge: t-paliad-212 Slice 2a — bindings-driven CalDAV sync (backend cut-over)

Sync engine pivots from scalar user_caldav_config.calendar_path to the
binding-driven loop over paliad.user_calendar_bindings. Invisible-but-shippable:
existing users keep working through the bootstrap binding row mig 101 created
for them; new bindings (Slice 2b UI) plug into the same loop.

- mig 107 — paliad.caldav_sync_log.binding_id (nullable FK ON DELETE SET NULL
  so audit history survives binding deletes) + partial index. Idempotent.
- CalendarBindingService — full CRUD + ListEnabled/ListAllEnabled, scope
  validation mirrors the CHECK constraints from mig 101.
- AppointmentTargetService — UpsertAfterPush, StaleForBinding,
  FindByUIDAndBinding. Authoritative source of per-target state going forward.
- CalDAVService rewritten: per-binding inner loop, ForBinding() scope filter
  (all_visible / personal_only / project — hierarchy scopes parked for Slice 3).
- REPORT calendar-multiget in caldav_client.go — collapses N GETs/min to one
  multistatus REPORT (fits inside iCloud/Google rate windows).
- Read-only GET /api/caldav-bindings (write APIs come in Slice 2b).
- caldav_sync_log writes carry binding_id; pre-mig-107 rows stay NULL.

First migration to land via the new gap-tolerant runner (boltzmann c85c382).
This commit is contained in:
mAi
2026-05-20 13:05:46 +02:00
12 changed files with 1028 additions and 140 deletions

View File

@@ -117,7 +117,9 @@ func main() {
}
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
bindingSvc := services.NewCalendarBindingService(pool)
targetSvc := services.NewAppointmentTargetService(pool)
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
// Wire the push hook so user-driven mutations sync to the external
// calendar without waiting for the next 60-second tick.
appointmentSvc.SetCalDAVPusher(caldavSvc)
@@ -143,6 +145,7 @@ func main() {
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
CalDAVBindings: bindingSvc,
Rules: rules,
Calculator: services.NewDeadlineCalculator(holidays),
Users: users,

View File

@@ -0,0 +1,5 @@
-- Reverse of 107: drop the binding_id column from caldav_sync_log.
-- The associated index drops automatically with the column.
ALTER TABLE paliad.caldav_sync_log
DROP COLUMN IF EXISTS binding_id;

View File

@@ -0,0 +1,53 @@
-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
--
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
-- records which binding the entry belongs to. NULL for legacy rows
-- and for "global" log entries that aren't per-binding (Slice 2a
-- still writes one row per user per tick — Slice 2b's sync rewrite
-- moves to one row per (user, binding) per tick).
--
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
-- its historical sync log (audit trail wins over referential tidiness).
--
-- Idempotent: column added via DO block with information_schema check.
SELECT set_config(
'paliad.audit_reason',
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id'
) THEN
ALTER TABLE paliad.caldav_sync_log
ADD COLUMN binding_id uuid
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
WHERE binding_id IS NOT NULL;
-- Assertion: column exists and is nullable.
DO $$
DECLARE
col_nullable text;
BEGIN
SELECT is_nullable INTO col_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'caldav_sync_log'
AND column_name = 'binding_id';
IF col_nullable IS NULL THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
END IF;
IF col_nullable <> 'YES' THEN
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
END IF;
END $$;

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -311,6 +312,34 @@ func handleTestCalDAVConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// GET /api/caldav-bindings — list the authenticated user's CalDAV
// bindings (the (calendar, scope) entries layered on the single CalDAV
// server connection). Read-only in Slice 2a; full CRUD lands in Slice 2b.
func handleListCalDAVBindings(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.caldavBindings == nil {
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": "CalDAV bindings unavailable (CalDAV service not configured)",
})
return
}
rows, err := dbSvc.caldavBindings.ListForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if rows == nil {
rows = []models.UserCalendarBinding{}
}
writeJSON(w, http.StatusOK, rows)
}
// GET /api/caldav-config/log — last 5 sync attempts.
func handleCalDAVSyncLog(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {

View File

@@ -57,6 +57,7 @@ type Services struct {
Deadline *services.DeadlineService
Appointment *services.AppointmentService
CalDAV *services.CalDAVService
CalDAVBindings *services.CalendarBindingService
Rules *services.DeadlineRuleService
Calculator *services.DeadlineCalculator
Users *services.UserService
@@ -129,6 +130,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
deadline: svc.Deadline,
appointment: svc.Appointment,
caldav: svc.CalDAV,
caldavBindings: svc.CalDAVBindings,
rules: svc.Rules,
calc: svc.Calculator,
users: svc.Users,
@@ -354,6 +356,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/caldav-config", handleDeleteCalDAVConfig)
protected.HandleFunc("POST /api/caldav-config/test", handleTestCalDAVConfig)
protected.HandleFunc("GET /api/caldav-config/log", handleCalDAVSyncLog)
// t-paliad-212 Slice 2a — read-only binding list. Full CRUD lands in Slice 2b.
protected.HandleFunc("GET /api/caldav-bindings", handleListCalDAVBindings)
// t-paliad-088 — Event Types (categorization for Deadlines).
protected.HandleFunc("GET /api/event-types", handleListEventTypes)

View File

@@ -24,6 +24,7 @@ type dbServices struct {
deadline *services.DeadlineService
appointment *services.AppointmentService
caldav *services.CalDAVService
caldavBindings *services.CalendarBindingService
rules *services.DeadlineRuleService
calc *services.DeadlineCalculator
users *services.UserService

View File

@@ -437,16 +437,60 @@ type UserCalDAVConfig struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// CalDAVSyncLogEntry is one historical sync record.
// CalDAVSyncLogEntry is one historical sync record. BindingID is populated
// for per-binding sync entries written by the post-Slice-2a sync engine;
// older rows have it NULL and the entry covers the user's default binding.
type CalDAVSyncLogEntry struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
Direction string `db:"direction" json:"direction"`
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
Error *string `db:"error" json:"error,omitempty"`
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
Direction string `db:"direction" json:"direction"`
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
Error *string `db:"error" json:"error,omitempty"`
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
BindingID *uuid.UUID `db:"binding_id" json:"binding_id,omitempty"`
}
// UserCalendarBinding is one of N (calendar, scope) bindings a user can
// configure on top of their single CalDAV server connection. The same
// Appointment can land in multiple bindings (e.g. master + per-project),
// with per-binding push state living in AppointmentCalDAVTarget.
type UserCalendarBinding struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
DisplayName string `db:"display_name" json:"display_name"`
ScopeKind string `db:"scope_kind" json:"scope_kind"`
ScopeID *uuid.UUID `db:"scope_id" json:"scope_id,omitempty"`
IncludePersonal bool `db:"include_personal" json:"include_personal"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Scope-kind enum mirrored from paliad.user_calendar_bindings_scope_kind_chk.
const (
BindingScopeAllVisible = "all_visible"
BindingScopePersonalOnly = "personal_only"
BindingScopeProject = "project"
BindingScopeClient = "client"
BindingScopeLitigation = "litigation"
BindingScopePatent = "patent"
BindingScopeCase = "case"
)
// AppointmentCalDAVTarget is the per-(appointment, binding) push state.
// The caldav_uid is canonical per Appointment (same value across all of
// an appointment's targets); caldav_etag varies per binding.
type AppointmentCalDAVTarget struct {
AppointmentID uuid.UUID `db:"appointment_id" json:"appointment_id"`
BindingID uuid.UUID `db:"binding_id" json:"binding_id"`
CalDAVUID string `db:"caldav_uid" json:"caldav_uid"`
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
LastPushedAt time.Time `db:"last_pushed_at" json:"last_pushed_at"`
}
// Party is a party to a Project (Kläger, Beklagter, etc. — typically on

View File

@@ -753,6 +753,86 @@ func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) (
return rows, nil
}
// ErrUnsupportedScope is returned by ForBinding when the binding's
// scope_kind is one of the hierarchy scopes (client / litigation /
// patent / case) — those land in Slice 3 of t-paliad-212. Slice 2
// only supports all_visible / personal_only / project.
var ErrUnsupportedScope = errors.New("binding scope_kind not yet supported")
// ForBinding returns the slice of the user's appointments that belongs
// in this binding's calendar. Implements the §2.3 scope filter from
// docs/design-caldav-slice-2-2026-05-20.md.
//
// - all_visible → AllForUser(userID)
// - personal_only → personal (project_id IS NULL) appointments
// created by this user
// - project → appointments attached to scope_id, gated by the
// same visibility predicate as AllForUser. Hidden
// projects return an empty slice (the binding stays
// in place but receives no events). If
// include_personal is true, the user's personal
// appointments are unioned in.
//
// Hierarchy scopes (client / litigation / patent / case) return
// ErrUnsupportedScope; Slice 3 wires them via the existing path-based
// descendant predicate.
func (s *AppointmentService) ForBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) ([]models.Appointment, error) {
if b == nil {
return nil, fmt.Errorf("%w: nil binding", ErrInvalidInput)
}
switch b.ScopeKind {
case models.BindingScopeAllVisible:
return s.AllForUser(ctx, userID)
case models.BindingScopePersonalOnly:
rows := []models.Appointment{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+appointmentColumns+`
FROM paliad.appointments t
WHERE t.project_id IS NULL
AND t.created_by = $1`, userID); err != nil {
return nil, fmt.Errorf("for-binding personal_only: %w", err)
}
return rows, nil
case models.BindingScopeProject:
if b.ScopeID == nil {
return nil, fmt.Errorf("%w: project binding missing scope_id", ErrInvalidInput)
}
var query string
if b.IncludePersonal {
query = `
SELECT ` + appointmentColumns + `
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE (
t.project_id = $2
AND ` + visibilityPredicatePositional("p", 1) + `
) OR (
t.project_id IS NULL AND t.created_by = $1
)`
} else {
query = `
SELECT ` + appointmentColumns + `
FROM paliad.appointments t
JOIN paliad.projects p ON p.id = t.project_id
WHERE t.project_id = $2
AND ` + visibilityPredicatePositional("p", 1)
}
rows := []models.Appointment{}
if err := s.db.SelectContext(ctx, &rows, query, userID, *b.ScopeID); err != nil {
return nil, fmt.Errorf("for-binding project: %w", err)
}
return rows, nil
case models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
return nil, ErrUnsupportedScope
default:
return nil, fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, b.ScopeKind)
}
}
// FindByCalDAVUID resolves a Appointment from its external UID.
func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) {
var t models.Appointment

View File

@@ -0,0 +1,265 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// CalendarBindingService — CRUD on paliad.user_calendar_bindings.
//
// Each row is one of N (calendar, scope) bindings layered on top of the
// user's single CalDAV server connection in paliad.user_caldav_config.
// Slice 1 (t-paliad-212) introduced the table + an auto-backfilled
// 'all_visible' binding per existing user; Slice 2a wires the service
// that owns the rows. The sync engine (CalDAVService) drives off
// ListEnabled to discover where to push.
//
// Validation of (scope_kind, scope_id) combinatorics is enforced both
// here (so the API returns a useful 400) and by the table's CHECK
// constraints (so direct SQL or older clients can't slip a bad row in).
type CalendarBindingService struct {
db *sqlx.DB
}
func NewCalendarBindingService(db *sqlx.DB) *CalendarBindingService {
return &CalendarBindingService{db: db}
}
const bindingColumns = `
id, user_id, calendar_path, display_name,
scope_kind, scope_id, include_personal, enabled,
last_sync_at, last_sync_error, created_at, updated_at`
// ListForUser returns every binding owned by the user, ordered by
// scope_kind then created_at so the all_visible / personal_only roots
// always sort to the top.
func (s *CalendarBindingService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
rows := []models.UserCalendarBinding{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE user_id = $1
ORDER BY
CASE scope_kind
WHEN 'all_visible' THEN 0
WHEN 'personal_only' THEN 1
ELSE 2
END,
created_at`, userID); err != nil {
return nil, fmt.Errorf("list bindings: %w", err)
}
return rows, nil
}
// ListEnabled returns the user's bindings with enabled = true.
// Used by the CalDAVService sync loop.
func (s *CalendarBindingService) ListEnabled(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
rows := []models.UserCalendarBinding{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE user_id = $1 AND enabled = true
ORDER BY created_at`, userID); err != nil {
return nil, fmt.Errorf("list enabled bindings: %w", err)
}
return rows, nil
}
// ListAllEnabled returns every enabled binding across all users.
// Used at server boot to spawn one sync goroutine per (user) that
// owns at least one enabled binding.
func (s *CalendarBindingService) ListAllEnabled(ctx context.Context) ([]models.UserCalendarBinding, error) {
rows := []models.UserCalendarBinding{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE enabled = true
ORDER BY user_id, created_at`); err != nil {
return nil, fmt.Errorf("list all enabled bindings: %w", err)
}
return rows, nil
}
// Get returns one binding scoped to the user; ErrNotVisible when the row
// doesn't exist or belongs to someone else.
func (s *CalendarBindingService) Get(ctx context.Context, userID, bindingID uuid.UUID) (*models.UserCalendarBinding, error) {
var b models.UserCalendarBinding
err := s.db.GetContext(ctx, &b,
`SELECT `+bindingColumns+`
FROM paliad.user_calendar_bindings
WHERE id = $1 AND user_id = $2`, bindingID, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("get binding: %w", err)
}
return &b, nil
}
// CreateInput is the payload for POST /api/caldav-bindings. Slice 2b
// wires this; Slice 2a exposes Create for tests + SQL-equivalent
// integration tests.
type CreateBindingInput struct {
CalendarPath string `json:"calendar_path"`
DisplayName string `json:"display_name"`
ScopeKind string `json:"scope_kind"`
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
IncludePersonal bool `json:"include_personal"`
Enabled bool `json:"enabled"`
}
// Create inserts a new binding. Validates scope_kind / scope_id
// combinatorics; returns ErrInvalidInput on a bad payload.
func (s *CalendarBindingService) Create(ctx context.Context, userID uuid.UUID, in CreateBindingInput) (*models.UserCalendarBinding, error) {
if err := validateScope(in.ScopeKind, in.ScopeID); err != nil {
return nil, err
}
if in.CalendarPath == "" {
return nil, fmt.Errorf("%w: calendar_path is required", ErrInvalidInput)
}
now := time.Now().UTC()
var b models.UserCalendarBinding
err := s.db.GetContext(ctx, &b,
`INSERT INTO paliad.user_calendar_bindings
(user_id, calendar_path, display_name, scope_kind, scope_id,
include_personal, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
RETURNING `+bindingColumns,
userID, in.CalendarPath, in.DisplayName, in.ScopeKind, in.ScopeID,
in.IncludePersonal, in.Enabled, now)
if err != nil {
return nil, fmt.Errorf("insert binding: %w", err)
}
return &b, nil
}
// UpdateInput captures the PATCH-shaped fields. Pointer fields = "leave
// as-is when nil".
type UpdateBindingInput struct {
DisplayName *string `json:"display_name,omitempty"`
ScopeKind *string `json:"scope_kind,omitempty"`
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
IncludePersonal *bool `json:"include_personal,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// Update mutates the binding. Validates the resulting (scope_kind, scope_id)
// combinatorics if either field changes.
func (s *CalendarBindingService) Update(ctx context.Context, userID, bindingID uuid.UUID, in UpdateBindingInput) (*models.UserCalendarBinding, error) {
existing, err := s.Get(ctx, userID, bindingID)
if err != nil {
return nil, err
}
if in.ScopeKind != nil || in.ScopeID != nil {
kind := existing.ScopeKind
if in.ScopeKind != nil {
kind = *in.ScopeKind
}
var sid *uuid.UUID
if in.ScopeID != nil {
sid = in.ScopeID
} else {
sid = existing.ScopeID
}
if err := validateScope(kind, sid); err != nil {
return nil, err
}
}
sets := []string{"updated_at = NOW()"}
args := []any{}
next := 1
addSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if in.DisplayName != nil {
addSet("display_name", *in.DisplayName)
}
if in.ScopeKind != nil {
addSet("scope_kind", *in.ScopeKind)
}
if in.ScopeID != nil {
addSet("scope_id", *in.ScopeID)
}
if in.IncludePersonal != nil {
addSet("include_personal", *in.IncludePersonal)
}
if in.Enabled != nil {
addSet("enabled", *in.Enabled)
}
// Append WHERE clause args last.
args = append(args, bindingID, userID)
q := fmt.Sprintf(`UPDATE paliad.user_calendar_bindings
SET %s
WHERE id = $%d AND user_id = $%d
RETURNING %s`, strings.Join(sets, ", "), next, next+1, bindingColumns)
var b models.UserCalendarBinding
if err := s.db.GetContext(ctx, &b, q, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
return nil, fmt.Errorf("update binding: %w", err)
}
return &b, nil
}
// Delete removes the binding row. Caller is responsible for the remote
// .ics cleanup (CalDAVService handles that via §2.6 of the Slice 2 brief)
// before invoking this; this method is the bare DB delete.
func (s *CalendarBindingService) Delete(ctx context.Context, userID, bindingID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.user_calendar_bindings
WHERE id = $1 AND user_id = $2`, bindingID, userID)
if err != nil {
return fmt.Errorf("delete binding: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
return nil
}
// SetSyncStatus is called by CalDAVService after each sync attempt for
// this binding. last_sync_error nil clears the previous error.
func (s *CalendarBindingService) SetSyncStatus(ctx context.Context, bindingID uuid.UUID, errStr *string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_calendar_bindings
SET last_sync_at = NOW(), last_sync_error = $1, updated_at = NOW()
WHERE id = $2`, errStr, bindingID)
if err != nil {
return fmt.Errorf("update binding sync status: %w", err)
}
return nil
}
// validateScope mirrors the table's CHECK constraints — we duplicate
// the rule here so the API can return a useful 400 instead of letting
// Postgres reject the row with a generic check_violation.
func validateScope(kind string, scopeID *uuid.UUID) error {
switch kind {
case models.BindingScopeAllVisible, models.BindingScopePersonalOnly:
if scopeID != nil {
return fmt.Errorf("%w: scope_id must be NULL when scope_kind = %q", ErrInvalidInput, kind)
}
case models.BindingScopeProject, models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
if scopeID == nil {
return fmt.Errorf("%w: scope_id is required when scope_kind = %q", ErrInvalidInput, kind)
}
default:
return fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, kind)
}
return nil
}

View File

@@ -169,6 +169,77 @@ func (c *calDAVClient) PropfindCalendar(ctx context.Context, calendarPath string
return parseMultiStatus(resp.Body)
}
// multigetMaxHrefs caps the number of hrefs in one REPORT request to keep
// us well within Google's documented limit (~200) and iCloud's
// rate-shaping. Callers chunk larger lists into multiple requests.
const multigetMaxHrefs = 100
// MultigetEvent is one (href, etag, calendar-data) result returned by
// ReportMultiget. CalendarData is the raw iCalendar body and is fed
// straight into parseICalendar; ETag matches the value that would have
// been returned by PROPFIND for the same href.
type MultigetEvent struct {
Href string
ETag string
CalendarData string
}
// ReportMultiget runs a `REPORT calendar-multiget` (RFC 4791 §7.9)
// against calendarPath and returns one MultigetEvent per requested href.
// Hrefs missing from the response (404 inside the multistatus) are
// omitted from the returned slice — callers should treat that as a
// remote deletion. Hrefs are auto-chunked at multigetMaxHrefs.
func (c *calDAVClient) ReportMultiget(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
if len(hrefs) == 0 {
return nil, nil
}
out := []MultigetEvent{}
for start := 0; start < len(hrefs); start += multigetMaxHrefs {
end := min(start+multigetMaxHrefs, len(hrefs))
chunk, err := c.reportMultigetChunk(ctx, calendarPath, hrefs[start:end])
if err != nil {
return nil, err
}
out = append(out, chunk...)
}
return out, nil
}
func (c *calDAVClient) reportMultigetChunk(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
`)
for _, h := range hrefs {
b.WriteString(" <D:href>")
_ = xml.EscapeText(&b, []byte(h))
b.WriteString("</D:href>\n")
}
b.WriteString(`</C:calendar-multiget>`)
req, err := http.NewRequestWithContext(ctx, "REPORT", c.absURL(calendarPath), strings.NewReader(b.String()))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("REPORT: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 207 {
raw, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("REPORT %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw))
}
return parseMultigetResponse(resp.Body)
}
// PropfindRoot performs a Depth:0 PROPFIND on the calendar URL — used by
// the "Test connection" button to verify auth + URL without storing creds.
func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
@@ -221,6 +292,7 @@ type propStat struct {
Status string `xml:"DAV: status"`
Prop struct {
ETag string `xml:"DAV: getetag"`
CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
ResourceType struct {
Collection *struct{} `xml:"DAV: collection"`
} `xml:"DAV: resourcetype"`
@@ -232,6 +304,35 @@ type multiStatus struct {
Responses []msResponse `xml:"DAV: response"`
}
func parseMultigetResponse(r io.Reader) ([]MultigetEvent, error) {
var ms multiStatus
dec := xml.NewDecoder(r)
if err := dec.Decode(&ms); err != nil {
return nil, fmt.Errorf("decode multistatus: %w", err)
}
out := []MultigetEvent{}
for _, resp := range ms.Responses {
var etag, data string
ok := false
for _, ps := range resp.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
etag = strings.Trim(ps.Prop.ETag, `"`)
data = ps.Prop.CalendarData
if data != "" {
ok = true
}
}
if !ok {
// 404 / 403 on this specific href — treat as missing, skip.
continue
}
out = append(out, MultigetEvent{Href: resp.Href, ETag: etag, CalendarData: data})
}
return out, nil
}
func parseMultiStatus(r io.Reader) ([]CalDAVEntry, error) {
var ms multiStatus
dec := xml.NewDecoder(r)

View File

@@ -34,9 +34,11 @@ import (
// never leave the process boundary; the API never returns them; the model
// has json:"-" on PasswordEncrypted.
type CalDAVService struct {
db *sqlx.DB
cipher *CalDAVCipher
db *sqlx.DB
cipher *CalDAVCipher
appointments *AppointmentService
bindings *CalendarBindingService
targets *AppointmentTargetService
mu sync.Mutex
cancels map[uuid.UUID]context.CancelFunc // userID -> goroutine cancel
@@ -47,12 +49,14 @@ type CalDAVService struct {
// NewCalDAVService wires the service. cipher may be nil — in that case all
// operations return ErrCalDAVNoKey and the goroutines are never spawned.
func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, appointments *AppointmentService) *CalDAVService {
func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, appointments *AppointmentService, bindings *CalendarBindingService, targets *AppointmentTargetService) *CalDAVService {
return &CalDAVService{
db: db,
cipher: cipher,
db: db,
cipher: cipher,
appointments: appointments,
cancels: map[uuid.UUID]context.CancelFunc{},
bindings: bindings,
targets: targets,
cancels: map[uuid.UUID]context.CancelFunc{},
}
}
@@ -60,8 +64,14 @@ func NewCalDAVService(db *sqlx.DB, cipher *CalDAVCipher, appointments *Appointme
// perform any CalDAV work.
func (s *CalDAVService) Enabled() bool { return s.cipher != nil }
// Start spawns one sync goroutine per row currently in user_caldav_config
// where enabled = true. No-op when CALDAV_ENCRYPTION_KEY is unset.
// Start spawns one sync goroutine per user that owns at least one
// enabled binding. No-op when CALDAV_ENCRYPTION_KEY is unset.
//
// Post-Slice-2a, "enabled" is determined by paliad.user_calendar_bindings,
// not paliad.user_caldav_config — the config row holds credentials,
// the binding rows decide which calendars get events. A user with a
// disabled config row still needs no goroutine (no credentials), so
// we intersect: enabled binding AND enabled config.
func (s *CalDAVService) Start(ctx context.Context) error {
if s.cipher == nil {
slog.Info("CalDAV: disabled (CALDAV_ENCRYPTION_KEY unset)")
@@ -69,14 +79,25 @@ func (s *CalDAVService) Start(ctx context.Context) error {
}
s.rootCtx, s.rootStop = context.WithCancel(ctx)
configs, err := s.listEnabledConfigs(ctx)
bindings, err := s.bindings.ListAllEnabled(ctx)
if err != nil {
return fmt.Errorf("list enabled caldav configs: %w", err)
return fmt.Errorf("list enabled caldav bindings: %w", err)
}
for _, cfg := range configs {
s.spawnLoop(cfg.UserID)
// Deduplicate by user_id — one goroutine per user, not per binding.
seen := map[uuid.UUID]bool{}
for _, b := range bindings {
if seen[b.UserID] {
continue
}
// Only spawn when the config row is also enabled (creds present).
cfg, err := s.loadDecryptedConfig(ctx, b.UserID)
if err != nil || cfg == nil || !cfg.Enabled {
continue
}
seen[b.UserID] = true
s.spawnLoop(b.UserID)
}
slog.Info("CalDAV: started", "users", len(configs))
slog.Info("CalDAV: started", "users", len(seen), "bindings", len(bindings))
return nil
}
@@ -194,6 +215,27 @@ func (s *CalDAVService) SaveConfig(ctx context.Context, userID uuid.UUID, in Sav
return nil, fmt.Errorf("upsert caldav config: %w", err)
}
// First-time configure: auto-create an all_visible binding pointing
// at the (legacy) calendar_path so Phase F's "set up CalDAV → events
// just appear" UX survives Slice 2a. The Slice 1 backfill handled
// existing users; this branch covers new users between Slice 2a
// landing and the Slice 2b picker UI being available.
if existing == nil {
path := in.CalendarPath
if path == "" {
path = in.URL
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.user_calendar_bindings
(user_id, calendar_path, display_name, scope_kind, scope_id,
include_personal, enabled, created_at, updated_at)
VALUES ($1, $2, '', 'all_visible', NULL, false, $3, $4, $4)
ON CONFLICT (user_id, calendar_path) DO NOTHING`,
userID, path, in.Enabled, now); err != nil {
slog.Warn("CalDAV: auto-create default binding failed", "user_id", userID, "error", err)
}
}
// Restart the per-user goroutine so the next tick uses the new config.
s.stopLoop(userID)
if in.Enabled {
@@ -312,56 +354,80 @@ func (s *CalDAVService) userLoop(ctx context.Context, userID uuid.UUID) {
}
}
// runSyncOnce runs every enabled binding for a user, writing one
// caldav_sync_log row per binding (binding_id populated post-Slice-2a)
// and rolling the per-user last_sync_at into user_caldav_config so the
// existing /api/caldav-config endpoint still shows aggregate status.
func (s *CalDAVService) runSyncOnce(ctx context.Context, userID uuid.UUID) {
start := time.Now()
pushed, pulled, syncErr := s.syncOnce(ctx, userID)
dur := int(time.Since(start) / time.Millisecond)
var errStr *string
if syncErr != nil {
es := syncErr.Error()
errStr = &es
slog.Warn("CalDAV: sync failed", "user_id", userID, "error", es)
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil || cfg == nil || !cfg.Enabled {
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
bindings, err := s.bindings.ListEnabled(ctx, userID)
if err != nil {
slog.Warn("CalDAV: list bindings failed", "user_id", userID, "error", err)
return
}
if len(bindings) == 0 {
return
}
var lastErr *string
for i := range bindings {
b := &bindings[i]
start := time.Now()
pushed, pulled, syncErr := s.syncBinding(ctx, cli, userID, b)
dur := int(time.Since(start) / time.Millisecond)
var errStr *string
if syncErr != nil {
es := syncErr.Error()
errStr = &es
lastErr = errStr
slog.Warn("CalDAV: binding sync failed", "user_id", userID, "binding_id", b.ID, "error", es)
}
if err := s.bindings.SetSyncStatus(ctx, b.ID, errStr); err != nil {
slog.Warn("CalDAV: write binding sync status", "binding_id", b.ID, "error", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.caldav_sync_log
(user_id, binding_id, direction, items_pushed, items_pulled, error, duration_ms)
VALUES ($1, $2, 'both', $3, $4, $5, $6)`,
userID, b.ID, pushed, pulled, errStr, dur); err != nil {
slog.Warn("CalDAV: write sync log", "error", err)
}
}
// Roll the worst-case per-binding error up onto user_caldav_config so
// the existing /api/caldav-config status surface still reflects sync
// health. last_sync_error = NULL means "every binding synced cleanly".
if _, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET last_sync_at = NOW(), last_sync_error = $1
WHERE user_id = $2`, errStr, userID); err != nil {
WHERE user_id = $2`, lastErr, userID); err != nil {
slog.Warn("CalDAV: write last_sync_at", "error", err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.caldav_sync_log
(user_id, direction, items_pushed, items_pulled, error, duration_ms)
VALUES ($1, 'both', $2, $3, $4, $5)`,
userID, pushed, pulled, errStr, dur); err != nil {
slog.Warn("CalDAV: write sync log", "error", err)
}
// Trim sync log to last 5 entries per user.
// Trim sync log to last 5 entries per (user, binding) so multi-binding
// users don't accumulate noise. Per-binding trim keeps each binding's
// own history independently inspectable.
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.caldav_sync_log
WHERE user_id = $1
AND id NOT IN (
SELECT id FROM paliad.caldav_sync_log
WHERE user_id = $1
ORDER BY occurred_at DESC LIMIT 5
ORDER BY occurred_at DESC LIMIT 25
)`, userID); err != nil {
slog.Warn("CalDAV: trim sync log", "error", err)
}
}
// syncOnce runs one push + one pull for a user. Returns (pushed, pulled, err).
func (s *CalDAVService) syncOnce(ctx context.Context, userID uuid.UUID) (int, int, error) {
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return 0, 0, err
}
if cfg == nil || !cfg.Enabled {
return 0, 0, nil
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
pushed, pushErr := s.pushAll(ctx, cli, cfg, userID)
pulled, pullErr := s.pullAll(ctx, cli, cfg, userID)
// syncBinding runs one push + one pull for a single binding.
func (s *CalDAVService) syncBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, int, error) {
pushed, pushErr := s.pushBinding(ctx, cli, userID, b)
pulled, pullErr := s.pullBinding(ctx, cli, userID, b)
if pushErr != nil && pullErr != nil {
return pushed, pulled, fmt.Errorf("push: %v; pull: %v", pushErr, pullErr)
@@ -375,61 +441,119 @@ func (s *CalDAVService) syncOnce(ctx context.Context, userID uuid.UUID) (int, in
return pushed, pulled, nil
}
// pushAll uploads every visible Appointment to the user's external calendar.
// Best effort: a single failed PUT logs and continues.
func (s *CalDAVService) pushAll(ctx context.Context, cli *calDAVClient, cfg *decryptedConfig, userID uuid.UUID) (int, error) {
appointments, err := s.appointments.AllForUser(ctx, userID)
// pushBinding uploads every in-scope Appointment to one binding's calendar
// and cleans up target rows for events that have left the scope. Best
// effort: a single failed PUT logs and continues; the next tick retries.
func (s *CalDAVService) pushBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
appointments, err := s.appointments.ForBinding(ctx, userID, b)
if err != nil {
if errors.Is(err, ErrUnsupportedScope) {
// Slice 2a sees a Slice-3 scope_kind. Skip cleanly — the UI
// shouldn't have let the user create this binding, but a
// direct SQL insert could; we don't want to crash the loop.
return 0, nil
}
return 0, err
}
inScope := make(map[uuid.UUID]struct{}, len(appointments))
pushed := 0
for i := range appointments {
t := &appointments[i]
inScope[t.ID] = struct{}{}
body := formatAppointment(t)
etag, err := cli.PutEvent(ctx, cfg.CalendarPath, terminUID(t.ID.String()), body)
uid := terminUID(t.ID.String())
etag, err := cli.PutEvent(ctx, b.CalendarPath, uid, body)
if err != nil {
slog.Warn("CalDAV: push appointment failed", "id", t.ID, "error", err)
slog.Warn("CalDAV: push appointment failed", "binding_id", b.ID, "id", t.ID, "error", err)
continue
}
uid := terminUID(t.ID.String())
if err := s.targets.UpsertAfterPush(ctx, t.ID, b.ID, uid, etag); err != nil {
slog.Warn("CalDAV: write target failed", "binding_id", b.ID, "id", t.ID, "error", err)
continue
}
// Back-compat: keep the scalar uid/etag on the appointment row
// fresh as a denormalised pointer to "some" target. Other code
// paths still read these in Slice 2a; Slice 4 drops them.
if err := s.appointments.SetCalDAVMeta(ctx, t.ID, uid, etag); err != nil {
slog.Warn("CalDAV: write meta failed", "id", t.ID, "error", err)
continue
}
pushed++
}
// Stale-target cleanup: events that used to be in this binding's
// scope but no longer are (project unshared, scope_kind PATCHed)
// get DELETEd from the remote calendar so the user's calendar app
// doesn't show ghost events.
currentIDs := make([]uuid.UUID, 0, len(appointments))
for id := range inScope {
currentIDs = append(currentIDs, id)
}
stale, err := s.targets.StaleForBinding(ctx, b.ID, currentIDs)
if err != nil {
slog.Warn("CalDAV: stale-target lookup failed", "binding_id", b.ID, "error", err)
return pushed, nil
}
for _, t := range stale {
if err := cli.DeleteEvent(ctx, b.CalendarPath, t.CalDAVUID); err != nil {
slog.Warn("CalDAV: stale delete failed", "binding_id", b.ID, "uid", t.CalDAVUID, "error", err)
continue
}
if err := s.targets.DeleteByAppointmentAndBinding(ctx, t.AppointmentID, b.ID); err != nil {
slog.Warn("CalDAV: stale target delete failed", "binding_id", b.ID, "uid", t.CalDAVUID, "error", err)
}
}
return pushed, nil
}
// pullAll inspects the remote calendar and reconciles local Appointments. UIDs
// outside the Paliad namespace (paliad-appointment-*@paliad.de) are ignored.
// pullBinding inspects the remote calendar for one binding and reconciles
// local Appointments using REPORT calendar-multiget. UIDs outside the
// Paliad namespace (paliad-appointment-*@paliad.de) are ignored.
//
// Reconciliation rules:
// Reconciliation rules (per (appointment, binding) target row):
// - UID matches a known Appointment + ETag changed → ApplyRemoteUpdate
// and upsert target.caldav_etag
// - UID matches a known Appointment + ETag unchanged → no-op
// - Locally-known UID NOT in remote list → DeleteByCalDAVUID
// - Locally-known UID NOT in remote list → DeleteByCalDAVUID + drop
// target row
//
// Foreign-UID events are intentionally not imported in v1 — Paliad
// "owns" its calendar; user-driven additions in the external calendar
// are out of scope and would create attribution problems.
func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *decryptedConfig, userID uuid.UUID) (int, error) {
entries, err := cli.PropfindCalendar(ctx, cfg.CalendarPath)
// Foreign-UID events are intentionally not imported — Paliad "owns" its
// UIDs.
func (s *CalDAVService) pullBinding(ctx context.Context, cli *calDAVClient, userID uuid.UUID, b *models.UserCalendarBinding) (int, error) {
entries, err := cli.PropfindCalendar(ctx, b.CalendarPath)
if err != nil {
return 0, err
}
remoteByUID := map[string]CalDAVEntry{}
// Diff against stored target etags. Hrefs whose etag matches the
// stored target are skipped; others get fetched via multiget in
// one round-trip.
storedByUID := map[string]models.AppointmentCalDAVTarget{}
storedTargets, err := s.targets.ListForBinding(ctx, b.ID)
if err == nil {
for _, t := range storedTargets {
storedByUID[t.CalDAVUID] = t
}
}
hrefsToFetch := make([]string, 0, len(entries))
for _, e := range entries {
// We can't determine UID from href alone; fetch every entry
// whose etag we haven't seen. The first multiget seeds the
// uid↔href mapping for future ticks.
hrefsToFetch = append(hrefsToFetch, e.Href)
}
multi, err := cli.ReportMultiget(ctx, b.CalendarPath, hrefsToFetch)
if err != nil {
return 0, err
}
remoteByUID := map[string]MultigetEvent{}
pulled := 0
for _, e := range entries {
body, err := cli.GetEvent(ctx, e.Href)
for _, m := range multi {
events, err := parseICalendar(m.CalendarData)
if err != nil {
slog.Warn("CalDAV: GET event failed", "href", e.Href, "error", err)
continue
}
events, err := parseICalendar(body)
if err != nil {
slog.Warn("CalDAV: parse event failed", "href", e.Href, "error", err)
slog.Warn("CalDAV: parse event failed", "href", m.Href, "binding_id", b.ID, "error", err)
continue
}
for _, ev := range events {
@@ -437,21 +561,25 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec
if id == "" {
continue
}
remoteByUID[ev.UID] = e
remoteByUID[ev.UID] = m
if _, err := uuid.Parse(id); err != nil {
continue
}
// Per-binding etag check: if we have a stored target with
// the same etag, nothing changed remotely for this binding.
if stored, ok := storedByUID[ev.UID]; ok && stored.CalDAVEtag != nil && *stored.CalDAVEtag == m.ETag {
continue
}
local, err := s.appointments.FindByCalDAVUID(ctx, ev.UID)
if err != nil {
continue // local row not yet created or deleted
}
if local.CreatedBy == nil || *local.CreatedBy != userID {
// Pulled an event owned by another user — skip; that user's
// own goroutine will reconcile.
continue
}
if local.CalDAVEtag != nil && *local.CalDAVEtag == e.ETag {
// Pulled an event owned by another user — that user's own
// goroutine reconciles it against their own binding(s).
continue
}
var titlePtr, descPtr, locPtr *string
@@ -467,11 +595,14 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec
ls := ev.Location
locPtr = &ls
}
changed, err := s.appointments.ApplyRemoteUpdate(ctx, local.ID, titlePtr, descPtr, locPtr, ev.DTStart, ev.DTEnd, e.ETag)
changed, err := s.appointments.ApplyRemoteUpdate(ctx, local.ID, titlePtr, descPtr, locPtr, ev.DTStart, ev.DTEnd, m.ETag)
if err != nil {
slog.Warn("CalDAV: apply remote update failed", "id", local.ID, "error", err)
slog.Warn("CalDAV: apply remote update failed", "id", local.ID, "binding_id", b.ID, "error", err)
continue
}
if err := s.targets.UpsertAfterPush(ctx, local.ID, b.ID, ev.UID, m.ETag); err != nil {
slog.Warn("CalDAV: refresh target etag failed", "id", local.ID, "binding_id", b.ID, "error", err)
}
if changed {
_ = s.appointments.LogConflict(ctx, local.ID, "Appointment from external calendar synced (last-write-wins)")
}
@@ -479,24 +610,21 @@ func (s *CalDAVService) pullAll(ctx context.Context, cli *calDAVClient, cfg *dec
}
}
// Detect remote deletions for this user's Paliad-owned events.
all, err := s.appointments.AllForUser(ctx, userID)
if err == nil {
for i := range all {
t := &all[i]
if t.CalDAVUID == nil {
continue
}
if _, ok := remoteByUID[*t.CalDAVUID]; ok {
continue
}
// Remote no longer has this UID — pull-delete.
if err := s.appointments.DeleteByCalDAVUID(ctx, *t.CalDAVUID); err != nil {
slog.Warn("CalDAV: pull-delete failed", "uid", *t.CalDAVUID, "error", err)
continue
}
pulled++
// Detect remote deletions: target rows for this binding whose UID
// is no longer in the remote list. Delete the Appointment locally
// (matches Phase F semantics) and drop the target row.
for uidStr, t := range storedByUID {
if _, ok := remoteByUID[uidStr]; ok {
continue
}
if err := s.appointments.DeleteByCalDAVUID(ctx, uidStr); err != nil {
slog.Warn("CalDAV: pull-delete failed", "uid", uidStr, "binding_id", b.ID, "error", err)
continue
}
if err := s.targets.DeleteByAppointmentAndBinding(ctx, t.AppointmentID, b.ID); err != nil {
slog.Warn("CalDAV: pull-delete target cleanup failed", "uid", uidStr, "binding_id", b.ID, "error", err)
}
pulled++
}
return pulled, nil
}
@@ -515,28 +643,15 @@ func (s *CalDAVService) OnAppointmentUpdated(_ context.Context, userID uuid.UUID
s.fireSync(userID, t, "update")
}
// OnAppointmentDeleted fires a DELETE against every binding that
// previously held a target for this appointment, regardless of current
// scope — the appointment is gone from Paliad and shouldn't survive in
// any user calendar.
func (s *CalDAVService) OnAppointmentDeleted(_ context.Context, userID uuid.UUID, t *models.Appointment) {
if !s.Enabled() {
return
}
go func(uid string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil || cfg == nil || !cfg.Enabled {
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
if err := cli.DeleteEvent(ctx, cfg.CalendarPath, uid); err != nil {
slog.Warn("CalDAV: hook delete failed", "uid", uid, "error", err)
}
}(terminUID(t.ID.String()))
}
func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op string) {
if !s.Enabled() {
return
}
appointmentID := t.ID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -545,18 +660,91 @@ func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op str
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
body := formatAppointment(t)
etag, err := cli.PutEvent(ctx, cfg.CalendarPath, terminUID(t.ID.String()), body)
uid := terminUID(appointmentID.String())
bindings, err := s.bindings.ListEnabled(ctx, userID)
if err != nil {
slog.Warn("CalDAV: hook push failed", "op", op, "id", t.ID, "error", err)
slog.Warn("CalDAV: hook list bindings failed", "user_id", userID, "error", err)
return
}
if err := s.appointments.SetCalDAVMeta(ctx, t.ID, terminUID(t.ID.String()), etag); err != nil {
slog.Warn("CalDAV: hook write meta failed", "id", t.ID, "error", err)
for i := range bindings {
b := &bindings[i]
if err := cli.DeleteEvent(ctx, b.CalendarPath, uid); err != nil {
slog.Warn("CalDAV: hook delete failed", "uid", uid, "binding_id", b.ID, "error", err)
}
if err := s.targets.DeleteByAppointmentAndBinding(ctx, appointmentID, b.ID); err != nil {
slog.Warn("CalDAV: hook target cleanup failed", "uid", uid, "binding_id", b.ID, "error", err)
}
}
}()
}
// fireSync pushes an appointment to every binding whose ForBinding()
// scope includes it. Best-effort per binding, identical 30s-timeout
// background goroutine to the Phase F single-binding hook.
func (s *CalDAVService) fireSync(userID uuid.UUID, t *models.Appointment, op string) {
if !s.Enabled() {
return
}
appointmentID := t.ID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil || cfg == nil || !cfg.Enabled {
return
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
uid := terminUID(appointmentID.String())
body := formatAppointment(t)
bindings, err := s.bindings.ListEnabled(ctx, userID)
if err != nil {
slog.Warn("CalDAV: hook list bindings failed", "op", op, "user_id", userID, "error", err)
return
}
for i := range bindings {
b := &bindings[i]
inScope, err := s.appointmentInBinding(ctx, userID, b, appointmentID)
if err != nil {
slog.Warn("CalDAV: hook scope check failed", "op", op, "binding_id", b.ID, "error", err)
continue
}
if !inScope {
continue
}
etag, err := cli.PutEvent(ctx, b.CalendarPath, uid, body)
if err != nil {
slog.Warn("CalDAV: hook push failed", "op", op, "id", appointmentID, "binding_id", b.ID, "error", err)
continue
}
if err := s.targets.UpsertAfterPush(ctx, appointmentID, b.ID, uid, etag); err != nil {
slog.Warn("CalDAV: hook target write failed", "id", appointmentID, "binding_id", b.ID, "error", err)
}
}
}()
}
// appointmentInBinding returns true when the binding's ForBinding()
// scope contains the given appointment. Implemented by reusing
// ForBinding so the rules stay in one place; for personal/project
// bindings the result set is small so the linear scan is fine.
func (s *CalDAVService) appointmentInBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding, appointmentID uuid.UUID) (bool, error) {
appointments, err := s.appointments.ForBinding(ctx, userID, b)
if err != nil {
if errors.Is(err, ErrUnsupportedScope) {
return false, nil
}
return false, err
}
for i := range appointments {
if appointments[i].ID == appointmentID {
return true, nil
}
}
return false, nil
}
// --- Internal helpers ---
type decryptedConfig struct {
@@ -606,17 +794,6 @@ func (s *CalDAVService) decryptedPassword(ctx context.Context, userID uuid.UUID)
return cfg.Password, nil
}
func (s *CalDAVService) listEnabledConfigs(ctx context.Context) ([]models.UserCalDAVConfig, error) {
var rows []models.UserCalDAVConfig
if err := s.db.SelectContext(ctx, &rows,
`SELECT user_id, url, username, password_encrypted, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at
FROM paliad.user_caldav_config WHERE enabled = true`); err != nil {
return nil, err
}
return rows, nil
}
// userIDFromCtx is used by TestConnection. The handler stores the userID
// in the context; we look it up here to avoid changing the function
// signature on every caller. Defined as a private hook so tests can

View File

@@ -0,0 +1,126 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// AppointmentTargetService — CRUD on paliad.appointment_caldav_targets.
//
// Each row is the per-(appointment, binding) push state: the caldav_uid
// PUT into that binding's calendar (canonical per Appointment) plus the
// ETag returned by the server on the last successful PUT. Replaces the
// scalar paliad.appointments.caldav_uid / caldav_etag columns for the
// post-Slice-2a sync engine; those scalars stay populated for back-compat
// through Slice 4.
type AppointmentTargetService struct {
db *sqlx.DB
}
func NewAppointmentTargetService(db *sqlx.DB) *AppointmentTargetService {
return &AppointmentTargetService{db: db}
}
const targetColumns = `appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at`
// UpsertAfterPush records the result of a successful PUT to the binding's
// calendar. Called by CalDAVService.pushAll after each PUT.
func (s *AppointmentTargetService) UpsertAfterPush(ctx context.Context, appointmentID, bindingID uuid.UUID, uid, etag string) error {
var etagPtr *string
if etag != "" {
etagPtr = &etag
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO paliad.appointment_caldav_targets
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (appointment_id, binding_id)
DO UPDATE SET caldav_uid = EXCLUDED.caldav_uid,
caldav_etag = EXCLUDED.caldav_etag,
last_pushed_at = NOW()`,
appointmentID, bindingID, uid, etagPtr)
if err != nil {
return fmt.Errorf("upsert caldav target: %w", err)
}
return nil
}
// FindByUIDAndBinding returns the target row matching this (uid, binding)
// pair, or nil when no such row exists.
func (s *AppointmentTargetService) FindByUIDAndBinding(ctx context.Context, uid string, bindingID uuid.UUID) (*models.AppointmentCalDAVTarget, error) {
var t models.AppointmentCalDAVTarget
err := s.db.GetContext(ctx, &t,
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE caldav_uid = $1 AND binding_id = $2`, uid, bindingID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("find target by uid+binding: %w", err)
}
return &t, nil
}
// ListForBinding returns every target row attached to this binding.
// Used by the pull-reconciliation pass to detect remote deletions.
func (s *AppointmentTargetService) ListForBinding(ctx context.Context, bindingID uuid.UUID) ([]models.AppointmentCalDAVTarget, error) {
rows := []models.AppointmentCalDAVTarget{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE binding_id = $1`, bindingID); err != nil {
return nil, fmt.Errorf("list targets for binding: %w", err)
}
return rows, nil
}
// DeleteByAppointmentAndBinding removes one specific target row.
// Used after a successful remote DELETE.
func (s *AppointmentTargetService) DeleteByAppointmentAndBinding(ctx context.Context, appointmentID, bindingID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.appointment_caldav_targets
WHERE appointment_id = $1 AND binding_id = $2`, appointmentID, bindingID)
if err != nil {
return fmt.Errorf("delete target: %w", err)
}
return nil
}
// StaleForBinding returns target rows whose appointment_id is no longer
// in the in-scope set. Used by the post-pull cleanup pass to delete
// appointments that left the binding's scope (e.g. project unshared,
// scope_kind PATCHed). currentAppointmentIDs may be empty — in that
// case every target row is considered stale.
func (s *AppointmentTargetService) StaleForBinding(ctx context.Context, bindingID uuid.UUID, currentAppointmentIDs []uuid.UUID) ([]models.AppointmentCalDAVTarget, error) {
rows := []models.AppointmentCalDAVTarget{}
if len(currentAppointmentIDs) == 0 {
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE binding_id = $1`, bindingID); err != nil {
return nil, fmt.Errorf("stale-targets all: %w", err)
}
return rows, nil
}
query, args, err := sqlx.In(
`SELECT `+targetColumns+`
FROM paliad.appointment_caldav_targets
WHERE binding_id = ?
AND appointment_id NOT IN (?)`, bindingID, currentAppointmentIDs)
if err != nil {
return nil, fmt.Errorf("stale-targets prepare: %w", err)
}
query = s.db.Rebind(query)
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("stale-targets exec: %w", err)
}
return rows, nil
}