Files
paliad/internal/services/dashboard_layout_service.go
mAi a421bff856 feat(dashboard): t-paliad-219 Slice A1 — user_dashboard_layouts storage + service
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.
2026-05-20 13:55:56 +02:00

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
}