feat(t-paliad-144 A1): backend substrate + Custom Views API

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.
This commit is contained in:
m
2026-05-07 12:51:37 +02:00
parent 956ff10e4d
commit b516201110
15 changed files with 3134 additions and 0 deletions

View File

@@ -159,6 +159,7 @@ func main() {
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
Approval: services.NewApprovalService(pool, users),
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
UserView: services.NewUserViewService(pool),
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).

View File

@@ -0,0 +1,3 @@
-- Reverse of 056_user_views.up.sql.
DROP TABLE IF EXISTS paliad.user_views;

View File

@@ -0,0 +1,77 @@
-- t-paliad-144 Phase A1: Custom Views — paliad.user_views.
--
-- Design: docs/design-data-display-model-2026-05-06.md (noether,
-- m-locked 2026-05-07).
--
-- Stores per-user saved view definitions. A view is a `(filter_spec,
-- render_spec, sidebar metadata)` tuple. RLS scopes every operation
-- to the calling user — there is no cross-user visibility in v1.
--
-- System defaults (dashboard / agenda / events / inbox) stay code-
-- resident in internal/services/system_views.go. They never appear
-- as rows in this table; the slugs are reserved and rejected at write
-- time by the application layer.
--
-- Sections:
-- 1. CREATE paliad.user_views (with RLS).
-- 2. Indexes.
-- ============================================================================
-- 1. paliad.user_views
-- ============================================================================
CREATE TABLE paliad.user_views (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
-- Stable user-facing identifier. Goes into the URL.
-- Application-layer validator enforces ^[a-z0-9][a-z0-9-]{0,62}$ +
-- a reserved-list rejection (dashboard, agenda, events, inbox, …).
slug text NOT NULL,
-- Display name. Free-form; user picks the language they think in.
-- Rendered verbatim in the sidebar; no fallback or translation.
name text NOT NULL,
-- One of a fixed set of icon keys (see Sidebar.tsx icon registry).
-- NULL → default icon (folder). Validator caps length to keep the
-- column sane even if the registry is bypassed.
icon text,
-- Filter spec — see internal/services/filter_spec.go FilterSpec.
-- Validated on write; jsonb here for forward-compat without
-- migrations as new dimensions land.
filter_spec jsonb NOT NULL,
-- Render spec — see internal/services/render_spec.go RenderSpec.
render_spec jsonb NOT NULL,
-- Sidebar ordering. Lower-first. New views land at MAX+1 server-side
-- so they sort to the bottom; the editor lets users drag-reorder.
sort_order integer NOT NULL DEFAULT 0,
-- Show a row-count badge on the sidebar entry. Costs one COUNT(*)
-- per refresh; opt-in (default false) so casual users don't pay.
show_count boolean NOT NULL DEFAULT false,
-- Most-recently-used landing on /views (Q10). Updated by a fire-
-- and-forget PATCH on every view-load.
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, slug)
);
CREATE INDEX user_views_owner_idx
ON paliad.user_views (user_id, sort_order);
ALTER TABLE paliad.user_views ENABLE ROW LEVEL SECURITY;
-- Owner-only access. No global_admin override: views are personal
-- working state, not auditable infrastructure.
CREATE POLICY user_views_owner_all
ON paliad.user_views FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -64,6 +64,7 @@ type Services struct {
Courts *services.CourtService
Approval *services.ApprovalService
Derivation *services.DerivationService
UserView *services.UserViewService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -100,6 +101,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
courts: svc.Courts,
approval: svc.Approval,
derivation: svc.Derivation,
userView: svc.UserView,
}
}
@@ -403,6 +405,22 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
}
// t-paliad-144 Phase A1 — Custom Views API (substrate + user_views CRUD).
// No user-visible page surface in A1; the frontend lands in A2. Endpoints
// are present so curl-smoke + tests can exercise the full contract.
if svc != nil && svc.UserView != nil && svc.Event != nil {
protected.HandleFunc("GET /api/user-views", handleListUserViews)
protected.HandleFunc("POST /api/user-views", handleCreateUserView)
protected.HandleFunc("GET /api/user-views/{id}", handleGetUserView)
protected.HandleFunc("PATCH /api/user-views/{id}", handleUpdateUserView)
protected.HandleFunc("DELETE /api/user-views/{id}", handleDeleteUserView)
protected.HandleFunc("POST /api/user-views/{id}/touch", handleTouchUserView)
protected.HandleFunc("POST /api/views/run", handleRunAdhocView)
protected.HandleFunc("POST /api/views/{slug}/run", handleRunSavedView)
protected.HandleFunc("GET /api/views/system", handleListSystemViews)
}
// Catch-all 404 — runs for any authenticated path that no more-specific
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from
// tests/smoke-auth-2026-04-25.md). Must be registered last on this mux.

View File

@@ -44,6 +44,7 @@ type dbServices struct {
courts *services.CourtService
approval *services.ApprovalService
derivation *services.DerivationService
userView *services.UserViewService
}
var dbSvc *dbServices

393
internal/handlers/views.go Normal file
View File

