Files
paliad/internal/handlers/views.go
m b516201110 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.
2026-05-07 12:51:37 +02:00

394 lines
11 KiB
Go

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)
}