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 }