@@ -0,0 +1,393 @@
package handlers
// HTTP handlers for the Custom Views feature (t-paliad-144 Phase A1).
//
// Endpoints:
// GET /api/user-views — list saved views
// POST /api/user-views — create
// GET /api/user-views/{id} — fetch one
// PATCH /api/user-views/{id} — partial update
// DELETE /api/user-views/{id} — delete
// POST /api/user-views/{id}/touch — bump last_used_at
//
// POST /api/views/run — run an ad-hoc spec
// POST /api/views/{slug}/run — run a saved view by slug
// GET /api/views/system — list system view definitions
//
// All endpoints require authentication. Paliad's RLS scopes user_views
// rows to auth.uid(); the handler layer also AND-joins userID for
// defense-in-depth.
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// requireUserViews returns true when the user-view + substrate services
// are wired. Calls writeJSON 503 + returns false otherwise.
func requireUserViews(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.userView == nil || dbSvc.event == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "views not configured",
})
return false
}
return true
}
// ============================================================================
// /api/user-views — CRUD
// ============================================================================
// GET /api/user-views — list the caller's saved views.
func handleListUserViews(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
views, err := dbSvc.userView.ListForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, views)
}
// userViewCreatePayload mirrors services.CreateUserViewInput on the wire.
type userViewCreatePayload struct {
Slug string `json:"slug"`
Name string `json:"name"`
Icon *string `json:"icon,omitempty"`
FilterSpec services.FilterSpec `json:"filter_spec"`
RenderSpec services.RenderSpec `json:"render_spec"`
ShowCount bool `json:"show_count,omitempty"`
}
// POST /api/user-views — create.
func handleCreateUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var p userViewCreatePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
created, err := dbSvc.userView.Create(r.Context(), uid, services.CreateUserViewInput{
Slug: p.Slug,
Name: p.Name,
Icon: p.Icon,
FilterSpec: p.FilterSpec,
RenderSpec: p.RenderSpec,
ShowCount: p.ShowCount,
})
if err != nil {
writeUserViewError(w, err)
return
}
writeJSON(w, http.StatusCreated, created)
}
// GET /api/user-views/{id} — fetch one.
func handleGetUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
view, err := dbSvc.userView.GetByID(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
if view == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, view)
}
// userViewUpdatePayload accepts every field as optional. `null` icon
// clears the field (matching service-side semantic of *string{""} → clear).
type userViewUpdatePayload struct {
Slug *string `json:"slug,omitempty"`
Name *string `json:"name,omitempty"`
Icon *string `json:"icon,omitempty"`
FilterSpec *services.FilterSpec `json:"filter_spec,omitempty"`
RenderSpec *services.RenderSpec `json:"render_spec,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
ShowCount *bool `json:"show_count,omitempty"`
}
// PATCH /api/user-views/{id} — partial update.
func handleUpdateUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var p userViewUpdatePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
updated, err := dbSvc.userView.Update(r.Context(), uid, id, services.UpdateUserViewInput{
Slug: p.Slug,
Name: p.Name,
Icon: p.Icon,
FilterSpec: p.FilterSpec,
RenderSpec: p.RenderSpec,
SortOrder: p.SortOrder,
ShowCount: p.ShowCount,
})
if err != nil {
writeUserViewError(w, err)
return
}
if updated == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, updated)
}
// DELETE /api/user-views/{id} — delete.
func handleDeleteUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
deleted, err := dbSvc.userView.Delete(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
if !deleted {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// POST /api/user-views/{id}/touch — bump last_used_at. Fire-and-forget
// from the page handler (Q10 most-recently-used landing).
func handleTouchUserView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
if err := dbSvc.userView.Touch(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================================
// /api/views — substrate execution
// ============================================================================
// runRequest wraps the optional spec override for /api/views/{slug}/run.
// When body is empty / fields are zero-valued, the saved spec is used as-is.
type runRequest struct {
Filter *services.FilterSpec `json:"filter,omitempty"`
Render *services.RenderSpec `json:"render,omitempty"` // currently informational; substrate ignores
}
// POST /api/views/run — execute an ad-hoc FilterSpec without persisting.
//
// Used by the editor's live-preview (Q27) and by the inbox/agenda
// system pages internally (Phase B will route them here; Phase A1
// leaves the wiring as a no-op for those pages).
func handleRunAdhocView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var p runRequest
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if p.Filter == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filter is required"})
return
}
if err := p.Filter.Validate(); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, *p.Filter, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// POST /api/views/{slug}/run — run a saved view (or a system view by slug).
//
// Optional body: { filter: <override> } overrides the saved spec for
// this run only (transient — doesn't mutate the stored row). Used for
// query-param overrides in the URL contract (Q16).
func handleRunSavedView(w http.ResponseWriter, r *http.Request) {
if !requireUserViews(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
slug := r.PathValue("slug")
if slug == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug is required"})
return
}
// System view first — code-resident; doesn't need DB read.
if sys := lookupSystemView(slug); sys != nil {
spec := sys.Filter
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
return
}
// User view.
view, err := dbSvc.userView.GetBySlug(r.Context(), uid, slug)
if err != nil {
writeServiceError(w, err)
return
}
if view == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "view not found"})
return
}
spec, err := services.UnmarshalFilterSpec(view.FilterSpec)
if err != nil {
writeServiceError(w, err)
return
}
if err := maybeOverrideSpec(&spec, r.Body); err != nil {
writeServiceError(w, err)
return
}
res, err := dbSvc.event.RunSpec(r.Context(), uid, spec, dbSvc.approval)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
// GET /api/views/system — list system view definitions. Used by the
// editor to seed "start from a system view as a template".
func handleListSystemViews(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
writeJSON(w, http.StatusOK, services.AllSystemViews())
}
// ============================================================================
// helpers
// ============================================================================
// lookupSystemView returns a SystemView whose slug matches, or nil.
func lookupSystemView(slug string) *services.SystemView {
for _, sv := range services.AllSystemViews() {
if sv.Slug == slug {
view := sv
return &view
}
}
return nil
}
// maybeOverrideSpec replaces `spec` with body.filter when the request
// body parses as a runRequest with a non-nil Filter. Empty body / no
// override → no-op. The override is validated.
func maybeOverrideSpec(spec *services.FilterSpec, body io.Reader) error {
var p runRequest
dec := json.NewDecoder(body)
if err := dec.Decode(&p); err != nil {
// Empty body is fine — no override.
return nil
}
if p.Filter == nil {
return nil
}
if err := p.Filter.Validate(); err != nil {
return err
}
*spec = *p.Filter
return nil
}
// writeUserViewError adds slug-taken handling on top of writeServiceError.
func writeUserViewError(w http.ResponseWriter, err error) {
if err == nil {
return
}
if errors.Is(err, services.ErrUserViewSlugTaken) {
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
}

View File

@@ -0,0 +1,393 @@
package services
// FilterSpec is the structured filter description that drives the substrate
// (ViewService.RunSpec). Same shape lives in paliad.user_views.filter_spec
// (jsonb) and is composed by the frontend's view editor.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 Q2.
//
// Validation rules live alongside the type. Every public mutation path
// (POST/PUT /api/user-views, /api/views/{slug}/run with override params)
// runs FilterSpec.Validate() before touching the DB or executing a query.
import (
"encoding/json"
"fmt"
"slices"
"time"
"github.com/google/uuid"
)
// DataSource identifies one of the substrate's source rails.
type DataSource string
const (
SourceDeadline DataSource = "deadline"
SourceAppointment DataSource = "appointment"
SourceProjectEvent DataSource = "project_event"
SourceApprovalRequest DataSource = "approval_request"
)
// AllSources lists every supported source. Used by the validator and by
// the "all sources" UI affordance.
var AllSources = []DataSource{
SourceDeadline,
SourceAppointment,
SourceProjectEvent,
SourceApprovalRequest,
}
// SpecVersion is the on-the-wire schema version. Bumped when the shape
// changes incompatibly. Validator rejects unknown versions so we get a
// clear error instead of silent misinterpretation.
const SpecVersion = 1
// FilterSpec is the top-level filter description.
type FilterSpec struct {
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
// time:
// - "all_visible" (default): no extra narrowing; RLS bounds to projects
// the caller can see (direct + descendant + derived per t-paliad-139).
// - "my_subtree": narrows to the caller's direct/descendant/derived
// staffing tree only. Equivalent to "all_visible" today (visibility
// == subtree); reserved for future when global_admin or shared
// visibility models add a wider tier.
// - explicit slice: exact project IDs. RLS still applies — IDs the
// caller can't see contribute zero rows (Q17 fail-open).
//
// PersonalOnly narrows deadline + appointment to caller-created rows.
// Mutually exclusive with explicit Projects (validator enforces). When
// PersonalOnly is true, project_event + approval_request sources still
// run with the standard visibility predicate — the "personal" framing
// only meaningfully applies to entity rows.
type ScopeSpec struct {
Projects ScopeProjects `json:"projects"`
PersonalOnly bool `json:"personal_only,omitempty"`
}
// ScopeProjects is the project-narrowing payload. Either a sentinel string
// ("all_visible" / "my_subtree") or an explicit []uuid.UUID.
//
// Encoded as a tagged shape on the wire so we don't have to teach a custom
// JSON unmarshaller a polymorphic field:
//
// {"mode": "all_visible"}
// {"mode": "my_subtree"}
// {"mode": "explicit", "ids": ["uuid", …]}
type ScopeProjects struct {
Mode ScopeMode `json:"mode"`
IDs []uuid.UUID `json:"ids,omitempty"`
}
type ScopeMode string
const (
ScopeAllVisible ScopeMode = "all_visible"
ScopeMySubtree ScopeMode = "my_subtree"
ScopeExplicit ScopeMode = "explicit"
)
// TimeSpec is the time-window narrowing. Horizon picks a relative window;
// when Horizon == "custom", From/To are honoured as an absolute window.
//
// Field selects which date column to filter against per source:
// - "auto" (default): each source picks — deadline → due_date,
// appointment → start_at, project_event → created_at,
// approval_request → requested_at.
// - "created_at": every source uses its own created_at column. Useful
// for "newly added events" views.
type TimeSpec struct {
Horizon TimeHorizon `json:"horizon"`
Field TimeField `json:"field,omitempty"`
From *time.Time `json:"from,omitempty"`
To *time.Time `json:"to,omitempty"`
}
type TimeHorizon string
const (
HorizonNext7d TimeHorizon = "next_7d"
HorizonNext30d TimeHorizon = "next_30d"
HorizonNext90d TimeHorizon = "next_90d"
HorizonPast30d TimeHorizon = "past_30d"
HorizonPast90d TimeHorizon = "past_90d"
HorizonAny TimeHorizon = "any"
HorizonAll TimeHorizon = "all"
HorizonCustom TimeHorizon = "custom"
)
type TimeField string
const (
FieldAuto TimeField = "auto"
FieldCreatedAt TimeField = "created_at"
)
// Predicates is the per-source narrowing payload. Empty fields mean
// "no narrowing" — never "exclude all".
type Predicates struct {
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
ProjectEvent *ProjectEventPredicates `json:"project_event,omitempty"`
ApprovalRequest *ApprovalRequestPredicates `json:"approval_request,omitempty"`
}
// DeadlinePredicates narrows the deadline rail.
type DeadlinePredicates struct {
Status []string `json:"status,omitempty"` // "pending" | "completed"
ApprovalStatus []string `json:"approval_status,omitempty"` // "approved" | "pending" | "legacy"
EventTypeIDs []uuid.UUID `json:"event_types,omitempty"`
IncludeUntyped bool `json:"include_untyped,omitempty"`
}
// AppointmentPredicates narrows the appointment rail.
type AppointmentPredicates struct {
ApprovalStatus []string `json:"approval_status,omitempty"`
AppointmentTypes []string `json:"appointment_types,omitempty"` // hearing/meeting/consultation/deadline_hearing
}
// ProjectEventPredicates narrows the audit/Verlauf rail. EventTypes is the
// curated list of project-event kinds (project_created, status_changed,
// deadline_created, …) — see KnownProjectEventKinds.
type ProjectEventPredicates struct {
EventTypes []string `json:"event_types,omitempty"`
}
// ApprovalRequestPredicates narrows the approval-inbox rail.
//
// ViewerRole shapes the per-row visibility:
// - "approver_eligible": rows where the caller is qualified to approve
// (the t-paliad-138 inbox path, extended by t-paliad-139 derivation).
// - "self_requested": rows the caller submitted.
// - "any_visible": every approval_request the caller can see via RLS
// (includes both of the above plus already-decided rows on visible
// projects). Used for retrospective/audit views.
type ApprovalRequestPredicates struct {
ViewerRole string `json:"viewer_role,omitempty"`
Status []string `json:"status,omitempty"` // "pending" | "approved" | "rejected" | "revoked"
EntityTypes []string `json:"entity_types,omitempty"` // "deadline" | "appointment"
}
// KnownProjectEventKinds is the curated set of project_event.event_type
// values the substrate exposes via the filter UI. Code-resident per
// design Q19.
//
// Mirrors the strings emitted by insertProjectEvent / insertProjectEventWithMeta
// in internal/services/*. New kinds appear here as they're added in code.
var KnownProjectEventKinds = []string{
"project_created",
"project_archived",
"project_reparented",
"project_type_changed",
"status_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
}
// validApprovalStatuses are the legal values for entity-side approval_status
// filters and request-side status filters respectively.
var (
validEntityApprovalStatuses = []string{"approved", "pending", "legacy"}
validRequestStatuses = []string{"pending", "approved", "rejected", "revoked"}
validApprovalEntityTypes = []string{"deadline", "appointment"}
validApprovalViewerRoles = []string{"approver_eligible", "self_requested", "any_visible"}
validDeadlineStatuses = []string{"pending", "completed"}
validAppointmentTypes = []string{"hearing", "meeting", "consultation", "deadline_hearing"}
)
// Validate runs the full FilterSpec contract. Returns a wrapped
// ErrInvalidInput on the first violation.
//
// The validator is the single gate that protects the DB from malformed
// jsonb. Both POST /api/user-views and runtime-override params on
// GET /api/views/{slug}/run go through it.
func (s *FilterSpec) Validate() error {
if s == nil {
return fmt.Errorf("%w: filter_spec is required", ErrInvalidInput)
}
if s.Version != SpecVersion {
return fmt.Errorf("%w: filter_spec version %d not supported (expected %d)", ErrInvalidInput, s.Version, SpecVersion)
}
if len(s.Sources) == 0 {
return fmt.Errorf("%w: at least one source must be selected", ErrInvalidInput)
}
seen := make(map[DataSource]bool, len(s.Sources))
for _, src := range s.Sources {
if !isKnownSource(src) {
return fmt.Errorf("%w: unknown source %q", ErrInvalidInput, src)
}
if seen[src] {
return fmt.Errorf("%w: duplicate source %q", ErrInvalidInput, src)
}
seen[src] = true
}
if err := s.Scope.validate(); err != nil {
return err
}
if err := s.Time.validate(s.Scope); err != nil {
return err
}
for src, preds := range s.Predicates {
if !isKnownSource(src) {
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
}
if !seen[src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
}
if err := preds.validate(); err != nil {
return err
}
}
return nil
}
func (s *ScopeSpec) validate() error {
switch s.Projects.Mode {
case ScopeAllVisible, ScopeMySubtree:
if len(s.Projects.IDs) > 0 {
return fmt.Errorf("%w: scope.projects.ids must be empty when mode=%q", ErrInvalidInput, s.Projects.Mode)
}
case ScopeExplicit:
if len(s.Projects.IDs) == 0 {
return fmt.Errorf("%w: scope.projects.ids must be non-empty when mode=%q", ErrInvalidInput, ScopeExplicit)
}
if s.PersonalOnly {
return fmt.Errorf("%w: scope.personal_only cannot be combined with explicit projects", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: unknown scope.projects.mode %q", ErrInvalidInput, s.Projects.Mode)
}
return nil
}
func (t *TimeSpec) validate(scope ScopeSpec) error {
switch t.Horizon {
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
HorizonPast30d, HorizonPast90d, HorizonAny:
// fine
case HorizonAll:
// Q26: reject "all" unless scope.projects is explicit. Performance
// safeguard — an unbounded substrate query across every visible
// project is the worst case and we want it gated by intent.
if scope.Projects.Mode != ScopeExplicit {
return fmt.Errorf("%w: time.horizon=%q requires scope.projects.mode=%q", ErrInvalidInput, HorizonAll, ScopeExplicit)
}
case HorizonCustom:
if t.From == nil || t.To == nil {
return fmt.Errorf("%w: time.horizon=%q requires both from and to", ErrInvalidInput, HorizonCustom)
}
if !t.To.After(*t.From) {
return fmt.Errorf("%w: time.to must be strictly after time.from", ErrInvalidInput)
}
default:
return fmt.Errorf("%w: unknown time.horizon %q", ErrInvalidInput, t.Horizon)
}
switch t.Field {
case "", FieldAuto, FieldCreatedAt:
// fine
default:
return fmt.Errorf("%w: unknown time.field %q", ErrInvalidInput, t.Field)
}
return nil
}
func (p *Predicates) validate() error {
if p.Deadline != nil {
if err := validateStringEnum("deadline.status", p.Deadline.Status, validDeadlineStatuses); err != nil {
return err
}
if err := validateStringEnum("deadline.approval_status", p.Deadline.ApprovalStatus, validEntityApprovalStatuses); err != nil {
return err
}
}
if p.Appointment != nil {
if err := validateStringEnum("appointment.approval_status", p.Appointment.ApprovalStatus, validEntityApprovalStatuses); err != nil {
return err
}
if err := validateStringEnum("appointment.appointment_types", p.Appointment.AppointmentTypes, validAppointmentTypes); err != nil {
return err
}
}
if p.ProjectEvent != nil {
if err := validateStringEnum("project_event.event_types", p.ProjectEvent.EventTypes, KnownProjectEventKinds); err != nil {
return err
}
}
if p.ApprovalRequest != nil {
if r := p.ApprovalRequest.ViewerRole; r != "" {
if err := validateStringEnum("approval_request.viewer_role", []string{r}, validApprovalViewerRoles); err != nil {
return err
}
}
if err := validateStringEnum("approval_request.status", p.ApprovalRequest.Status, validRequestStatuses); err != nil {
return err
}
if err := validateStringEnum("approval_request.entity_types", p.ApprovalRequest.EntityTypes, validApprovalEntityTypes); err != nil {
return err
}
}
return nil
}
func validateStringEnum(field string, values, allowed []string) error {
for _, v := range values {
if !slices.Contains(allowed, v) {
return fmt.Errorf("%w: unknown %s value %q", ErrInvalidInput, field, v)
}
}
return nil
}
func isKnownSource(s DataSource) bool {
return slices.Contains(AllSources, s)
}
// DefaultFilterSpec returns a minimal valid spec — used as the seed when
// the editor opens with a blank canvas.
func DefaultFilterSpec() FilterSpec {
return FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
}
}
// MarshalFilterSpec is a convenience wrapper used by handlers when storing
// the spec into the jsonb column. Pre-validates so a malformed spec never
// reaches the DB.
func MarshalFilterSpec(s FilterSpec) ([]byte, error) {
if err := s.Validate(); err != nil {
return nil, err
}
return json.Marshal(s)
}
// UnmarshalFilterSpec parses + validates a stored / submitted spec.
func UnmarshalFilterSpec(b []byte) (FilterSpec, error) {
var s FilterSpec
if err := json.Unmarshal(b, &s); err != nil {
return FilterSpec{}, fmt.Errorf("%w: filter_spec malformed: %v", ErrInvalidInput, err)
}
if err := s.Validate(); err != nil {
return FilterSpec{}, err
}
return s, nil
}

View File

@@ -0,0 +1,263 @@
package services
// Pure-Go tests for FilterSpec — no DB touch. Cover happy path, every
// reject branch, and the cross-field constraints (Q26 horizon-clamp,
// scope mode/IDs invariants).
import (
"errors"
"testing"
"time"
"github.com/google/uuid"
)
func validBaseSpec() FilterSpec {
return FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
}
}
func TestFilterSpec_HappyPath(t *testing.T) {
s := validBaseSpec()
if err := s.Validate(); err != nil {
t.Fatalf("base spec should validate: %v", err)
}
}
func TestFilterSpec_DefaultIsValid(t *testing.T) {
s := DefaultFilterSpec()
if err := s.Validate(); err != nil {
t.Fatalf("DefaultFilterSpec must validate: %v", err)
}
}
func TestFilterSpec_Version(t *testing.T) {
s := validBaseSpec()
s.Version = 2
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown version must reject with ErrInvalidInput, got %v", err)
}
}
func TestFilterSpec_NoSources(t *testing.T) {
s := validBaseSpec()
s.Sources = nil
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty sources must reject, got %v", err)
}
}
func TestFilterSpec_UnknownSource(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{"bogus"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown source must reject, got %v", err)
}
}
func TestFilterSpec_DuplicateSource(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline, SourceDeadline}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("duplicate source must reject, got %v", err)
}
}
func TestFilterSpec_AllSourcesAccepted(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline, SourceAppointment, SourceProjectEvent, SourceApprovalRequest}
if err := s.Validate(); err != nil {
t.Fatalf("all four sources together must validate: %v", err)
}
}
func TestFilterSpec_ScopeAllVisibleRejectsIDs(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects.IDs = []uuid.UUID{uuid.New()}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("ids on all_visible mode must reject, got %v", err)
}
}
func TestFilterSpec_ScopeExplicitNeedsIDs(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("explicit mode without ids must reject, got %v", err)
}
}
func TestFilterSpec_ScopeExplicitWithIDsValidates(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
if err := s.Validate(); err != nil {
t.Fatalf("explicit mode + ids must validate: %v", err)
}
}
func TestFilterSpec_PersonalOnlyConflictsWithExplicit(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
s.Scope.PersonalOnly = true
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("personal_only + explicit projects must reject, got %v", err)
}
}
func TestFilterSpec_HorizonAllRejectsWithoutExplicit(t *testing.T) {
// Q26 lock-in: horizon=all is rejected unless scope.projects.mode=explicit.
s := validBaseSpec()
s.Time.Horizon = HorizonAll
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("horizon=all without explicit projects must reject, got %v", err)
}
}
func TestFilterSpec_HorizonAllAcceptsWithExplicit(t *testing.T) {
s := validBaseSpec()
s.Scope.Projects = ScopeProjects{Mode: ScopeExplicit, IDs: []uuid.UUID{uuid.New()}}
s.Time.Horizon = HorizonAll
if err := s.Validate(); err != nil {
t.Fatalf("horizon=all with explicit projects must validate: %v", err)
}
}
func TestFilterSpec_HorizonCustomNeedsBothBounds(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("custom horizon without bounds must reject, got %v", err)
}
}
func TestFilterSpec_HorizonCustomRejectsInvertedRange(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
earlier := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// to before from
s.Time.From = &later
s.Time.To = &earlier
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("inverted from/to must reject, got %v", err)
}
}
func TestFilterSpec_HorizonCustomAcceptsValidRange(t *testing.T) {
s := validBaseSpec()
s.Time.Horizon = HorizonCustom
from := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s.Time.From = &from
s.Time.To = &to
if err := s.Validate(); err != nil {
t.Fatalf("valid custom range must accept: %v", err)
}
}
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline}
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("predicates on unselected source must reject, got %v", err)
}
}
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Predicates = map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown deadline.status must reject, got %v", err)
}
}
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = append(s.Sources, SourceAppointment)
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown appointment_type must reject, got %v", err)
}
}
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceProjectEvent}
s.Predicates = map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown project_event kind must reject, got %v", err)
}
}
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown viewer_role must reject, got %v", err)
}
}
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown approval_request.status must reject, got %v", err)
}
}
func TestFilterSpec_RoundTripJSON(t *testing.T) {
original := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline, SourceApprovalRequest},
Scope: ScopeSpec{
Projects: ScopeProjects{Mode: ScopeAllVisible},
PersonalOnly: false,
},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{
Status: []string{"pending"},
ApprovalStatus: []string{"approved", "pending"},
}},
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
}
b, err := MarshalFilterSpec(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := UnmarshalFilterSpec(b)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Version != original.Version {
t.Errorf("version mismatch: %d vs %d", parsed.Version, original.Version)
}
if len(parsed.Sources) != len(original.Sources) {
t.Errorf("sources mismatch: %v vs %v", parsed.Sources, original.Sources)
}
}

View File

@@ -0,0 +1,207 @@
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
}

View File

@@ -0,0 +1,103 @@
package services
// Pure-Go tests for RenderSpec.
import (
"errors"
"testing"
)
func TestRenderSpec_HappyPath(t *testing.T) {
s := DefaultRenderSpec()
if err := s.Validate(); err != nil {
t.Fatalf("default render spec must validate: %v", err)
}
}
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
for _, sh := range cases {
t.Run(string(sh), func(t *testing.T) {
s := RenderSpec{Shape: sh}
if err := s.Validate(); err != nil {
t.Fatalf("shape %q must validate: %v", sh, err)
}
})
}
}
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
s := RenderSpec{Shape: "kanban"}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown shape must reject, got %v", err)
}
}
func TestRenderSpec_ListColumnEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: []string{"date", "bogus"}}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown list column must reject, got %v", err)
}
}
func TestRenderSpec_KnownListColumnsAccepted(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: KnownListColumns}}
if err := s.Validate(); err != nil {
t.Fatalf("known columns must validate: %v", err)
}
}
func TestRenderSpec_ListSortEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Sort: "weird"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown sort must reject, got %v", err)
}
}
func TestRenderSpec_ListDensityEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Density: "huge"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown density must reject, got %v", err)
}
}
func TestRenderSpec_CardsGroupByEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeCards, Cards: &CardsConfig{GroupBy: "month"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown group_by must reject, got %v", err)
}
}
func TestRenderSpec_CalendarViewEnum(t *testing.T) {
s := RenderSpec{Shape: ShapeCalendar, Calendar: &CalendarConfig{DefaultView: "year"}}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown default_view must reject, got %v", err)
}
}
func TestRenderSpec_RoundTrip(t *testing.T) {
original := RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Columns: []string{"time", "actor", "title", "project"},
Sort: SortDateDesc,
Density: DensityCompact,
},
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
Calendar: &CalendarConfig{DefaultView: CalendarMonth},
}
b, err := MarshalRenderSpec(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := UnmarshalRenderSpec(b)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.Shape != original.Shape {
t.Errorf("shape mismatch: %v vs %v", parsed.Shape, original.Shape)
}
if parsed.List == nil || parsed.List.Density != DensityCompact {
t.Errorf("list config not preserved: %+v", parsed.List)
}
}

View File

@@ -0,0 +1,208 @@
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 — the
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
func InboxSystemView() SystemView {
return SystemView{
Slug: "inbox",
Name: "Inbox",
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: "approver_eligible",
Status: []string{"pending"},
}},
},
},
Render: RenderSpec{
Shape: ShapeList,
List: &ListConfig{
Density: DensityComfortable,
Sort: SortDateAsc,
},
},
}
}
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
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,
},
},
}
}
// 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)
}

View File

@@ -0,0 +1,47 @@
package services
import "testing"
// Pure-Go tests for the SystemView registry. Each system view's specs
// must self-validate; the slugs must be reserved.
func TestSystemViews_AllValidate(t *testing.T) {
for _, sv := range AllSystemViews() {
t.Run(sv.Slug, func(t *testing.T) {
if err := sv.Filter.Validate(); err != nil {
t.Errorf("%s filter spec invalid: %v", sv.Slug, err)
}
if err := sv.Render.Validate(); err != nil {
t.Errorf("%s render spec invalid: %v", sv.Slug, err)
}
})
}
}
func TestSystemViews_SlugsReserved(t *testing.T) {
for _, sv := range AllSystemViews() {
t.Run(sv.Slug, func(t *testing.T) {
if !IsReservedUserViewSlug(sv.Slug) {
t.Errorf("system slug %q must be reserved against user_views", sv.Slug)
}
})
}
}
func TestReservedSlugs_CaseFolded(t *testing.T) {
if !IsReservedUserViewSlug("Dashboard") {
t.Error("reserved-slug check must be case-insensitive")
}
if !IsReservedUserViewSlug("INBOX") {
t.Error("reserved-slug check must be case-insensitive")
}
}
func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
cases := []string{"freitag-stand", "approval-pending-mine", "siemens", "my-view"}
for _, slug := range cases {
if IsReservedUserViewSlug(slug) {
t.Errorf("user-friendly slug %q must not be reserved", slug)
}
}
}

View File

@@ -0,0 +1,377 @@
package services
// UserViewService is the CRUD layer for paliad.user_views — saved Custom
// View definitions per user.
//
// Design: docs/design-data-display-model-2026-05-06.md §5.
//
// Visibility: every read and write is scoped to the calling user via the
// RLS policy `user_views_owner_all` on auth.uid() = user_id. The service
// also AND-joins user_id in the SQL for defense-in-depth (RLS can be
// disabled in tests, the code-level check still holds).
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// UserView is the persisted shape of a saved Custom View.
type UserView struct {
ID uuid.UUID `db:"id" json:"id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Slug string `db:"slug" json:"slug"`
Name string `db:"name" json:"name"`
Icon *string `db:"icon" json:"icon,omitempty"`
FilterSpec json.RawMessage `db:"filter_spec" json:"filter_spec"`
RenderSpec json.RawMessage `db:"render_spec" json:"render_spec"`
SortOrder int `db:"sort_order" json:"sort_order"`
ShowCount bool `db:"show_count" json:"show_count"`
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// UserViewService manages paliad.user_views.
type UserViewService struct {
db *sqlx.DB
}
// NewUserViewService wires the service to the pool.
func NewUserViewService(db *sqlx.DB) *UserViewService {
return &UserViewService{db: db}
}
// CreateUserViewInput is the payload for Create.
type CreateUserViewInput struct {
Slug string
Name string
Icon *string
FilterSpec FilterSpec
RenderSpec RenderSpec
ShowCount bool
// SortOrder is server-assigned (MAX+1) on create — callers cannot set it.
}
// UpdateUserViewInput is the partial-update payload. All fields are
// optional; nil means "no change".
type UpdateUserViewInput struct {
Slug *string
Name *string
Icon *string // pointer-to-pointer-of-string would be clearer for "clear vs unchanged"; we treat *string{""} as "clear"
FilterSpec *FilterSpec
RenderSpec *RenderSpec
SortOrder *int
ShowCount *bool
}
// slugRE caps slugs to URL-safe lowercase. Same shape as the migration
// comment promises (^[a-z0-9][a-z0-9-]{0,62}$).
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// ErrUserViewSlugTaken signals "slug already exists for this user". The
// HTTP layer maps this to 409.
var ErrUserViewSlugTaken = errors.New("user_view slug already exists for this user")
// ListForUser returns the caller's saved views, ordered by sort_order ASC
// then name. Result is the same shape /api/user-views returns to the
// frontend on app load (sidebar hydration).
func (s *UserViewService) ListForUser(ctx context.Context, userID uuid.UUID) ([]UserView, error) {
var rows []UserView
err := s.db.SelectContext(ctx, &rows, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1
ORDER BY sort_order ASC, name ASC`, userID)
if err != nil {
return nil, fmt.Errorf("list user_views: %w", err)
}
return rows, nil
}
// GetBySlug fetches one view by slug. Returns (nil, nil) when the slug
// is unknown for this user.
func (s *UserViewService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1 AND slug = $2`, userID, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user_view: %w", err)
}
return &v, nil
}
// GetByID fetches one view by id. Same nil-on-miss semantic.
func (s *UserViewService) GetByID(ctx context.Context, userID, id uuid.UUID) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1 AND id = $2`, userID, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get user_view: %w", err)
}
return &v, nil
}
// Create persists a new view for the caller. Server-assigns sort_order
// to MAX(existing)+1 inside the same tx so two parallel creates don't
// collide.
func (s *UserViewService) Create(ctx context.Context, userID uuid.UUID, input CreateUserViewInput) (*UserView, error) {
if err := validateCreateInput(input); err != nil {
return nil, err
}
filterJSON, err := MarshalFilterSpec(input.FilterSpec)
if err != nil {
return nil, err
}
renderJSON, err := MarshalRenderSpec(input.RenderSpec)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var nextSortOrder int
if err := tx.GetContext(ctx, &nextSortOrder, `
SELECT COALESCE(MAX(sort_order), -1) + 1
FROM paliad.user_views
WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("compute next sort_order: %w", err)
}
var v UserView
err = tx.GetContext(ctx, &v, `
INSERT INTO paliad.user_views
(user_id, slug, name, icon, filter_spec, render_spec, sort_order, show_count)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at`,
userID, input.Slug, input.Name, input.Icon,
filterJSON, renderJSON, nextSortOrder, input.ShowCount)
if err != nil {
if isUniqueViolation(err) {
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, input.Slug)
}
return nil, fmt.Errorf("insert user_view: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit user_view create: %w", err)
}
return &v, nil
}
// Update applies a partial update to an existing view. Returns
// (nil, nil) if the row doesn't exist for this user.
func (s *UserViewService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateUserViewInput) (*UserView, error) {
current, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if current == nil {
return nil, nil
}
// Coalesce input over current.
slug := current.Slug
if input.Slug != nil {
slug = *input.Slug
}
if err := validateSlug(slug); err != nil {
return nil, err
}
name := current.Name
if input.Name != nil {
name = *input.Name
}
if err := validateName(name); err != nil {
return nil, err
}
icon := current.Icon
if input.Icon != nil {
s := *input.Icon
if s == "" {
icon = nil
} else {
icon = &s
}
}
filterJSON := []byte(current.FilterSpec)
if input.FilterSpec != nil {
b, err := MarshalFilterSpec(*input.FilterSpec)
if err != nil {
return nil, err
}
filterJSON = b
}
renderJSON := []byte(current.RenderSpec)
if input.RenderSpec != nil {
b, err := MarshalRenderSpec(*input.RenderSpec)
if err != nil {
return nil, err
}
renderJSON = b
}
sortOrder := current.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
showCount := current.ShowCount
if input.ShowCount != nil {
showCount = *input.ShowCount
}
var v UserView
err = s.db.GetContext(ctx, &v, `
UPDATE paliad.user_views
SET slug = $3, name = $4, icon = $5,
filter_spec = $6, render_spec = $7,
sort_order = $8, show_count = $9,
updated_at = now()
WHERE user_id = $1 AND id = $2
RETURNING id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at`,
userID, id, slug, name, icon, filterJSON, renderJSON, sortOrder, showCount)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
if isUniqueViolation(err) {
return nil, fmt.Errorf("%w: %s", ErrUserViewSlugTaken, slug)
}
return nil, fmt.Errorf("update user_view: %w", err)
}
return &v, nil
}
// Delete removes a saved view. Single Yes/No modal on the frontend
// (Q25 lock-in); no audit emit (these are personal working state).
// Returns (false, nil) when the row didn't exist.
func (s *UserViewService) Delete(ctx context.Context, userID, id uuid.UUID) (bool, error) {
res, err := s.db.ExecContext(ctx, `
DELETE FROM paliad.user_views
WHERE user_id = $1 AND id = $2`, userID, id)
if err != nil {
return false, fmt.Errorf("delete user_view: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("delete user_view rows affected: %w", err)
}
return n > 0, nil
}
// Touch updates last_used_at to now. Fire-and-forget from the page
// handler — no error surface to the user.
func (s *UserViewService) Touch(ctx context.Context, userID, id uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `
UPDATE paliad.user_views
SET last_used_at = now()
WHERE user_id = $1 AND id = $2`, userID, id)
if err != nil {
return fmt.Errorf("touch user_view: %w", err)
}
return nil
}
// MostRecent returns the caller's most-recently-used view, or nil if
// the user has none / has never opened one. Used for the /views landing
// (Q10 most-recently-used default).
func (s *UserViewService) MostRecent(ctx context.Context, userID uuid.UUID) (*UserView, error) {
var v UserView
err := s.db.GetContext(ctx, &v, `
SELECT id, user_id, slug, name, icon, filter_spec, render_spec,
sort_order, show_count, last_used_at, created_at, updated_at
FROM paliad.user_views
WHERE user_id = $1
AND last_used_at IS NOT NULL
ORDER BY last_used_at DESC
LIMIT 1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("most-recent user_view: %w", err)
}
return &v, nil
}
// ============================================================================
// Validators (slug + name + create input)
// ============================================================================
func validateSlug(slug string) error {
if slug == "" {
return fmt.Errorf("%w: slug is required", ErrInvalidInput)
}
if !slugRE.MatchString(slug) {
return fmt.Errorf("%w: slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (got %q)", ErrInvalidInput, slug)
}
if IsReservedUserViewSlug(slug) {
return fmt.Errorf("%w: slug %q is reserved", ErrInvalidInput, slug)
}
return nil
}
func validateName(name string) error {
if name == "" {
return fmt.Errorf("%w: name is required", ErrInvalidInput)
}
// 1-character names are fine (some users may want 1-letter shortcuts).
// 200 is the codebase-wide cap (matches Notes / Checklists).
if len(name) > 200 {
return fmt.Errorf("%w: name exceeds 200 characters", ErrInvalidInput)
}
return nil
}
func validateCreateInput(input CreateUserViewInput) error {
if err := validateSlug(input.Slug); err != nil {
return err
}
if err := validateName(input.Name); err != nil {
return err
}
if input.Icon != nil && len(*input.Icon) > 64 {
return fmt.Errorf("%w: icon key exceeds 64 characters", ErrInvalidInput)
}
if err := input.FilterSpec.Validate(); err != nil {
return err
}
if err := input.RenderSpec.Validate(); err != nil {
return err
}
return nil
}
// isUniqueViolation is shared with event_type_service.go (defined there).

View File

@@ -0,0 +1,324 @@
package services
// Live-DB tests for UserViewService. Skipped when TEST_DATABASE_URL is
// unset, mirroring the rest of the live-DB test suite.
import (
"context"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
type userViewTestEnv struct {
t *testing.T
pool *sqlx.DB
svc *UserViewService
userID uuid.UUID
cleanup func()
}
func setupUserViewTest(t *testing.T) *userViewTestEnv {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
ctx := context.Background()
userID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Logf("skip auth.users seed: %v (continuing — auth schema may be locked down)", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'View Test User', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
cleanup := func() {
ctx := context.Background()
pool.ExecContext(ctx, `DELETE FROM paliad.user_views WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
pool.Close()
}
return &userViewTestEnv{
t: t,
pool: pool,
svc: NewUserViewService(pool),
userID: userID,
cleanup: cleanup,
}
}
func goodCreateInput(slug, name string) CreateUserViewInput {
return CreateUserViewInput{
Slug: slug,
Name: name,
FilterSpec: DefaultFilterSpec(),
RenderSpec: DefaultRenderSpec(),
}
}
func TestUserViewService_CreateAndList(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freitag-stand", "Freitag-Stand"))
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Slug != "freitag-stand" || created.Name != "Freitag-Stand" {
t.Errorf("created shape: %+v", created)
}
if created.SortOrder != 0 {
t.Errorf("first view sort_order = %d, want 0", created.SortOrder)
}
second, err := env.svc.Create(ctx, env.userID, goodCreateInput("siemens", "Siemens-Aktivität"))
if err != nil {
t.Fatalf("second create: %v", err)
}
if second.SortOrder != 1 {
t.Errorf("second view sort_order = %d, want 1", second.SortOrder)
}
list, err := env.svc.ListForUser(ctx, env.userID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 views, got %d", len(list))
}
// sort_order ASC ordering
if list[0].Slug != "freitag-stand" || list[1].Slug != "siemens" {
t.Errorf("ordering: %v", []string{list[0].Slug, list[1].Slug})
}
}
func TestUserViewService_GetBySlugAndID(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("test-view", "Test View"))
if err != nil {
t.Fatalf("create: %v", err)
}
bySlug, err := env.svc.GetBySlug(ctx, env.userID, "test-view")
if err != nil || bySlug == nil {
t.Fatalf("GetBySlug: %v / nil", err)
}
if bySlug.ID != created.ID {
t.Errorf("GetBySlug id mismatch")
}
byID, err := env.svc.GetByID(ctx, env.userID, created.ID)
if err != nil || byID == nil {
t.Fatalf("GetByID: %v / nil", err)
}
missing, err := env.svc.GetBySlug(ctx, env.userID, "does-not-exist")
if err != nil {
t.Fatalf("GetBySlug missing: %v", err)
}
if missing != nil {
t.Error("missing slug should return nil")
}
}
func TestUserViewService_SlugUniquenessPerUser(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
if _, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "First")); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "Second"))
if !errors.Is(err, ErrUserViewSlugTaken) {
t.Fatalf("duplicate slug must return ErrUserViewSlugTaken, got %v", err)
}
}
func TestUserViewService_Update(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("orig", "Original"))
if err != nil {
t.Fatalf("create: %v", err)
}
newName := "Updated"
newShowCount := true
updated, err := env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{
Name: &newName,
ShowCount: &newShowCount,
})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Updated" {
t.Errorf("name not updated: %s", updated.Name)
}
if !updated.ShowCount {
t.Errorf("show_count not updated")
}
// Slug should be unchanged.
if updated.Slug != "orig" {
t.Errorf("slug should be unchanged, got %s", updated.Slug)
}
}
func TestUserViewService_UpdateRejectsReservedSlug(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freely", "Freely"))
if err != nil {
t.Fatalf("create: %v", err)
}
reserved := "dashboard"
_, err = env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{Slug: &reserved})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("update to reserved slug must reject, got %v", err)
}
}
func TestUserViewService_Delete(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("doomed", "Doomed"))
if err != nil {
t.Fatalf("create: %v", err)
}
deleted, err := env.svc.Delete(ctx, env.userID, created.ID)
if err != nil || !deleted {
t.Fatalf("delete: %v, %v", deleted, err)
}
deletedAgain, err := env.svc.Delete(ctx, env.userID, created.ID)
if err != nil {
t.Fatalf("second delete: %v", err)
}
if deletedAgain {
t.Error("second delete should report not-deleted")
}
}
func TestUserViewService_TouchAndMostRecent(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
a, _ := env.svc.Create(ctx, env.userID, goodCreateInput("a-view", "A"))
b, _ := env.svc.Create(ctx, env.userID, goodCreateInput("b-view", "B"))
// Before any touch — MostRecent is nil.
mr, err := env.svc.MostRecent(ctx, env.userID)
if err != nil {
t.Fatalf("most_recent: %v", err)
}
if mr != nil {
t.Errorf("MostRecent should be nil before any touch")
}
if err := env.svc.Touch(ctx, env.userID, a.ID); err != nil {
t.Fatalf("touch a: %v", err)
}
if err := env.svc.Touch(ctx, env.userID, b.ID); err != nil {
t.Fatalf("touch b: %v", err)
}
mr, err = env.svc.MostRecent(ctx, env.userID)
if err != nil || mr == nil {
t.Fatalf("most_recent after touch: %v / nil", err)
}
if mr.ID != b.ID {
t.Errorf("most-recent should be b (touched last), got %s", mr.Slug)
}
}
func TestUserViewService_RejectsReservedSlugOnCreate(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("inbox", "Inbox copy"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("reserved slug on create must reject, got %v", err)
}
}
func TestUserViewService_RejectsBadSlug(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("Has Spaces", "Bad"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("slug with spaces must reject, got %v", err)
}
_, err = env.svc.Create(ctx, env.userID, goodCreateInput("UPPER", "Bad"))
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("uppercase slug must reject, got %v", err)
}
}
func TestUserViewService_RejectsEmptyName(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
Slug: "no-name",
Name: "",
FilterSpec: DefaultFilterSpec(),
RenderSpec: DefaultRenderSpec(),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty name must reject, got %v", err)
}
}
func TestUserViewService_RejectsInvalidSpec(t *testing.T) {
env := setupUserViewTest(t)
defer env.cleanup()
ctx := context.Background()
bad := DefaultFilterSpec()
bad.Sources = nil
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
Slug: "bad-spec",
Name: "Bad",
FilterSpec: bad,
RenderSpec: DefaultRenderSpec(),
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid spec must reject, got %v", err)
}
}

