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.
177 lines
5.5 KiB
Go
177 lines
5.5 KiB
Go
package services
|
|
|
|
// DashboardLayoutSpec — JSON shape for paliad.user_dashboard_layouts.layout_json.
|
|
//
|
|
// Design: docs/design-dashboard-configurable-2026-05-20.md §5.2.
|
|
//
|
|
// Validation surface:
|
|
// - version must be 1 (v0 / unknown versions seed the factory default at
|
|
// read time; the validator only ever sees writes from a current client).
|
|
// - widgets is at most 32 entries (sanity cap; catalog can grow but a
|
|
// single user's layout shouldn't).
|
|
// - each widget.key must be in KnownWidgetKeys on WRITE.
|
|
// - no duplicate keys.
|
|
// - each widget.settings (if present) is validated against its catalog
|
|
// entry's WidgetSettingsSchema.
|
|
//
|
|
// On READ, unknown keys are dropped silently — see SanitizeForRead.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
// LayoutSpecVersion is the only supported version for v1.
|
|
const LayoutSpecVersion = 1
|
|
|
|
// LayoutWidgetCap is the sanity cap on widgets per layout. The v1 catalog
|
|
// has 7 entries; 32 leaves room for catalog growth without unbounded JSON
|
|
// blobs.
|
|
const LayoutWidgetCap = 32
|
|
|
|
// DashboardWidgetRef is a single widget entry in the ordered widgets[] array.
|
|
// Visible=false entries are kept in the array so the picker can show them as
|
|
// "hidden" and re-adding restores their position.
|
|
type DashboardWidgetRef struct {
|
|
Key WidgetKey `json:"key"`
|
|
Visible bool `json:"visible"`
|
|
Settings json.RawMessage `json:"settings,omitempty"`
|
|
}
|
|
|
|
// DashboardLayoutSpec is the persisted layout shape.
|
|
type DashboardLayoutSpec struct {
|
|
Version int `json:"v"`
|
|
Widgets []DashboardWidgetRef `json:"widgets"`
|
|
}
|
|
|
|
// FactoryDefaultLayout returns the Slice A1 baseline layout — every
|
|
// widget in KnownWidgetKeys, visible, in canonical order, with per-widget
|
|
// default settings drawn from the catalog. A user with no row sees this
|
|
// on first load and is byte-identical to today's dashboard plus the new
|
|
// inbox-approvals widget.
|
|
func FactoryDefaultLayout() DashboardLayoutSpec {
|
|
catalog := WidgetCatalog()
|
|
byKey := make(map[WidgetKey]WidgetDef, len(catalog))
|
|
for _, def := range catalog {
|
|
byKey[def.Key] = def
|
|
}
|
|
|
|
widgets := make([]DashboardWidgetRef, 0, len(KnownWidgetKeys))
|
|
for _, k := range KnownWidgetKeys {
|
|
def, ok := byKey[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
ref := DashboardWidgetRef{Key: k, Visible: def.DefaultVisible}
|
|
if settings := defaultSettingsJSON(def); settings != nil {
|
|
ref.Settings = settings
|
|
}
|
|
widgets = append(widgets, ref)
|
|
}
|
|
|
|
return DashboardLayoutSpec{
|
|
Version: LayoutSpecVersion,
|
|
Widgets: widgets,
|
|
}
|
|
}
|
|
|
|
// defaultSettingsJSON encodes the per-widget defaults declared on the
|
|
// catalog entry. Returns nil when the widget has no settings.
|
|
func defaultSettingsJSON(def WidgetDef) json.RawMessage {
|
|
if def.DefaultCount == nil && def.DefaultHorizon == nil {
|
|
return nil
|
|
}
|
|
out := map[string]int{}
|
|
if def.DefaultCount != nil {
|
|
out["count"] = *def.DefaultCount
|
|
}
|
|
if def.DefaultHorizon != nil {
|
|
out["horizon_days"] = *def.DefaultHorizon
|
|
}
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Validate enforces the structural invariants on write. Returns
|
|
// ErrInvalidInput wrapped with a precise message on the first violation.
|
|
func (s DashboardLayoutSpec) Validate() error {
|
|
if s.Version != LayoutSpecVersion {
|
|
return fmt.Errorf("%w: layout version %d not supported (want %d)",
|
|
ErrInvalidInput, s.Version, LayoutSpecVersion)
|
|
}
|
|
if len(s.Widgets) > LayoutWidgetCap {
|
|
return fmt.Errorf("%w: layout has %d widgets (cap %d)",
|
|
ErrInvalidInput, len(s.Widgets), LayoutWidgetCap)
|
|
}
|
|
|
|
seen := make(map[WidgetKey]bool, len(s.Widgets))
|
|
for i, w := range s.Widgets {
|
|
if !slices.Contains(KnownWidgetKeys, w.Key) {
|
|
return fmt.Errorf("%w: widgets[%d].key %q is not a known widget",
|
|
ErrInvalidInput, i, w.Key)
|
|
}
|
|
if seen[w.Key] {
|
|
return fmt.Errorf("%w: widgets has duplicate key %q",
|
|
ErrInvalidInput, w.Key)
|
|
}
|
|
seen[w.Key] = true
|
|
|
|
def, ok := LookupWidgetDef(w.Key)
|
|
if !ok {
|
|
// Defense in depth — KnownWidgetKeys was checked above.
|
|
return fmt.Errorf("%w: widgets[%d].key %q has no catalog entry",
|
|
ErrInvalidInput, i, w.Key)
|
|
}
|
|
if err := def.Settings.Validate(w.Settings); err != nil {
|
|
return fmt.Errorf("widgets[%d]: %w", i, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SanitizeForRead applies the forgiving read-path rules: drop entries whose
|
|
// keys are not in the catalog (catalog has shrunk) and bump the version to
|
|
// the current one if missing. Settings on surviving entries pass through
|
|
// unchanged — invalid settings on read are not worth aborting over and the
|
|
// next write will reject them anyway.
|
|
//
|
|
// Returns true if anything was changed; callers can use that to decide
|
|
// whether to PUT the cleaned spec back.
|
|
func (s *DashboardLayoutSpec) SanitizeForRead() bool {
|
|
changed := false
|
|
if s.Version != LayoutSpecVersion {
|
|
s.Version = LayoutSpecVersion
|
|
changed = true
|
|
}
|
|
if len(s.Widgets) == 0 {
|
|
return changed
|
|
}
|
|
out := make([]DashboardWidgetRef, 0, len(s.Widgets))
|
|
for _, w := range s.Widgets {
|
|
if _, ok := LookupWidgetDef(w.Key); !ok {
|
|
changed = true
|
|
continue
|
|
}
|
|
out = append(out, w)
|
|
}
|
|
s.Widgets = out
|
|
return changed
|
|
}
|
|
|
|
// ParseDashboardLayoutSpec decodes JSON bytes and validates. Used by the
|
|
// HTTP handler on incoming request bodies.
|
|
func ParseDashboardLayoutSpec(b []byte) (DashboardLayoutSpec, error) {
|
|
var s DashboardLayoutSpec
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return DashboardLayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
|
|
}
|
|
if err := s.Validate(); err != nil {
|
|
return DashboardLayoutSpec{}, err
|
|
}
|
|
return s, nil
|
|
}
|