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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
53
internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
Normal file
53
internal/db/migrations/107_caldav_sync_log_binding_id.up.sql
Normal 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 $$;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
265
internal/services/binding_service.go
Normal file
265
internal/services/binding_service.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
126
internal/services/target_service.go
Normal file
126
internal/services/target_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user