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:
@@ -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).
|
||||
|
||||
3
internal/db/migrations/056_user_views.down.sql
Normal file
3
internal/db/migrations/056_user_views.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 056_user_views.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_views;
|
||||
77
internal/db/migrations/056_user_views.up.sql
Normal file
77
internal/db/migrations/056_user_views.up.sql
Normal 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());
|
||||
@@ -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.
|
||||
|
||||
@@ -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
393
internal/handlers/views.go
Normal 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)
|
||||
}
|
||||
393
internal/services/filter_spec.go
Normal file
393
internal/services/filter_spec.go
Normal 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
|
||||
}
|
||||
263
internal/services/filter_spec_test.go
Normal file
263
internal/services/filter_spec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
207
internal/services/render_spec.go
Normal file
207
internal/services/render_spec.go
Normal 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
|
||||
}
|
||||
103
internal/services/render_spec_test.go
Normal file
103
internal/services/render_spec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
208
internal/services/system_views.go
Normal file
208
internal/services/system_views.go
Normal 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)
|
||||
}
|
||||
47
internal/services/system_views_test.go
Normal file
47
internal/services/system_views_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
377
internal/services/user_view_service.go
Normal file
377
internal/services/user_view_service.go
Normal 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).
|
||||
324
internal/services/user_view_service_test.go
Normal file
324
internal/services/user_view_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
719
internal/services/view_service.go
Normal file
719
internal/services/view_service.go
Normal 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{}
|
||||
)
|
||||
Reference in New Issue
Block a user