Files
paliad/internal/services/user_view_service.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

378 lines
12 KiB
Go

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