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.
315 lines
10 KiB
Go
315 lines
10 KiB
Go
package services
|
|
|
|
// RenderSpec is the structured render description that drives how a view
|
|
// is presented. Same shape lives in paliad.user_views.render_spec (jsonb)
|
|
// and is composed by the frontend's view editor.
|
|
//
|
|
// Design: docs/design-data-display-model-2026-05-06.md §4 (Q4-Q5).
|
|
//
|
|
// Q4 lock-in (m, 2026-05-07): three shapes — list, cards, calendar.
|
|
// "Activity" is content selection (sources + filters), not visualisation —
|
|
// achieved via shape="list", list.density="compact", list.columns=
|
|
// ["time","actor","title","project"]. Same component, different config.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
// RenderShape identifies which presentation component a view uses.
|
|
type RenderShape string
|
|
|
|
const (
|
|
ShapeList RenderShape = "list"
|
|
ShapeCards RenderShape = "cards"
|
|
ShapeCalendar RenderShape = "calendar"
|
|
// ShapeTimeline (t-paliad-177 Slice 4, faraday-Q7): cross-project
|
|
// horizontal chart rendered by frontend/src/client/views/shape-
|
|
// timeline-cv.ts on top of the same SVG renderer that powers
|
|
// /projects/{id}/chart. Lane axis = project_id. Adapter is lossy:
|
|
// ProjectionService projected rows are NOT surfaced (ViewService
|
|
// doesn't run the calculator). UI tooltip on first open documents
|
|
// the limitation.
|
|
ShapeTimeline RenderShape = "timeline"
|
|
)
|
|
|
|
// AllShapes lists every supported shape. Used by the validator and by
|
|
// the in-page shape switcher.
|
|
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
|
|
|
|
// RenderSpec is the top-level render description.
|
|
//
|
|
// Per-shape config blocks are kept on save even when a different shape
|
|
// is selected, so flipping back to a previously-used shape preserves
|
|
// its tweaks (Q5 design decision).
|
|
type RenderSpec struct {
|
|
Shape RenderShape `json:"shape"`
|
|
List *ListConfig `json:"list,omitempty"`
|
|
Cards *CardsConfig `json:"cards,omitempty"`
|
|
Calendar *CalendarConfig `json:"calendar,omitempty"`
|
|
Timeline *TimelineConfig `json:"timeline,omitempty"`
|
|
}
|
|
|
|
// TimelineConfig is the per-shape config for shape=timeline. Mirrors the
|
|
// URL-state knobs of the standalone /projects/{id}/chart page: a saved
|
|
// CV-timeline view bakes the user's chosen palette / density / range
|
|
// preset into render_spec so reopening the view restores the same
|
|
// visual. None are required — empty defaults match the standalone
|
|
// chart's defaults (default palette, standard density, 1y range).
|
|
type TimelineConfig struct {
|
|
Palette string `json:"palette,omitempty"`
|
|
Density string `json:"density,omitempty"`
|
|
RangePreset string `json:"range_preset,omitempty"`
|
|
RangeFrom string `json:"range_from,omitempty"`
|
|
RangeTo string `json:"range_to,omitempty"`
|
|
}
|
|
|
|
// ListConfig is the per-shape config for shape=list. Powers both the
|
|
// /events table look (density=comfortable) and the activity-feed look
|
|
// (density=compact + actor/time columns).
|
|
//
|
|
// RowAction tells shape-list which row interaction to wire when the
|
|
// universal <FilterBar> renders the table. "navigate" (the default and
|
|
// the contract for the existing /agenda/dashboard surfaces) routes a
|
|
// row click to a per-kind detail page. "complete_toggle" is the
|
|
// /events deadline-row pattern (checkbox + reopen button). "approve"
|
|
// is the /inbox approver row (approve/reject buttons + revoke). "none"
|
|
// is read-only (audit views, retrospective lists).
|
|
//
|
|
// shape-list.ts honours this when emitting the table's `entity-table`
|
|
// classes — `entity-table--readonly` plus `none` skips the navigate
|
|
// handler entirely.
|
|
type ListConfig struct {
|
|
Columns []string `json:"columns,omitempty"`
|
|
Sort SortOrder `json:"sort,omitempty"`
|
|
Density ListDensity `json:"density,omitempty"`
|
|
RowAction ListRowAction `json:"row_action,omitempty"`
|
|
}
|
|
|
|
// CardsConfig is the per-shape config for shape=cards.
|
|
type CardsConfig struct {
|
|
GroupBy CardsGroupBy `json:"group_by,omitempty"`
|
|
Sort SortOrder `json:"sort,omitempty"`
|
|
ShowEmptyDays bool `json:"show_empty_days,omitempty"`
|
|
}
|
|
|
|
// CalendarConfig is the per-shape config for shape=calendar.
|
|
type CalendarConfig struct {
|
|
DefaultView CalendarView `json:"default_view,omitempty"`
|
|
ShowWeekends bool `json:"show_weekends,omitempty"`
|
|
}
|
|
|
|
type SortOrder string
|
|
|
|
const (
|
|
SortDateAsc SortOrder = "date_asc"
|
|
SortDateDesc SortOrder = "date_desc"
|
|
)
|
|
|
|
type ListDensity string
|
|
|
|
const (
|
|
DensityComfortable ListDensity = "comfortable"
|
|
DensityCompact ListDensity = "compact"
|
|
)
|
|
|
|
// ListRowAction identifies which row interaction the list-shape renderer
|
|
// should wire. Defaults to RowActionNavigate when empty so existing
|
|
// SystemView definitions and saved user views continue to render rows
|
|
// that route to the per-kind detail page.
|
|
type ListRowAction string
|
|
|
|
const (
|
|
RowActionNavigate ListRowAction = "navigate"
|
|
RowActionCompleteToggle ListRowAction = "complete_toggle"
|
|
RowActionApprove ListRowAction = "approve"
|
|
RowActionInbox ListRowAction = "inbox"
|
|
RowActionNone ListRowAction = "none"
|
|
)
|
|
|
|
// KnownRowActions is the registry the validator checks against. Adding a
|
|
// new action = add a const above AND append here AND extend
|
|
// shape-list.ts's switch.
|
|
var KnownRowActions = []ListRowAction{
|
|
RowActionNavigate,
|
|
RowActionCompleteToggle,
|
|
RowActionApprove,
|
|
RowActionInbox,
|
|
RowActionNone,
|
|
}
|
|
|
|
type CardsGroupBy string
|
|
|
|
const (
|
|
CardsGroupByDay CardsGroupBy = "day"
|
|
CardsGroupByWeek CardsGroupBy = "week"
|
|
CardsGroupByNone CardsGroupBy = "none"
|
|
)
|
|
|
|
type CalendarView string
|
|
|
|
const (
|
|
CalendarMonth CalendarView = "month"
|
|
CalendarWeek CalendarView = "week"
|
|
)
|
|
|
|
// KnownListColumns is the curated set of column keys the list-shape
|
|
// renderer understands. Validator rejects anything else so a typo in
|
|
// the editor surfaces immediately.
|
|
var KnownListColumns = []string{
|
|
"date", "time", "title", "project", "actor", "status",
|
|
"rule", "event_type", "location", "appointment_type",
|
|
"approval_status", "decided_by", "kind",
|
|
}
|
|
|
|
// Validate runs the full RenderSpec contract.
|
|
func (s *RenderSpec) Validate() error {
|
|
if s == nil {
|
|
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
|
|
}
|
|
switch s.Shape {
|
|
case ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline:
|
|
// fine
|
|
default:
|
|
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
|
|
}
|
|
|
|
if s.List != nil {
|
|
if err := s.List.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if s.Cards != nil {
|
|
if err := s.Cards.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if s.Calendar != nil {
|
|
if err := s.Calendar.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if s.Timeline != nil {
|
|
if err := s.Timeline.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// KnownTimelinePalettes / Densities / Ranges mirror the frontend enums
|
|
// in shape-timeline-chart.ts. Anything outside this set is rejected so
|
|
// a stray value from an old build / hostile editor can't sneak into
|
|
// stored render_spec rows.
|
|
var (
|
|
knownTimelinePalettes = []string{
|
|
"default", "kind-coded", "track-coded", "high-contrast", "print",
|
|
}
|
|
knownTimelineDensities = []string{
|
|
"compact", "standard", "spacious",
|
|
}
|
|
knownTimelineRanges = []string{
|
|
"1y", "2y", "all", "custom",
|
|
}
|
|
)
|
|
|
|
func (c *TimelineConfig) validate() error {
|
|
if c.Palette != "" && !slices.Contains(knownTimelinePalettes, c.Palette) {
|
|
return fmt.Errorf("%w: unknown timeline.palette %q", ErrInvalidInput, c.Palette)
|
|
}
|
|
if c.Density != "" && !slices.Contains(knownTimelineDensities, c.Density) {
|
|
return fmt.Errorf("%w: unknown timeline.density %q", ErrInvalidInput, c.Density)
|
|
}
|
|
if c.RangePreset != "" && !slices.Contains(knownTimelineRanges, c.RangePreset) {
|
|
return fmt.Errorf("%w: unknown timeline.range_preset %q", ErrInvalidInput, c.RangePreset)
|
|
}
|
|
// RangeFrom / RangeTo are free-form ISO dates — the frontend regex-
|
|
// checks them; here we only verify they're plain ASCII length-bounded
|
|
// so a giant string can't bloat the jsonb column.
|
|
if len(c.RangeFrom) > 32 {
|
|
return fmt.Errorf("%w: timeline.range_from too long", ErrInvalidInput)
|
|
}
|
|
if len(c.RangeTo) > 32 {
|
|
return fmt.Errorf("%w: timeline.range_to too long", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *ListConfig) validate() error {
|
|
for _, col := range c.Columns {
|
|
if !slices.Contains(KnownListColumns, col) {
|
|
return fmt.Errorf("%w: unknown list.columns value %q", ErrInvalidInput, col)
|
|
}
|
|
}
|
|
switch c.Sort {
|
|
case "", SortDateAsc, SortDateDesc:
|
|
default:
|
|
return fmt.Errorf("%w: unknown list.sort %q", ErrInvalidInput, c.Sort)
|
|
}
|
|
switch c.Density {
|
|
case "", DensityComfortable, DensityCompact:
|
|
default:
|
|
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
|
|
}
|
|
if c.RowAction != "" && !slices.Contains(KnownRowActions, c.RowAction) {
|
|
return fmt.Errorf("%w: unknown list.row_action %q", ErrInvalidInput, c.RowAction)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *CardsConfig) validate() error {
|
|
switch c.GroupBy {
|
|
case "", CardsGroupByDay, CardsGroupByWeek, CardsGroupByNone:
|
|
default:
|
|
return fmt.Errorf("%w: unknown cards.group_by %q", ErrInvalidInput, c.GroupBy)
|
|
}
|
|
switch c.Sort {
|
|
case "", SortDateAsc, SortDateDesc:
|
|
default:
|
|
return fmt.Errorf("%w: unknown cards.sort %q", ErrInvalidInput, c.Sort)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *CalendarConfig) validate() error {
|
|
switch c.DefaultView {
|
|
case "", CalendarMonth, CalendarWeek:
|
|
default:
|
|
return fmt.Errorf("%w: unknown calendar.default_view %q", ErrInvalidInput, c.DefaultView)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultRenderSpec returns a minimal valid spec — used as the seed when
|
|
// the editor opens with a blank canvas.
|
|
func DefaultRenderSpec() RenderSpec {
|
|
return RenderSpec{
|
|
Shape: ShapeList,
|
|
List: &ListConfig{
|
|
Sort: SortDateAsc,
|
|
Density: DensityComfortable,
|
|
},
|
|
}
|
|
}
|
|
|
|
// MarshalRenderSpec validates + encodes for storage.
|
|
func MarshalRenderSpec(s RenderSpec) ([]byte, error) {
|
|
if err := s.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(s)
|
|
}
|
|
|
|
// UnmarshalRenderSpec parses + validates.
|
|
func UnmarshalRenderSpec(b []byte) (RenderSpec, error) {
|
|
var s RenderSpec
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return RenderSpec{}, fmt.Errorf("%w: render_spec malformed: %v", ErrInvalidInput, err)
|
|
}
|
|
if err := s.Validate(); err != nil {
|
|
return RenderSpec{}, err
|
|
}
|
|
return s, nil
|
|
}
|