Files
paliad/internal/services/system_views.go
m b516201110 feat(t-paliad-144 A1): backend substrate + Custom Views API
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.
2026-05-07 12:51:37 +02:00

209 lines
6.6 KiB
Go

package services
// SystemView is a code-resident view definition. The four system pages
// (dashboard / agenda / events / inbox) resolve to one of these when
// they want to consume the substrate as if they were a Custom View.
//
// Design: docs/design-data-display-model-2026-05-06.md §5 Q8.
//
// Q8 lock-in: defaults are config-as-code, not seeded rows in
// paliad.user_views. Their slugs are reserved (validator rejects
// matching user-view slugs).
import (
"slices"
)
// SystemView is the in-process projection used by the substrate's
// SystemView callers. It mirrors the persisted user-view shape but
// never round-trips through the DB.
type SystemView struct {
Slug string // matches the system-page URL ("/dashboard" → "dashboard")
Name string // display label (kept English here; UI re-translates via i18n)
Filter FilterSpec // canonical filter the page resolves to today
Render RenderSpec // canonical render shape
}
// DashboardSystemView returns the SystemView definition for /dashboard.
//
// Note: /dashboard is composed of multiple sections (5-bucket summary +
// matter card + two-column lists + activity feed). It does NOT resolve
// to a single FilterSpec/RenderSpec — Phase B will compose several
// SystemView resolutions into the dashboard page. This entry exists so
// the slug is known to the reserved-list and so future composition has
// a stable hook.
func DashboardSystemView() SystemView {
return SystemView{
Slug: "dashboard",
Name: "Dashboard",
// Placeholder filter — the dashboard composes multiple queries
// in Phase B; this single spec covers the activity feed only.
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldCreatedAt},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityCompact,
Sort: SortDateDesc,
Columns: []string{"time", "actor", "title", "project"},
},
},
}
}
// AgendaSystemView returns the SystemView definition for /agenda — a
// day-grouped feed of upcoming deadlines + appointments.
func AgendaSystemView() SystemView {
return SystemView{
Slug: "agenda",
Name: "Agenda",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
},
},
Render: RenderSpec{
Shape: ShapeCards,
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
},
}
}
// EventsSystemView returns the SystemView definition for /events — the
// table view over deadlines + appointments. The legacy URL keeps a
// per-type chip toggle; this SystemView reflects the "all" tab default.
func EventsSystemView() SystemView {
return SystemView{
Slug: "events",
Name: "Events",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxSystemView returns the SystemView definition for /inbox — the
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
func InboxRequesterSystemView() SystemView {
return SystemView{
Slug: "inbox-mine",
Name: "Inbox (mine)",
Filter: FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "self_requested",
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// AllSystemViews returns every system-defined view in registration order.
// Used by the reserved-slug list and by future Phase B composition.
func AllSystemViews() []SystemView {
return []SystemView{
DashboardSystemView(),
AgendaSystemView(),
EventsSystemView(),
InboxSystemView(),
InboxRequesterSystemView(),
}
}
// reservedUserViewSlugs is the static list of slugs the user-view CRUD
// rejects on create / update. Includes the SystemView slugs plus URLs
// the application owns at the top level (admin, settings, login, …).
//
// Q23 lock-in (m, 2026-05-07): list as drafted.
var reservedUserViewSlugs = []string{
// SystemView slugs:
"dashboard", "agenda", "events", "inbox", "inbox-mine",
// /views/* routes:
"new", "edit",
// Top-level application URLs:
"tools", "admin", "settings", "login", "logout",
"projects", "team", "courts", "glossary", "links",
"downloads", "checklists", "views", "changelog",
}
// IsReservedUserViewSlug returns true if `slug` matches a reserved slug.
// User-view CRUD rejects matches with ErrInvalidInput. Case-folded so
// "Dashboard" is also rejected.
func IsReservedUserViewSlug(slug string) bool {
return slices.Contains(reservedUserViewSlugs, foldSlug(slug))
}
// foldSlug normalises a slug for reserved-list comparison. Slugs are
// already lowercased + dash-only by the validator before this is called,
// but this lets IsReservedUserViewSlug be safe under direct calls.
func foldSlug(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
out = append(out, c+('a'-'A'))
default:
out = append(out, c)
}
}
return string(out)
}