Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.
Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (c85c382) lets any
embedded migration apply regardless of authoring order.
What ships:
- paliad.user_dashboard_layouts table: single-row PK on user_id (Q2 pick
was single layout per user — no named-layout switcher). RLS owner-only,
mirrors user_card_layouts / user_views patterns.
- DashboardLayoutSpec: { v: 1, widgets: [{ key, visible, settings? }] }.
Validation is strict on write (catalog membership + per-widget settings
schema, duplicate-key check, 32-widget cap, version pin). SanitizeForRead
is forgiving — unknown keys dropped silently per design §10 versioning
rule.
- DashboardLayoutService: GetOrSeed (auto-seeds factory default on first
call, idempotent under concurrent first-load via ON CONFLICT DO NOTHING),
Update (validates + upserts), ResetToDefault.
- WidgetCatalog: 7 v1 widget defs (deadline-summary, matter-summary,
upcoming-deadlines, upcoming-appointments, inline-agenda, recent-activity,
inbox-approvals). Per-widget WidgetSettingsSchema with CountOptions +
HorizonOptions per design §18 Note B. pinned-projects const reserved
but omitted from KnownWidgetKeys until Slice C lands its widget module.
- 18 pure-function tests pin: factory layout shape, validation failures
(wrong version / over cap / unknown key / duplicate / bad settings),
sanitize-on-read (drop unknown / noop on clean / bump version), JSON
round-trip, catalog completeness, nil-schema behaviour.
- 4 live-DB tests (skipped without TEST_DATABASE_URL): GetOrSeed
auto-seeds + idempotent, Update round-trips, Update rejects invalid,
ResetToDefault overwrites.
Migration SQL dry-run live in BEGIN..ROLLBACK against supabase — clean.
go build + go test ./internal/services/ -short both clean.
Slice C0 (pin-machinery) from the design doc is OBSOLETE — paliad
.user_pinned_projects + PinService already exist (pre-dates t-paliad-219).
Slice C in the original plan becomes a single PR adding the
pinned-projects widget module that reads from the existing service.
Design: docs/design-dashboard-configurable-2026-05-20.md §5 + §18.
158 lines
5.4 KiB
Go
158 lines
5.4 KiB
Go
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
|
|
}
|