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.
378 lines
12 KiB
Go
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).
|