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.
266 lines
9.1 KiB
Go
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
|
|
}
|
|
|