Files
paliad/internal/services/render_spec.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

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
}