Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.
Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
sort_dir, group_by, sort_order, show_count, last_used_at,
created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
MRU (last_used_at) over per-page-default; Q2 placed view_type
inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.
Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
/views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
ErrViewSlugFormat surface to handlers as the typed error set.
Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
overlayURLFields, filterQueryToJSON, filterJSONToQuery,
filterFromJSONPayload, anySliceToStrings + every old handler
(handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
applySavedView + applyDefaultView calls in handleTree.
DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).
Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
GET /views → MRU landing
GET /views/{slug} → render
GET /views/new → editor
GET /views/{slug}/edit → editor
POST /views, /views/{slug}, /views/{slug}/delete → CRUD
Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.
Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
362 lines
11 KiB
Go
362 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// View is one row in projax.views — a first-class /views/{slug} page.
|
|
// Phase 5j paliad-shape: the slug is the user-facing key; URLs and the
|
|
// sidebar both index by it. The uuid id stays because it's cheap and
|
|
// surfaces in future MCP integrations, but it is NOT exposed in URLs.
|
|
type View struct {
|
|
ID string
|
|
Slug string
|
|
Name string
|
|
Icon *string
|
|
FilterJSON []byte // raw jsonb payload — includes view_type per m's Q2
|
|
SortField *string
|
|
SortDir *string
|
|
GroupBy *string
|
|
SortOrder int
|
|
ShowCount bool
|
|
LastUsedAt *time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ErrViewNotFound surfaces from Get*/Update*/Delete when no row matches.
|
|
var ErrViewNotFound = errors.New("view not found")
|
|
|
|
// ErrViewSlugTaken is returned by Create / Update when the slug already
|
|
// belongs to another view. Web handlers map this to 409.
|
|
var ErrViewSlugTaken = errors.New("view slug already exists")
|
|
|
|
// ErrViewSlugReserved is returned when the caller picks a slug that
|
|
// shadows a system slug or a top-level URL segment. Web handlers map
|
|
// this to 400 with a friendly message.
|
|
var ErrViewSlugReserved = errors.New("view slug is reserved")
|
|
|
|
// ErrViewSlugFormat is returned when the slug doesn't match the format
|
|
// regex. Same mapping as reserved.
|
|
var ErrViewSlugFormat = errors.New("view slug must match ^[a-z0-9][a-z0-9-]{0,62}$")
|
|
|
|
// slugRE is the format guard. Mirrors the SQL CHECK constraint so callers
|
|
// get a friendly error before round-tripping to the DB.
|
|
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|
|
|
// reservedViewSlugs is the static list of slugs the validator rejects.
|
|
// Combines system-view slugs (slice C wires them) with top-level route
|
|
// segments the application owns.
|
|
var reservedViewSlugs = map[string]struct{}{
|
|
// System views (slice C):
|
|
"tree": {}, "dashboard": {}, "calendar": {}, "timeline": {}, "graph": {},
|
|
// /views sub-routes:
|
|
"new": {}, "edit": {},
|
|
// Top-level application URLs:
|
|
"admin": {}, "login": {}, "logout": {}, "healthz": {}, "mcp": {},
|
|
"static": {}, "i": {}, "views": {},
|
|
}
|
|
|
|
// IsReservedViewSlug reports whether the slug shadows a system slug or a
|
|
// top-level URL segment. Exported for the editor's slug-derivation
|
|
// helper.
|
|
func IsReservedViewSlug(slug string) bool {
|
|
_, ok := reservedViewSlugs[strings.ToLower(slug)]
|
|
return ok
|
|
}
|
|
|
|
// ValidateSlug runs format + reserved checks. Returns nil for valid slugs.
|
|
func ValidateSlug(slug string) error {
|
|
if !slugRE.MatchString(slug) {
|
|
return ErrViewSlugFormat
|
|
}
|
|
if IsReservedViewSlug(slug) {
|
|
return ErrViewSlugReserved
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ViewInput is the writeable subset for Create / Update. Defaults
|
|
// applied: nil FilterJSON → {}; SortOrder is server-assigned on Create.
|
|
type ViewInput struct {
|
|
Slug string
|
|
Name string
|
|
Icon *string
|
|
FilterJSON []byte
|
|
SortField string
|
|
SortDir string
|
|
GroupBy string
|
|
ShowCount bool
|
|
}
|
|
|
|
// ListViews returns every view ordered by sort_order ASC then name —
|
|
// matches the sidebar rendering order.
|
|
func (s *Store) ListViews(ctx context.Context) ([]*View, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT id, slug, name, icon, filter_json,
|
|
sort_field, sort_dir, group_by,
|
|
sort_order, show_count, last_used_at,
|
|
created_at, updated_at
|
|
FROM projax.views
|
|
ORDER BY sort_order ASC, name ASC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list views: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []*View
|
|
for rows.Next() {
|
|
v, err := scanView(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetView returns one view by slug. ErrViewNotFound when missing.
|
|
func (s *Store) GetView(ctx context.Context, slug string) (*View, error) {
|
|
return s.getView(ctx, `slug = $1`, slug)
|
|
}
|
|
|
|
// GetViewByID returns one view by uuid id. Used by the legacy
|
|
// `?view=<uuid>` 302-redirect path during the 5i → 5j cutover.
|
|
func (s *Store) GetViewByID(ctx context.Context, id string) (*View, error) {
|
|
return s.getView(ctx, `id = $1`, id)
|
|
}
|
|
|
|
func (s *Store) getView(ctx context.Context, where, arg string) (*View, error) {
|
|
row := s.Pool.QueryRow(ctx, `
|
|
SELECT id, slug, name, icon, filter_json,
|
|
sort_field, sort_dir, group_by,
|
|
sort_order, show_count, last_used_at,
|
|
created_at, updated_at
|
|
FROM projax.views
|
|
WHERE `+where, arg)
|
|
v, err := scanView(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrViewNotFound
|
|
}
|
|
return v, err
|
|
}
|
|
|
|
// MostRecentView returns the view with the most recent last_used_at. nil
|
|
// when no view has been touched yet (or none exist). Drives the /views
|
|
// landing redirect.
|
|
func (s *Store) MostRecentView(ctx context.Context) (*View, error) {
|
|
row := s.Pool.QueryRow(ctx, `
|
|
SELECT id, slug, name, icon, filter_json,
|
|
sort_field, sort_dir, group_by,
|
|
sort_order, show_count, last_used_at,
|
|
created_at, updated_at
|
|
FROM projax.views
|
|
WHERE last_used_at IS NOT NULL
|
|
ORDER BY last_used_at DESC
|
|
LIMIT 1`)
|
|
v, err := scanView(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return v, err
|
|
}
|
|
|
|
// CreateView inserts a new view. SortOrder is server-assigned to
|
|
// MAX(existing)+1 inside the same tx so two parallel creates don't
|
|
// collide on the index.
|
|
func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
|
if err := validateViewInput(in); err != nil {
|
|
return nil, err
|
|
}
|
|
if in.FilterJSON == nil {
|
|
in.FilterJSON = []byte("{}")
|
|
}
|
|
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
var nextOrder int
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COALESCE(MAX(sort_order), -1) + 1 FROM projax.views`,
|
|
).Scan(&nextOrder); err != nil {
|
|
return nil, fmt.Errorf("compute next sort_order: %w", err)
|
|
}
|
|
var id string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO projax.views
|
|
(slug, name, icon, filter_json, sort_field, sort_dir, group_by, sort_order, show_count)
|
|
VALUES
|
|
($1, $2, $3, $4::jsonb, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, $9)
|
|
RETURNING id`,
|
|
in.Slug, in.Name, in.Icon, in.FilterJSON,
|
|
in.SortField, in.SortDir, in.GroupBy, nextOrder, in.ShowCount,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
if isUniqueSlugViolation(err) {
|
|
return nil, ErrViewSlugTaken
|
|
}
|
|
return nil, fmt.Errorf("insert view: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit: %w", err)
|
|
}
|
|
return s.GetView(ctx, in.Slug)
|
|
}
|
|
|
|
// UpdateView replaces every writeable field on the row matching `slug`.
|
|
// To rename, pass the desired new slug in `in.Slug`; if it collides with
|
|
// another row, ErrViewSlugTaken surfaces.
|
|
func (s *Store) UpdateView(ctx context.Context, slug string, in ViewInput) (*View, error) {
|
|
if err := validateViewInput(in); err != nil {
|
|
return nil, err
|
|
}
|
|
if in.FilterJSON == nil {
|
|
in.FilterJSON = []byte("{}")
|
|
}
|
|
tag, err := s.Pool.Exec(ctx, `
|
|
UPDATE projax.views
|
|
SET slug = $2,
|
|
name = $3,
|
|
icon = $4,
|
|
filter_json = $5::jsonb,
|
|
sort_field = NULLIF($6,''),
|
|
sort_dir = NULLIF($7,''),
|
|
group_by = NULLIF($8,''),
|
|
show_count = $9
|
|
WHERE slug = $1`,
|
|
slug, in.Slug, in.Name, in.Icon, in.FilterJSON,
|
|
in.SortField, in.SortDir, in.GroupBy, in.ShowCount,
|
|
)
|
|
if err != nil {
|
|
if isUniqueSlugViolation(err) {
|
|
return nil, ErrViewSlugTaken
|
|
}
|
|
return nil, fmt.Errorf("update view: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, ErrViewNotFound
|
|
}
|
|
return s.GetView(ctx, in.Slug)
|
|
}
|
|
|
|
// DeleteView removes a view by slug. Hard delete (no soft-delete column
|
|
// in the redesign — single-user, no audit obligation). Idempotent only
|
|
// on the second call; first call against a non-existent row returns
|
|
// ErrViewNotFound.
|
|
func (s *Store) DeleteView(ctx context.Context, slug string) error {
|
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM projax.views WHERE slug = $1`, slug)
|
|
if err != nil {
|
|
return fmt.Errorf("delete view: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrViewNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TouchView bumps last_used_at to now(). Fire-and-forget from the render
|
|
// handler — failures are logged but never block the page.
|
|
func (s *Store) TouchView(ctx context.Context, slug string) error {
|
|
tag, err := s.Pool.Exec(ctx,
|
|
`UPDATE projax.views SET last_used_at = now() WHERE slug = $1`, slug)
|
|
if err != nil {
|
|
return fmt.Errorf("touch view: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrViewNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReorderViews applies a sort_order rewrite where the provided slugs map
|
|
// to ascending sort_order values starting at 0. Slugs not present in the
|
|
// input keep their existing sort_order. Drives slice G's drag-reorder UI.
|
|
func (s *Store) ReorderViews(ctx context.Context, slugs []string) error {
|
|
if len(slugs) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("begin: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
for i, slug := range slugs {
|
|
if _, err := tx.Exec(ctx,
|
|
`UPDATE projax.views SET sort_order = $1 WHERE slug = $2`,
|
|
i, slug,
|
|
); err != nil {
|
|
return fmt.Errorf("reorder %q: %w", slug, err)
|
|
}
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// validateViewInput runs Go-side guards. The DB CHECK constraints are the
|
|
// durable contract; these checks let handlers surface friendlier errors.
|
|
func validateViewInput(in ViewInput) error {
|
|
if err := ValidateSlug(in.Slug); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(in.Name) == "" {
|
|
return errors.New("view name is required")
|
|
}
|
|
if in.SortDir != "" && in.SortDir != "asc" && in.SortDir != "desc" {
|
|
return fmt.Errorf("invalid sort_dir %q", in.SortDir)
|
|
}
|
|
if in.Icon != nil && len(*in.Icon) > 64 {
|
|
return errors.New("icon key exceeds 64 characters")
|
|
}
|
|
if len(in.FilterJSON) > 0 {
|
|
var probe any
|
|
if err := json.Unmarshal(in.FilterJSON, &probe); err != nil {
|
|
return fmt.Errorf("filter_json is not valid JSON: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isUniqueSlugViolation matches the postgres unique_violation SQLSTATE
|
|
// (23505) on the views_slug_uniq index. We don't import pgconn here to
|
|
// avoid widening the package's dep surface; substring match on the
|
|
// pgx-formatted error covers both the wire-level codes pgx surfaces.
|
|
func isUniqueSlugViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return strings.Contains(s, "views_slug_uniq") ||
|
|
(strings.Contains(s, "SQLSTATE 23505") && strings.Contains(s, "slug"))
|
|
}
|
|
|
|
type viewScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanView(s viewScanner) (*View, error) {
|
|
v := &View{}
|
|
var icon, sortField, sortDir, groupBy *string
|
|
var lastUsedAt *time.Time
|
|
if err := s.Scan(
|
|
&v.ID, &v.Slug, &v.Name, &icon, &v.FilterJSON,
|
|
&sortField, &sortDir, &groupBy,
|
|
&v.SortOrder, &v.ShowCount, &lastUsedAt,
|
|
&v.CreatedAt, &v.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
v.Icon = icon
|
|
v.SortField = sortField
|
|
v.SortDir = sortDir
|
|
v.GroupBy = groupBy
|
|
v.LastUsedAt = lastUsedAt
|
|
return v, nil
|
|
}
|