Files
paliad/internal/services/system_views.go
mAi 4ead2d08c1 feat(inbox): t-paliad-249 Slice A backend — project_event feed + read cursor (m/paliad#80)
Substrate changes that turn /inbox from approvals-only into the
unified notification surface m asked for.

- Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor;
  pending approval_requests bypass it per design §3).
- KnownProjectEventKinds gains note_created, our_side_changed,
  deadline_updated/deleted, deadlines_imported. New
  InboxProjectEventKinds curated subset (head's Q1=A lock).
- InboxSystemView spans [approval_request, project_event]; defaults to
  past 30 days, newest first, row_action="inbox".
- view_service.allowedProjectEventKinds drops *_approval_* audits when
  ApprovalRequest is also in spec.Sources (no double-count).
- RunSpec resolves the caller's inbox_seen_at once and threads it
  through viewSpecBounds; runProjectEvents excludes self-authored
  events and rows older than the cursor when unread_only is set.
  Decided approval_requests follow the cursor; pending always survives.
- ApprovalService.UnseenInboxCountForUser (unified badge count) +
  MarkInboxSeen + InboxSeenAt service methods.
- GET /api/inbox/count returns the unified count; new
  POST /api/inbox/mark-all-seen advances the cursor (optional up_to=).

Tests cover the InboxSystemView shape, the audit-dedup helper, the
isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
2026-05-25 15:49:39 +02:00

230 lines
7.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.
//
// 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: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"},
}},
SourceProjectEvent: {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: map[DataSource]Predicates{
SourceApprovalRequest: {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)
}