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.
394 lines
11 KiB
Go
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)
|
|
}
|