package services // DashboardLayoutService is the CRUD layer for paliad.user_dashboard_layouts — // per-user configurable dashboard layout for /dashboard. // // Design: docs/design-dashboard-configurable-2026-05-20.md §5.4. // // Visibility: every read and write is scoped to the calling user via the // RLS policy `user_dashboard_layouts_owner_all` on auth.uid() = user_id. // The service also AND-joins user_id in SQL for defense-in-depth. import ( "context" "database/sql" "encoding/json" "errors" "fmt" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) // DashboardLayoutService manages paliad.user_dashboard_layouts. type DashboardLayoutService struct { db *sqlx.DB } // NewDashboardLayoutService wires the service. func NewDashboardLayoutService(db *sqlx.DB) *DashboardLayoutService { return &DashboardLayoutService{db: db} } // GetOrSeed returns the caller's saved layout. On first call for a user // (no row), it inserts and returns the factory default. The seed is // idempotent — concurrent first-loads converge to the same row via the // ON CONFLICT DO NOTHING clause. // // The returned spec has SanitizeForRead applied; if any entries were // dropped (catalog shrank) the cleaned spec is also persisted back so the // next write doesn't trip on stale entries. func (s *DashboardLayoutService) GetOrSeed(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) { spec, found, err := s.fetch(ctx, userID) if err != nil { return DashboardLayoutSpec{}, err } if !found { return s.seedFactoryDefault(ctx, userID) } if spec.SanitizeForRead() { // Best-effort cleanup; on failure we still return the in-memory // sanitized spec — the user sees a clean dashboard either way. _ = s.upsert(ctx, userID, spec) } return spec, nil } // Update validates the spec and UPSERTs it. Returns the persisted spec // (round-tripped through the DB to confirm storage). func (s *DashboardLayoutService) Update(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) (DashboardLayoutSpec, error) { if err := spec.Validate(); err != nil { return DashboardLayoutSpec{}, err } if err := s.upsert(ctx, userID, spec); err != nil { return DashboardLayoutSpec{}, err } out, found, err := s.fetch(ctx, userID) if err != nil { return DashboardLayoutSpec{}, err } if !found { return DashboardLayoutSpec{}, fmt.Errorf("dashboard layout vanished after upsert for user %s", userID) } return out, nil } // ResetToDefault overwrites the user's layout with the factory default. func (s *DashboardLayoutService) ResetToDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) { def := FactoryDefaultLayout() if err := s.upsert(ctx, userID, def); err != nil { return DashboardLayoutSpec{}, err } return def, nil } // fetch returns (spec, found, err). found=false means the user has no row // yet — the seed path takes over. func (s *DashboardLayoutService) fetch(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, bool, error) { var raw json.RawMessage err := s.db.GetContext(ctx, &raw, ` SELECT layout_json FROM paliad.user_dashboard_layouts WHERE user_id = $1 `, userID) if errors.Is(err, sql.ErrNoRows) { return DashboardLayoutSpec{}, false, nil } if err != nil { return DashboardLayoutSpec{}, false, fmt.Errorf("fetch dashboard layout: %w", err) } var spec DashboardLayoutSpec if err := json.Unmarshal(raw, &spec); err != nil { // Stored row is unparseable — treat as a missing row, the seed // path will overwrite it. Log via the returned error wrapper. return DashboardLayoutSpec{}, false, fmt.Errorf("dashboard layout JSON decode for user %s: %w", userID, err) } return spec, true, nil } // seedFactoryDefault inserts the factory layout for a brand-new user. // ON CONFLICT DO NOTHING handles the race where two concurrent first // loads both miss the SELECT and both try to insert. func (s *DashboardLayoutService) seedFactoryDefault(ctx context.Context, userID uuid.UUID) (DashboardLayoutSpec, error) { def := FactoryDefaultLayout() bytes, err := json.Marshal(def) if err != nil { return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout marshal: %w", err) } if _, err := s.db.ExecContext(ctx, ` INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json) VALUES ($1, $2) ON CONFLICT (user_id) DO NOTHING `, userID, json.RawMessage(bytes)); err != nil { return DashboardLayoutSpec{}, fmt.Errorf("seed dashboard layout insert: %w", err) } // Re-fetch in case ON CONFLICT DO NOTHING let another writer's row win; // either way the user now has a row. out, found, err := s.fetch(ctx, userID) if err != nil { return DashboardLayoutSpec{}, err } if !found { // Extremely unlikely — would mean the row vanished between // INSERT and SELECT. Return the factory default in-memory. return def, nil } return out, nil } // upsert overwrites the layout. updated_at gets bumped on conflict so // callers can observe write recency. func (s *DashboardLayoutService) upsert(ctx context.Context, userID uuid.UUID, spec DashboardLayoutSpec) error { bytes, err := json.Marshal(spec) if err != nil { return fmt.Errorf("dashboard layout marshal: %w", err) } _, err = s.db.ExecContext(ctx, ` INSERT INTO paliad.user_dashboard_layouts (user_id, layout_json) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET layout_json = EXCLUDED.layout_json, updated_at = now() `, userID, json.RawMessage(bytes)) if err != nil { return fmt.Errorf("dashboard layout upsert: %w", err) } return nil }