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.
127 lines
4.6 KiB
Go
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
|
|
}
|