Files
paliad/internal/services/target_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

127 lines
4.6 KiB
Go

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
}