The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.
The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.
Fix: align Go to the flat shape the frontend already emits.
- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
approvalStatusMatches + allowed* helpers + system_views literals
+ tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
guard.
- Frontend FilterSpec.predicates type tightened from
`Partial<Record<DataSource, Predicates>>` (which silently allowed
the wrong runtime write) to `Predicates`.
Regression coverage:
- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
the bar's exact wire payload unmarshals into a non-nil per-source
predicate; marshalling a Go-constructed spec produces the same flat
shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
cases for /views/any (every axis the saved view's sources expose).
Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.
No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
230 lines
7.5 KiB
Go
230 lines
7.5 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: &Predicates{
|
|
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.
|
|
//
|
|
// t-paliad-249 (Slice A, 2026-05-25) widened the inbox from
|
|
// approval-requests-only to a project-events feed PLUS approval
|
|
// requests. Sources is [ApprovalRequest, ProjectEvent]; the project
|
|
// rail is narrowed to InboxProjectEventKinds (curated set, head pick
|
|
// Q1=A). The `*_approval_*` audit events are de-duplicated against
|
|
// the approval_request rows by view_service.allowedProjectEventKinds.
|
|
//
|
|
// Time window defaults to last 30 days; the bar's time-axis chip
|
|
// can widen or narrow. Sort is newest-first — different from the
|
|
// pre-249 ascending default; m's inbox metaphor is "what just
|
|
// happened", not "what's coming up".
|
|
//
|
|
// RowAction = RowActionInbox → shape-list.ts dispatches per
|
|
// row.kind: approval rows get the approve/reject/revoke layout,
|
|
// project_event rows get a navigate-style stream row.
|
|
func InboxSystemView() SystemView {
|
|
return SystemView{
|
|
Slug: "inbox",
|
|
Name: "Inbox",
|
|
Filter: FilterSpec{
|
|
Version: SpecVersion,
|
|
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
|
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
|
|
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
|
|
Predicates: &Predicates{
|
|
ApprovalRequest: &ApprovalRequestPredicates{
|
|
ViewerRole: "any_visible",
|
|
Status: []string{"pending"},
|
|
},
|
|
ProjectEvent: &ProjectEventPredicates{
|
|
EventTypes: InboxProjectEventKinds,
|
|
},
|
|
},
|
|
},
|
|
Render: RenderSpec{
|
|
Shape: ShapeList,
|
|
List: &ListConfig{
|
|
Density: DensityComfortable,
|
|
Sort: SortDateDesc,
|
|
RowAction: RowActionInbox,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// InboxRequesterSystemView is the "Eigene Anfragen" sibling view of
|
|
// /inbox. Reachable via the bar's approval_viewer_role chip ("Eigene
|
|
// Anfragen") on the /inbox surface, or as its own URL on /views/inbox-mine.
|
|
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: &Predicates{
|
|
ApprovalRequest: &ApprovalRequestPredicates{
|
|
ViewerRole: "self_requested",
|
|
},
|
|
},
|
|
},
|
|
Render: RenderSpec{
|
|
Shape: ShapeList,
|
|
List: &ListConfig{
|
|
Density: DensityComfortable,
|
|
Sort: SortDateAsc,
|
|
RowAction: RowActionApprove,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|