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.
208 lines
5.6 KiB
Go
208 lines
5.6 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"
|
|
)
|
|
|
|
// AllShapes lists every supported shape. Used by the validator and by
|
|
// the in-page shape switcher.
|
|
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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).
|
|
type ListConfig struct {
|
|
Columns []string `json:"columns,omitempty"`
|
|
Sort SortOrder `json:"sort,omitempty"`
|
|
Density ListDensity `json:"density,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"
|
|
)
|
|
|
|
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:
|
|
// 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
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|