Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only; no user-visible change in A1. A2 (frontend) lands separately. What's new: - Migration 056: paliad.user_views table with RLS scoped to caller (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE (user_id, slug). No is_system flag — system defaults stay code- resident per Q8 lock-in. - internal/services/filter_spec.go (+test): structured FilterSpec with Sources / Scope / Time / Predicates. Server-side validator rejects unknown sources, duplicate sources, conflicting scope modes, horizon=all without explicit projects (Q26 clamp), and every per-source enum (deadline.status, appointment_types, project_event kinds, approval_request status / viewer_role). - internal/services/render_spec.go (+test): RenderSpec with three shapes (list / cards / calendar — Q4 lock-in 2026-05-07). Per-shape config kept separately so flipping shapes preserves tweaks. Validator over column / sort / density / group_by / default_view enums. - internal/services/system_views.go (+test): code-resident SystemView definitions for dashboard / agenda / events / inbox / inbox-mine. Reserved-slug list (Q23) prevents user-views from colliding with top-level URLs. Case-folded matching. - internal/services/view_service.go: extends EventService with RunSpec — runs a FilterSpec across all four substrate sources (deadline + appointment + project_event + approval_request) and merges into []ViewRow sorted by event_date. ViewRow is a discriminated projection (kind + common header + per-source Detail json.RawMessage). Q17 fail-open attribution: returns inaccessible_project_ids for explicit-scope queries where the caller can't see some IDs. - internal/services/user_view_service.go (+test): CRUD on paliad.user_views — Create (server-assigns sort_order MAX+1 in tx), GetBySlug, GetByID, Update (partial), Delete, Touch (last_used_at), MostRecent. Reserved-slug + slug-format validators on every write. - internal/handlers/views.go: nine HTTP handlers wiring the endpoints (GET/POST/PATCH/DELETE /api/user-views/..., POST /api/user-views/{id}/touch, POST /api/views/run, POST /api/views/{slug}/run, GET /api/views/system). - main.go + handlers.go + projects.go: wire UserViewService into the bundle; conditional route registration when both UserView + Event services are present. Pure-Go tests (no DB): 32 cases pass — filter spec validators, render spec validators, system view registry, reserved slugs. Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases covering create / list / get / uniqueness / update / delete / touch / most-recent / reserved-slug / bad-slug / empty-name / invalid-spec. Coexists with t-139 (in-flight on noether's other branch) and t-138 (shipped) without coordination commits — RunSpec uses the existing visibility predicate that t-139's migration 055 will extend with derivation. Approval-request source delegates to ApprovalService.ListPendingForApprover / ListSubmittedByUser (both already extended for derived_peer authority in t-139 Phase 3). Files: 15 changed, 3134 insertions. Build clean. Tests green.
78 lines
3.0 KiB
SQL
78 lines
3.0 KiB
SQL
-- t-paliad-144 Phase A1: Custom Views — paliad.user_views.
|
|
--
|
|
-- Design: docs/design-data-display-model-2026-05-06.md (noether,
|
|
-- m-locked 2026-05-07).
|
|
--
|
|
-- Stores per-user saved view definitions. A view is a `(filter_spec,
|
|
-- render_spec, sidebar metadata)` tuple. RLS scopes every operation
|
|
-- to the calling user — there is no cross-user visibility in v1.
|
|
--
|
|
-- System defaults (dashboard / agenda / events / inbox) stay code-
|
|
-- resident in internal/services/system_views.go. They never appear
|
|
-- as rows in this table; the slugs are reserved and rejected at write
|
|
-- time by the application layer.
|
|
--
|
|
-- Sections:
|
|
-- 1. CREATE paliad.user_views (with RLS).
|
|
-- 2. Indexes.
|
|
|
|
-- ============================================================================
|
|
-- 1. paliad.user_views
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE paliad.user_views (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
|
|
|
-- Stable user-facing identifier. Goes into the URL.
|
|
-- Application-layer validator enforces ^[a-z0-9][a-z0-9-]{0,62}$ +
|
|
-- a reserved-list rejection (dashboard, agenda, events, inbox, …).
|
|
slug text NOT NULL,
|
|
|
|
-- Display name. Free-form; user picks the language they think in.
|
|
-- Rendered verbatim in the sidebar; no fallback or translation.
|
|
name text NOT NULL,
|
|
|
|
-- One of a fixed set of icon keys (see Sidebar.tsx icon registry).
|
|
-- NULL → default icon (folder). Validator caps length to keep the
|
|
-- column sane even if the registry is bypassed.
|
|
icon text,
|
|
|
|
-- Filter spec — see internal/services/filter_spec.go FilterSpec.
|
|
-- Validated on write; jsonb here for forward-compat without
|
|
-- migrations as new dimensions land.
|
|
filter_spec jsonb NOT NULL,
|
|
|
|
-- Render spec — see internal/services/render_spec.go RenderSpec.
|
|
render_spec jsonb NOT NULL,
|
|
|
|
-- Sidebar ordering. Lower-first. New views land at MAX+1 server-side
|
|
-- so they sort to the bottom; the editor lets users drag-reorder.
|
|
sort_order integer NOT NULL DEFAULT 0,
|
|
|
|
-- Show a row-count badge on the sidebar entry. Costs one COUNT(*)
|
|
-- per refresh; opt-in (default false) so casual users don't pay.
|
|
show_count boolean NOT NULL DEFAULT false,
|
|
|
|
-- Most-recently-used landing on /views (Q10). Updated by a fire-
|
|
-- and-forget PATCH on every view-load.
|
|
last_used_at timestamptz,
|
|
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
|
|
UNIQUE (user_id, slug)
|
|
);
|
|
|
|
CREATE INDEX user_views_owner_idx
|
|
ON paliad.user_views (user_id, sort_order);
|
|
|
|
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Owner-only access. No global_admin override: views are personal
|
|
-- working state, not auditable infrastructure.
|
|
CREATE POLICY user_views_owner_all
|
|
ON paliad.user_views FOR ALL
|
|
USING (user_id = auth.uid())
|
|
WITH CHECK (user_id = auth.uid());
|