Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
labels.
- RLS owner-only (mirrors paliad.user_views from t-144).
LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}
DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).
CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.
ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.
Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).
Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.
Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
title-row-first invariant, hidden leading allowed, dup-key rejection,
unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
+ idempotent, first Create auto-becomes default, SetDefault clears
prior, Delete refuses active default, Delete non-default works,
duplicate name rejected, Update round-trips layout JSON.
go build / vet / test (short) clean.
Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
package services
|
|
|
|
// LayoutSpec — JSON shape for paliad.user_card_layouts.layout_json.
|
|
//
|
|
// Design: docs/design-projects-page-2026-05-07.md §5b.3.
|
|
//
|
|
// Validation surface (server-side):
|
|
// - Every fact key must be in KnownFactKeys.
|
|
// - Each key appears at most once (no duplicates).
|
|
// - "title-row" must be the first visible fact (always-on, structural).
|
|
// - count is bounded [1, 5] when set (only meaningful for next-events /
|
|
// recent-verlauf).
|
|
// - density ∈ {compact, roomy}.
|
|
// - gridColumns ∈ {auto, 2, 3, 4}.
|
|
//
|
|
// JSON shape mirrors the TypeScript type in
|
|
// frontend/src/client/projects-cards-types.ts.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
// FactKey enumerates the cards facts the user can show / hide / reorder.
|
|
type FactKey string
|
|
|
|
const (
|
|
FactTitleRow FactKey = "title-row"
|
|
FactTypeChip FactKey = "type-chip"
|
|
FactStatusChip FactKey = "status-chip"
|
|
FactClientMatter FactKey = "client-matter"
|
|
FactParentPath FactKey = "parent-path"
|
|
FactDeadlineCounts FactKey = "deadline-counts"
|
|
FactNextEvents FactKey = "next-events"
|
|
FactRecentVerlauf FactKey = "recent-verlauf"
|
|
FactTeamChips FactKey = "team-chips"
|
|
FactReference FactKey = "reference"
|
|
FactLastActivityAt FactKey = "last-activity-at"
|
|
)
|
|
|
|
// KnownFactKeys is the registry. Adding a new fact = add a const above
|
|
// AND append here. Frontend has its own mirror in projects-cards.ts.
|
|
var KnownFactKeys = []FactKey{
|
|
FactTitleRow,
|
|
FactTypeChip,
|
|
FactStatusChip,
|
|
FactClientMatter,
|
|
FactParentPath,
|
|
FactDeadlineCounts,
|
|
FactNextEvents,
|
|
FactRecentVerlauf,
|
|
FactTeamChips,
|
|
FactReference,
|
|
FactLastActivityAt,
|
|
}
|
|
|
|
// CardDensity controls per-card padding + line-height (Kompakt / Geräumig).
|
|
// Distinct type from t-paliad-144's ListDensity (which only ranges over
|
|
// {comfortable, compact} and applies to the views.list render shape).
|
|
type CardDensity string
|
|
|
|
const (
|
|
CardDensityCompact CardDensity = "compact"
|
|
CardDensityRoomy CardDensity = "roomy"
|
|
)
|
|
|
|
// GridColumns controls the responsive grid. "auto" lets the browser
|
|
// fit-as-many-as-possible at minmax(280px, 1fr); 2/3/4 force fixed columns.
|
|
type GridColumns string
|
|
|
|
const (
|
|
GridAuto GridColumns = "auto"
|
|
GridTwo GridColumns = "2"
|
|
GridThree GridColumns = "3"
|
|
GridFour GridColumns = "4"
|
|
)
|
|
|
|
// LayoutFact is a single fact entry in the ordered facts[] array.
|
|
type LayoutFact struct {
|
|
Key FactKey `json:"key"`
|
|
Visible bool `json:"visible"`
|
|
// Count is meaningful for next-events and recent-verlauf only. nil for
|
|
// every other key. Bounded [1, 5] when set; default 3 (the seed value).
|
|
Count *int `json:"count,omitempty"`
|
|
}
|
|
|
|
// LayoutSpec is the persisted card-layout shape.
|
|
type LayoutSpec struct {
|
|
Facts []LayoutFact `json:"facts"`
|
|
Density CardDensity `json:"density"`
|
|
GridColumns GridColumns `json:"grid_columns"`
|
|
ShowAllLevels bool `json:"show_all_levels"`
|
|
}
|
|
|
|
// DefaultLayoutSpec returns the seed "Standard" layout per design §5b.4 —
|
|
// rich content set, all 9 facts visible, roomy density, auto grid.
|
|
func DefaultLayoutSpec() LayoutSpec {
|
|
three := 3
|
|
return LayoutSpec{
|
|
Facts: []LayoutFact{
|
|
{Key: FactTitleRow, Visible: true},
|
|
{Key: FactTypeChip, Visible: true},
|
|
{Key: FactStatusChip, Visible: true},
|
|
{Key: FactClientMatter, Visible: true},
|
|
{Key: FactParentPath, Visible: true},
|
|
{Key: FactDeadlineCounts, Visible: true},
|
|
{Key: FactNextEvents, Visible: true, Count: &three},
|
|
{Key: FactRecentVerlauf, Visible: true, Count: &three},
|
|
{Key: FactTeamChips, Visible: true},
|
|
},
|
|
Density: CardDensityRoomy,
|
|
GridColumns: GridAuto,
|
|
ShowAllLevels: false,
|
|
}
|
|
}
|
|
|
|
// Validate enforces the structural invariants. Returns ErrInvalidInput
|
|
// wrapped with a precise message on the first violation.
|
|
func (s LayoutSpec) Validate() error {
|
|
if len(s.Facts) == 0 {
|
|
return fmt.Errorf("%w: layout.facts is empty", ErrInvalidInput)
|
|
}
|
|
|
|
// First visible fact must be title-row.
|
|
firstVisible := -1
|
|
for i, f := range s.Facts {
|
|
if f.Visible {
|
|
firstVisible = i
|
|
break
|
|
}
|
|
}
|
|
if firstVisible == -1 {
|
|
return fmt.Errorf("%w: layout has no visible facts", ErrInvalidInput)
|
|
}
|
|
if s.Facts[firstVisible].Key != FactTitleRow {
|
|
return fmt.Errorf("%w: first visible fact must be %q (got %q)",
|
|
ErrInvalidInput, FactTitleRow, s.Facts[firstVisible].Key)
|
|
}
|
|
|
|
seen := make(map[FactKey]bool, len(s.Facts))
|
|
for i, f := range s.Facts {
|
|
if !slices.Contains(KnownFactKeys, f.Key) {
|
|
return fmt.Errorf("%w: layout.facts[%d].key %q is not a known fact",
|
|
ErrInvalidInput, i, f.Key)
|
|
}
|
|
if seen[f.Key] {
|
|
return fmt.Errorf("%w: layout.facts has duplicate key %q",
|
|
ErrInvalidInput, f.Key)
|
|
}
|
|
seen[f.Key] = true
|
|
if f.Count != nil {
|
|
if f.Key != FactNextEvents && f.Key != FactRecentVerlauf {
|
|
return fmt.Errorf("%w: layout.facts[%d] count is only valid for next-events / recent-verlauf",
|
|
ErrInvalidInput, i)
|
|
}
|
|
if *f.Count < 1 || *f.Count > 5 {
|
|
return fmt.Errorf("%w: layout.facts[%d].count %d out of range [1, 5]",
|
|
ErrInvalidInput, i, *f.Count)
|
|
}
|
|
}
|
|
}
|
|
|
|
switch s.Density {
|
|
case CardDensityCompact, CardDensityRoomy:
|
|
default:
|
|
return fmt.Errorf("%w: layout.density %q invalid", ErrInvalidInput, s.Density)
|
|
}
|
|
|
|
switch s.GridColumns {
|
|
case GridAuto, GridTwo, GridThree, GridFour:
|
|
default:
|
|
return fmt.Errorf("%w: layout.grid_columns %q invalid", ErrInvalidInput, s.GridColumns)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ParseLayoutSpec decodes JSON bytes and validates. Used both by the HTTP
|
|
// handler (request body) and by the service (read-back from the DB column).
|
|
func ParseLayoutSpec(b []byte) (LayoutSpec, error) {
|
|
var s LayoutSpec
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return LayoutSpec{}, fmt.Errorf("%w: layout JSON decode: %v", ErrInvalidInput, err)
|
|
}
|
|
if err := s.Validate(); err != nil {
|
|
return LayoutSpec{}, err
|
|
}
|
|
return s, nil
|
|
}
|