Files
paliad/internal/services/binding_service.go
mAi 694c7a53ad feat(caldav): Slice 2a backend cut-over — bindings-driven sync (t-paliad-212)
Cuts the CalDAVService sync engine over from the Phase F scalar
calendar_path to the binding-row model introduced in Slice 1
(mig 101). Invisible-but-shippable: existing Phase F users keep
their backfilled all_visible binding, new users hitting the legacy
PUT /api/caldav-config get an auto-created all_visible binding so
the "configure → it just works" UX survives. Slice 2b adds the
picker UI and write APIs on top.

Schema (mig 107)
- paliad.caldav_sync_log.binding_id (nullable, FK ON DELETE SET NULL
  so audit history survives binding deletes).
- Per-binding index for the read path.
- Idempotent (column-exists DO block) + assertion.

Services
- CalendarBindingService: ListForUser, ListEnabled, ListAllEnabled,
  Get, Create, Update, Delete, SetSyncStatus. Mirrors the table
  CHECK constraints client-side so the API returns useful 400s.
- AppointmentTargetService: UpsertAfterPush, FindByUIDAndBinding,
  ListForBinding, DeleteByAppointmentAndBinding, StaleForBinding.
  Replaces SetCalDAVMeta as the authoritative source of per-target
  state; legacy scalar columns still written for back-compat.
- AppointmentService.ForBinding: scope filter implementing
  all_visible, personal_only, project. Hierarchy scopes
  (client/litigation/patent/case) return ErrUnsupportedScope —
  Slice 3 wires them via the existing path-based descendant
  predicate.

Sync engine rewrite
- CalDAVService.Start iterates ListAllEnabled to discover users
  with at least one enabled binding.
- runSyncOnce loops bindings, writes one caldav_sync_log row per
  (user, binding) tick, rolls the worst-case error up onto
  user_caldav_config.last_sync_error so /api/caldav-config still
  shows aggregate status.
- pushBinding pushes the ForBinding() slice + cleans up
  stale-target rows (project unshared, scope PATCHed).
- pullBinding swaps the N×GET pattern for REPORT calendar-multiget
  (RFC 4791 §7.9; chunked at 100 hrefs to stay inside provider rate
  limits) and reconciles via per-target etag comparison.
- Hooks (OnAppointmentCreated/Updated/Deleted) fan out across the
  user's matching bindings using appointmentInBinding() — best
  effort per binding, same 30s timeout as Phase F.
- SaveConfig auto-creates an all_visible binding on first-time
  configure so Phase F "configure → events appear" survives the
  cut-over.

CalDAV client
- New ReportMultiget verb implementing RFC 4791 §7.9
  calendar-multiget. Chunked at multigetMaxHrefs=100 to fit Google
  Calendar's per-request cap.

HTTP API
- GET /api/caldav-bindings — read-only list of the authenticated
  user's bindings. Slice 2b adds POST/PATCH/DELETE.

Verification
- BEGIN..ROLLBACK against live Supabase (PG 15.8): mig 107 applies
  cleanly + the synthetic two-binding scenario lands the project
  appointment in both bindings while keeping the personal one in
  master only; cascade on appointment-delete drops targets; cascade
  on binding-delete drops targets AND sets sync_log.binding_id NULL.
- go build ./..., go test ./internal/..., bun run build all clean.

Backwards-compat
- paliad.appointments.caldav_uid / caldav_etag still written in
  pushBinding so legacy readers see fresh values. Slice 4 drops
  them after telemetry confirms no path still reads them.
2026-05-20 13:05:27 +02:00

266 lines
9.1 KiB
Go

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
}