View File

@@ -0,0 +1,719 @@
package services
// ViewService extension on EventService — runs a FilterSpec across the
// 4 substrate sources (deadline, appointment, project_event,
// approval_request) and returns a unified []ViewRow.
//
// Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3.
//
// EventService is extended (not renamed) so the existing handlers
// (/api/events, /api/events/summary) keep working unchanged. New
// handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec.
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// ViewRow is the unified row shape returned by RunSpec. Discriminated by
// `Kind`; type-specific fields live under `Detail` as a per-source struct
// marshalled via json.RawMessage.
type ViewRow struct {
Kind DataSource `json:"kind"`
ID uuid.UUID `json:"id"`
Title string `json:"title"`
// Subtitle: one short context line (e.g. "Frist", "Termin",
// "Genehmigung von …"). Optional; UIs render it under the title.
Subtitle *string `json:"subtitle,omitempty"`
// EventDate is the canonical sort key per row. Source-determined:
// - deadline: due_date at 00:00 UTC
// - appointment: start_at
// - project_event: created_at
// - approval_request: requested_at (or decided_at if status decided)
EventDate time.Time `json:"event_date"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectReference *string `json:"project_reference,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ActorID *uuid.UUID `json:"actor_id,omitempty"`
ActorName *string `json:"actor_name,omitempty"`
// Detail is the per-source typed payload as raw JSON. Frontend
// type-narrows on Kind and parses Detail accordingly.
Detail json.RawMessage `json:"detail"`
}
// ViewRunResult is the response shape of RunSpec — rows + a count of
// projects that contributed zero rows because the caller can't see them
// (Q17 fail-open attribution).
type ViewRunResult struct {
Rows []ViewRow `json:"rows"`
InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"`
}
// RunSpec executes the FilterSpec against the substrate and returns
// merged rows sorted by EventDate (ascending for forward-looking,
// descending if any sort hint says so). Visibility is enforced via
// the per-source RLS predicates already used by the underlying tables;
// `userID` is the caller for context propagation.
//
// Caller has run spec.Validate() before us. We trust the spec.
func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) {
if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) {
// Approval source requires the approval service. Return a clear
// error rather than silently skipping it — handlers always pass
// the bundle's approval service.
return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil")
}
rows := make([]ViewRow, 0, 256)
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
for _, src := range spec.Sources {
switch src {
case SourceDeadline:
batch, err := s.runDeadlines(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceAppointment:
batch, err := s.runAppointments(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceProjectEvent:
batch, err := s.runProjectEvents(ctx, userID, spec, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
case SourceApprovalRequest:
batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds)
if err != nil {
return nil, err
}
rows = append(rows, batch...)
}
}
// Default sort: ascending. Per-source sort hints don't apply here —
// Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing
// knob. We give the substrate a stable shape; the renderer flips it.
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].EventDate.Equal(rows[j].EventDate) {
// Tiebreaker: kind alphabetical, then title — deterministic.
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
return rows[i].Title < rows[j].Title
}
return rows[i].EventDate.Before(rows[j].EventDate)
})
out := &ViewRunResult{Rows: rows}
// Q17 fail-open attribution: if the caller specified explicit
// project IDs, surface the ones they couldn't see. We do that with
// one cheap check against can_see_project (via RLS-aware visibility
// predicate), batched per call.
if spec.Scope.Projects.Mode == ScopeExplicit {
inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs)
if err != nil {
return nil, err
}
if len(inaccessible) > 0 {
out.InaccessibleProjectIDs = inaccessible
}
}
return out, nil
}
// viewSpecBounds carries the resolved [from, to) window the spec
// translates into. Either bound can be nil (open-ended).
type viewSpecBounds struct {
from *time.Time
to *time.Time
}
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
now = now.UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
switch ts.Horizon {
case HorizonNext7d:
from := day
to := day.AddDate(0, 0, 7)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext30d:
from := day
to := day.AddDate(0, 0, 30)
return viewSpecBounds{from: &from, to: &to}
case HorizonNext90d:
from := day
to := day.AddDate(0, 0, 90)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast30d:
from := day.AddDate(0, 0, -30)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast90d:
from := day.AddDate(0, 0, -90)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonAny, HorizonAll:
return viewSpecBounds{}
case HorizonCustom:
return viewSpecBounds{from: ts.From, to: ts.To}
}
return viewSpecBounds{}
}
// runDeadlines projects DeadlineWithProject rows from the existing
// DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing.
func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
df := ListFilter{}
if spec.Scope.PersonalOnly {
uid := userID
df.CreatedBy = &uid
}
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
dp := preds.Deadline
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
// If the spec asks for both pending+completed → no narrowing; if
// only pending → DeadlineFilterPending; only completed → Completed.
switch {
case len(dp.Status) == 1 && dp.Status[0] == "pending":
df.Status = DeadlineFilterPending
case len(dp.Status) == 1 && dp.Status[0] == "completed":
df.Status = DeadlineFilterCompleted
default:
df.Status = DeadlineFilterAll
}
df.EventTypeIDs = dp.EventTypeIDs
df.IncludeUntyped = dp.IncludeUntyped
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
// DeadlineService takes one project id; we filter post-load when
// spec selects multiple projects (the visibility predicate already
// bounds to the caller's set, and explicit IDs are a refinement).
}
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedProjects := explicitProjectSet(spec)
for _, r := range rows {
eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC)
if !inSpecWindow(eventDate, bounds) {
continue
}
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
continue
}
// Approval-status narrowing (entity-side pill).
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) {
continue
}
detail, _ := json.Marshal(map[string]any{
"due_date": r.DueDate.Format("2006-01-02"),
"status": r.Status,
"approval_status": r.ApprovalStatus,
"source": r.Source,
"rule_id": r.RuleID,
"rule_code": r.RuleCode,
"rule_name": r.RuleName,
"event_type_ids": r.EventTypeIDs,
"description": r.Description,
"completed_at": r.CompletedAt,
})
pid := r.ProjectID
pt := r.ProjectTitle
ptype := r.ProjectType
out = append(out, ViewRow{
Kind: SourceDeadline,
ID: r.ID,
Title: r.Title,
EventDate: eventDate,
ProjectID: &pid,
ProjectTitle: &pt,
ProjectReference: r.ProjectReference,
ProjectType: &ptype,
ActorID: r.CreatedBy,
Detail: detail,
})
}
return out, nil
}
// runAppointments projects AppointmentWithProject onto ViewRow.
func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
af := AppointmentListFilter{}
if spec.Scope.PersonalOnly {
uid := userID
af.CreatedBy = &uid
}
af.From = bounds.from
af.To = bounds.to
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
ap := preds.Appointment
// AppointmentListFilter takes a single Type today; narrow to first
// listed value, fall back to all if multiple.
if len(ap.AppointmentTypes) == 1 {
t := ap.AppointmentTypes[0]
af.Type = &t
}
}
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedProjects := explicitProjectSet(spec)
allowedTypes := allowedAppointmentTypes(spec)
for _, r := range rows {
if !inSpecWindow(r.StartAt, bounds) {
continue
}
if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] {
continue
}
if r.ProjectID == nil && allowedProjects != nil {
continue
}
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) {
continue
}
if allowedTypes != nil {
if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] {
continue
}
}
detail, _ := json.Marshal(map[string]any{
"start_at": r.StartAt,
"end_at": r.EndAt,
"location": r.Location,
"appointment_type": r.AppointmentType,
"approval_status": r.ApprovalStatus,
"description": r.Description,
})
out = append(out, ViewRow{
Kind: SourceAppointment,
ID: r.ID,
Title: r.Title,
EventDate: r.StartAt,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectReference: r.ProjectReference,
ProjectType: r.ProjectType,
ActorID: r.CreatedBy,
Detail: detail,
})
}
return out, nil
}
// runProjectEvents queries paliad.project_events with the visibility
// predicate. The audit table doesn't have a service wrapper today; we
// run our own SQL bounded by the spec.
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
conds := []string{visibilityPredicatePositional("p", 1)}
args := []any{userID}
allowedKinds := allowedProjectEventKinds(spec)
if len(allowedKinds) > 0 {
args = append(args, pq.Array(allowedKinds))
conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args)))
}
if bounds.from != nil {
args = append(args, *bounds.from)
conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args)))
}
if bounds.to != nil {
args = append(args, *bounds.to)
conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args)))
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
args = append(args, spec.Scope.Projects.IDs)
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
}
q := `
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
pe.event_date, pe.created_by, pe.created_at,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference,
u.display_name AS actor_name
FROM paliad.project_events pe
JOIN paliad.projects p ON p.id = pe.project_id
LEFT JOIN paliad.users u ON u.id = pe.created_by
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pe.created_at DESC
LIMIT 500`
type row struct {
ID uuid.UUID `db:"id"`
ProjectID uuid.UUID `db:"project_id"`
EventType *string `db:"event_type"`
Title string `db:"title"`
Description *string `db:"description"`
EventDate *time.Time `db:"event_date"`
CreatedBy *uuid.UUID `db:"created_by"`
CreatedAt time.Time `db:"created_at"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
ActorName *string `db:"actor_name"`
}
var dbRows []row
if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil {
return nil, fmt.Errorf("project_events query: %w", err)
}
out := make([]ViewRow, 0, len(dbRows))
for _, r := range dbRows {
detail, _ := json.Marshal(map[string]any{
"event_type": r.EventType,
"description": r.Description,
"event_date": r.EventDate,
})
pid := r.ProjectID
pt := r.ProjectTitle
ptype := r.ProjectType
out = append(out, ViewRow{
Kind: SourceProjectEvent,
ID: r.ID,
Title: r.Title,
EventDate: r.CreatedAt,
ProjectID: &pid,
ProjectTitle: &pt,
ProjectReference: r.ProjectReference,
ProjectType: &ptype,
ActorID: r.CreatedBy,
ActorName: r.ActorName,
Detail: detail,
})
}
return out, nil
}
// runApprovalRequests projects approval_request rows via the existing
// ApprovalService inbox queries. ViewerRole picks which underlying
// query runs.
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
preds := spec.Predicates[SourceApprovalRequest]
role := "approver_eligible"
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
role = preds.ApprovalRequest.ViewerRole
}
filter := InboxFilter{}
if preds.ApprovalRequest != nil {
// InboxFilter takes a single status today. If the spec says
// only one, narrow; if multiple, leave open.
if len(preds.ApprovalRequest.Status) == 1 {
filter.Status = preds.ApprovalRequest.Status[0]
}
if len(preds.ApprovalRequest.EntityTypes) == 1 {
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
}
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
pid := spec.Scope.Projects.IDs[0]
filter.ProjectID = &pid
}
var rows []ApprovalRequestView
var err error
switch role {
case "approver_eligible":
rows, err = approval.ListPendingForApprover(ctx, userID, filter)
case "self_requested":
rows, err = approval.ListSubmittedByUser(ctx, userID, filter)
case "any_visible":
// any_visible is the broadest read — RLS bounds it. The existing
// ApprovalService doesn't have a "list all visible" call; we
// approximate by running both inbox queries and de-duping. Future
// optimization: dedicated service method.
a, errA := approval.ListPendingForApprover(ctx, userID, filter)
if errA != nil {
return nil, errA
}
b, errB := approval.ListSubmittedByUser(ctx, userID, filter)
if errB != nil {
return nil, errB
}
seen := make(map[uuid.UUID]bool, len(a)+len(b))
for _, r := range a {
if !seen[r.ID] {
rows = append(rows, r)
seen[r.ID] = true
}
}
for _, r := range b {
if !seen[r.ID] {
rows = append(rows, r)
seen[r.ID] = true
}
}
default:
return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role)
}
if err != nil {
return nil, err
}
out := make([]ViewRow, 0, len(rows))
allowedStatuses := allowedRequestStatuses(spec)
allowedEntityTypes := allowedRequestEntityTypes(spec)
allowedProjects := explicitProjectSet(spec)
for _, r := range rows {
// Spec status filter (when the inbox query received broad results).
if allowedStatuses != nil && !allowedStatuses[r.Status] {
continue
}
if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] {
continue
}
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
continue
}
// Sort key: decided_at if decided, else requested_at.
eventDate := r.RequestedAt
if r.DecidedAt != nil {
eventDate = *r.DecidedAt
}
if !inSpecWindow(eventDate, bounds) {
continue
}
title := approvalRowTitle(r)
subtitle := approvalRowSubtitle(r)
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
actorID := r.RequestedBy
actorName := r.RequesterName
pid := r.ProjectID
pt := r.ProjectTitle
out = append(out, ViewRow{
Kind: SourceApprovalRequest,
ID: r.ID,
Title: title,
Subtitle: &subtitle,
EventDate: eventDate,
ProjectID: &pid,
ProjectTitle: &pt,
ActorID: &actorID,
ActorName: &actorName,
Detail: detail,
})
}
return out, nil
}
// approvalRowTitle returns a one-line title describing the approval
// request — used as the ViewRow.Title.
func approvalRowTitle(r ApprovalRequestView) string {
if r.EntityTitle != nil && *r.EntityTitle != "" {
return *r.EntityTitle
}
return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent)
}
// approvalRowSubtitle returns a one-line context for the request.
func approvalRowSubtitle(r ApprovalRequestView) string {
switch r.Status {
case "pending":
return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName)
case "approved":
if r.DeciderName != nil {
return fmt.Sprintf("Genehmigt von %s", *r.DeciderName)
}
return "Genehmigt"
case "rejected":
if r.DeciderName != nil {
return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName)
}
return "Abgelehnt"
case "revoked":
return "Widerrufen"
}
return r.Status
}
// inSpecWindow returns true when ts is within [from, to). nil bounds
// are open-ended.
func inSpecWindow(ts time.Time, b viewSpecBounds) bool {
if b.from != nil && ts.Before(*b.from) {
return false
}
if b.to != nil && !ts.Before(*b.to) {
return false
}
return true
}
// explicitProjectSet returns nil when the scope isn't explicit, otherwise
// a set membership map for fast filtering.
func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
if spec.Scope.Projects.Mode != ScopeExplicit {
return nil
}
out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs))
for _, id := range spec.Scope.Projects.IDs {
out[id] = true
}
return out
}
// approvalStatusMatches checks the entity-side approval_status filter.
// Returns true when the row passes (no filter set → always true).
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
preds, ok := spec.Predicates[src]
if !ok {
return true
}
var allowed []string
switch src {
case SourceDeadline:
if preds.Deadline != nil {
allowed = preds.Deadline.ApprovalStatus
}
case SourceAppointment:
if preds.Appointment != nil {
allowed = preds.Appointment.ApprovalStatus
}
}
if len(allowed) == 0 {
return true
}
return slices.Contains(allowed, rowStatus)
}
// allowedAppointmentTypes returns nil when the filter is open, otherwise
// a set of legal appointment_type values.
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceAppointment]
if !ok || preds.Appointment == nil {
return nil
}
if len(preds.Appointment.AppointmentTypes) <= 1 {
return nil // single-value already pushed down via AppointmentListFilter.Type
}
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
for _, t := range preds.Appointment.AppointmentTypes {
out[t] = true
}
return out
}
// allowedProjectEventKinds returns the slice of project_event.event_type
// values the spec narrows to, or nil for "all known kinds".
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
if !ok || preds.ProjectEvent == nil {
return nil
}
if len(preds.ProjectEvent.EventTypes) == 0 {
return nil
}
return preds.ProjectEvent.EventTypes
}
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
// already pushed into InboxFilter.Status").
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.Status) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
for _, s := range preds.ApprovalRequest.Status {
out[s] = true
}
return out
}
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
for _, t := range preds.ApprovalRequest.EntityTypes {
out[t] = true
}
return out
}
// filterInaccessibleProjects returns the subset of `requested` that the
// caller cannot see. Implementation: SELECT id FROM paliad.projects
// WHERE id = ANY(...) (RLS filters the visible ones); the missing ones
// are inaccessible. One DB hit per RunSpec when scope is explicit.
func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) {
if len(requested) == 0 {
return nil, nil
}
q := `SELECT p.id
FROM paliad.projects p
WHERE p.id = ANY($1)
AND ` + visibilityPredicatePositional("p", 2)
var visible []uuid.UUID
if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil {
return nil, fmt.Errorf("filter inaccessible projects: %w", err)
}
visibleSet := make(map[uuid.UUID]bool, len(visible))
for _, id := range visible {
visibleSet[id] = true
}
out := make([]uuid.UUID, 0)
for _, id := range requested {
if !visibleSet[id] {
out = append(out, id)
}
}
return out, nil
}
// Compile-time guards: the substrate's source loaders read fields off
// known model shapes. If a model rename breaks this, the build fails
// here rather than at runtime in production.
var (
_ = models.DeadlineWithProject{}
_ = models.AppointmentWithProject{}
_ = models.ProjectEvent{}
)