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.
77 lines
3.4 KiB
SQL
77 lines
3.4 KiB
SQL
-- t-paliad-149 PR 2: per-user named card layouts.
|
|
--
|
|
-- Design: docs/design-projects-page-2026-05-07.md §5b.3 (godel,
|
|
-- m-locked 2026-05-07: full drag-rearrange + named layouts).
|
|
--
|
|
-- Stores per-user named card-layout definitions for the /projects Cards
|
|
-- view. A layout is a `(facts[], density, gridColumns, showAllLevels)`
|
|
-- bundle plus a name and the is_default flag.
|
|
--
|
|
-- The very first time a user opens Cards view, the application layer
|
|
-- auto-seeds a "Standard" layout (the rich content set per design §5b.4)
|
|
-- and flips its is_default=true. From there the user can rename, create
|
|
-- new layouts, drag facts around, switch defaults, and delete (except
|
|
-- the active default — UI gates).
|
|
--
|
|
-- RLS scopes every operation to the calling user; layouts are personal
|
|
-- working state (no firm-wide / cross-user visibility v1). Partial unique
|
|
-- index keeps "at most one default per user" honest at the DB level even
|
|
-- if the application layer's tx-flip-default ever races.
|
|
|
|
-- ============================================================================
|
|
-- 1. paliad.user_card_layouts
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE paliad.user_card_layouts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
|
|
|
-- Display name. Free-form; user picks the language they think in.
|
|
-- Renders verbatim in the layout dropdown; no translation.
|
|
name text NOT NULL,
|
|
|
|
-- Exactly one default per user, enforced via partial unique index below.
|
|
-- Application layer flips this in a transaction (clear old, set new).
|
|
is_default boolean NOT NULL DEFAULT false,
|
|
|
|
-- Layout JSON — see internal/services/layout_spec.go LayoutSpec.
|
|
-- Validated on write; jsonb here for forward-compat without migrations
|
|
-- as new fact keys land.
|
|
layout_json jsonb NOT NULL,
|
|
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
|
|
-- Names are unique per user so the layout dropdown can use names as
|
|
-- stable labels and the application layer can return ErrUserCardLayoutNameTaken.
|
|
UNIQUE (user_id, name)
|
|
);
|
|
|
|
-- ============================================================================
|
|
-- 2. Indexes
|
|
-- ============================================================================
|
|
|
|
-- Hot path: list a user's layouts in name order.
|
|
CREATE INDEX user_card_layouts_user_idx
|
|
ON paliad.user_card_layouts (user_id, name);
|
|
|
|
-- Partial unique index: at most one default layout per user. Keeps the
|
|
-- invariant honest even if two concurrent PATCH .../set-default calls land
|
|
-- (the second one's UPDATE will conflict, the application layer retries).
|
|
CREATE UNIQUE INDEX user_card_layouts_default_idx
|
|
ON paliad.user_card_layouts (user_id)
|
|
WHERE is_default = true;
|
|
|
|
-- ============================================================================
|
|
-- 3. RLS
|
|
-- ============================================================================
|
|
|
|
ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Owner-only access. No global_admin override (mirrors paliad.user_views;
|
|
-- card layouts are personal working state, not auditable infrastructure).
|
|
CREATE POLICY user_card_layouts_owner_all
|
|
ON paliad.user_card_layouts FOR ALL
|
|
USING (user_id = auth.uid())
|
|
WITH CHECK (user_id = auth.uid());
|