Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.
Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
collapses the area/project distinction (kind keeps the slot but 'area'
is no longer a special value), drops the structural rules from the
path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
mai.projects without a 'mai-project' item_link, create a projax.items
row under a heuristic-chosen area (mhealth→health, msports/manjin→
sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
mhome→home, default→dev), insert the item_link, and tag the row
management=['mai']. Also flips management='mai' on any already-linked
pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
projax_admin and writes mai.projects directly (after the operator-run
grant + RLS policy widening — documented in the migration header).
sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
cycle suppressed. Slug stays the join key for new rows; the
item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
compute_item_paths walks via parents' precomputed paths (no recursive
CTE in the hot path; cycle detection uses one). New triggers:
items_check_slug_collision (multi-parent uniqueness),
items_after_delete (manual cascade since arrays don't carry FK).
Trigger refresh_item_paths_recursive does parent-first DFS over
descendants, guarded by projax.refreshing_paths GUC.
Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
OtherPaths helpers feed the detail breadcrumb. Source always
'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
(duplicated nodes in distinct branches). Tag-filter prune is
branch-preserving.
Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
"Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
bidirectional sync removed the dichotomy).
Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths inserts an item with two parents,
asserts both inherited paths.
- TestSlugCollisionUnderCommonParent refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow HTTP-level: /i/dev.X and
/i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].
Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.
mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
523 lines
14 KiB
Go
523 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
//go:embed templates/*.tmpl
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Server bundles handlers, templates, and the store.
|
|
type Server struct {
|
|
Store *store.Store
|
|
pages map[string]*template.Template
|
|
Logger *slog.Logger
|
|
Auth *AuthConfig // nil → no auth (local dev / tests)
|
|
}
|
|
|
|
// New builds a Server. Each page is parsed alongside the layout into its own
|
|
// Template so per-page `define "content"` blocks don't shadow each other. The
|
|
// login page is intentionally NOT wrapped in the regular layout (chrome would
|
|
// imply you're already inside the app).
|
|
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
funcs := template.FuncMap{
|
|
"deref": func(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
},
|
|
"join": func(sep string, parts []string) string { return strings.Join(parts, sep) },
|
|
"contains": func(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if h == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
|
next := []string{}
|
|
if isActive {
|
|
for _, t := range active {
|
|
if t != tag {
|
|
next = append(next, t)
|
|
}
|
|
}
|
|
} else {
|
|
next = append(next, active...)
|
|
next = append(next, tag)
|
|
}
|
|
if len(next) == 0 {
|
|
return "/"
|
|
}
|
|
return "/?tag=" + strings.Join(next, ",")
|
|
},
|
|
}
|
|
pages := map[string]*template.Template{}
|
|
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
|
|
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/"+name+".tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", name, err)
|
|
}
|
|
pages[name] = t
|
|
}
|
|
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse login: %w", err)
|
|
}
|
|
pages["login"] = loginTmpl
|
|
return &Server{Store: s, pages: pages, Logger: logger}, nil
|
|
}
|
|
|
|
// Routes wires every URL to a handler and returns the mux.
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /", s.handleTree)
|
|
mux.HandleFunc("GET /i/", s.handleDetail)
|
|
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
|
mux.HandleFunc("GET /new", s.handleNewForm)
|
|
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
|
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.Store.Pool.Ping(r.Context()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "ok")
|
|
})
|
|
|
|
static, _ := fs.Sub(staticFS, "static")
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
|
|
|
var h http.Handler = mux
|
|
if s.Auth != nil {
|
|
h = authMiddleware(*s.Auth, s.Logger, h)
|
|
}
|
|
return logging(s.Logger, h)
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
type treeNode struct {
|
|
Item *store.Item
|
|
Children []*treeNode
|
|
}
|
|
|
|
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
items, err := s.Store.ListAll(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
tags, err := s.Store.AllTags(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
activeTags := parseCSV(r.URL.Query().Get("tag"))
|
|
roots, orphans, total, orphanN := buildForest(items, activeTags)
|
|
s.render(w, "tree", map[string]any{
|
|
"Title": "tree",
|
|
"Roots": roots,
|
|
"Orphans": orphans,
|
|
"Total": total,
|
|
"OrphanN": orphanN,
|
|
"AllTags": tags,
|
|
"ActiveTags": activeTags,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
|
if path == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parents, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
s.render(w, "detail", map[string]any{
|
|
"Title": it.Title,
|
|
"Item": it,
|
|
"ParentOptions": parents,
|
|
"StatusOptions": []string{"active", "done", "archived"},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
|
if base, ok := strings.CutSuffix(path, "/reparent"); ok {
|
|
s.handleReparent(w, r, base)
|
|
return
|
|
}
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
// Legacy single-value field for the classify HTMX action.
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
in := store.UpdateInput{
|
|
Title: strings.TrimSpace(r.FormValue("title")),
|
|
Slug: strings.TrimSpace(r.FormValue("slug")),
|
|
ParentIDs: parentIDs,
|
|
ContentMD: r.FormValue("content_md"),
|
|
Status: strings.TrimSpace(r.FormValue("status")),
|
|
Pinned: r.FormValue("pinned") == "1",
|
|
Archived: r.FormValue("archived") == "1",
|
|
Tags: parseCSV(r.FormValue("tags")),
|
|
Management: parseCSV(r.FormValue("management")),
|
|
}
|
|
updated, err := s.Store.Update(r.Context(), it.ID, in)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
// handleReparent replaces parent_ids. /admin/classify uses this to move
|
|
// a root mai-managed item under a chosen parent without touching other fields.
|
|
// HTMX-friendly: returns a fragment when HX-Request is set.
|
|
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
if len(parentIDs) == 0 {
|
|
http.Error(w, "reparent: parent_ids required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
fmt.Fprintf(w, `<tr class="classified"><td colspan="5">Moved to <a href="/i/%s">%s</a></td></tr>`,
|
|
template.HTMLEscapeString(moved.PrimaryPath()), template.HTMLEscapeString(moved.PrimaryPath()))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+moved.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
// dedupeStrings preserves order, drops empties.
|
|
func dedupeStrings(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
|
|
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
|
|
// JSON/SQL writes get an explicit empty array).
|
|
func parseCSV(raw string) []string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return []string{}
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
|
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
|
}) {
|
|
t := strings.ToLower(strings.TrimSpace(part))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
|
parentPath := r.URL.Query().Get("parent")
|
|
var parent *store.Item
|
|
if parentPath != "" {
|
|
p, err := s.Store.GetByPath(r.Context(), parentPath)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parent = p
|
|
}
|
|
s.render(w, "new", map[string]any{
|
|
"Title": "new",
|
|
"Parent": parent,
|
|
"StatusOptions": []string{"active", "done", "archived"},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
kind := strings.TrimSpace(r.FormValue("kind"))
|
|
if kind == "" {
|
|
kind = "project"
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
in := store.CreateInput{
|
|
Kind: []string{kind},
|
|
Title: strings.TrimSpace(r.FormValue("title")),
|
|
Slug: strings.TrimSpace(r.FormValue("slug")),
|
|
ParentIDs: parentIDs,
|
|
ContentMD: r.FormValue("content_md"),
|
|
Status: strings.TrimSpace(r.FormValue("status")),
|
|
Tags: parseCSV(r.FormValue("tags")),
|
|
Management: parseCSV(r.FormValue("management")),
|
|
}
|
|
it, err := s.Store.Create(r.Context(), in)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
|
orphans, err := s.Store.MaiOrphans(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parents, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
s.render(w, "classify", map[string]any{
|
|
"Title": "classify",
|
|
"Orphans": orphans,
|
|
"ParentOptions": parents,
|
|
})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
// ParentOption is a flat option for the parent <select>.
|
|
type ParentOption struct {
|
|
ID string
|
|
Path string
|
|
}
|
|
|
|
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
|
items, err := s.Store.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []ParentOption
|
|
for _, it := range items {
|
|
// Surface every primary path as a candidate parent — multi-parent
|
|
// items appear once per parent option using their primary path so the
|
|
// UI stays unambiguous.
|
|
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out, nil
|
|
}
|
|
|
|
// buildForest groups items_unified rows by parent into a sortable tree. A
|
|
// multi-parent item appears under EACH of its parents (duplicated nodes in
|
|
// distinct branches). When activeTags is non-empty, a branch is kept only
|
|
// when it (or any descendant) matches every active tag. orphans lists
|
|
// mai-managed root items so /admin/classify and the tree page can surface
|
|
// them.
|
|
func buildForest(items []*store.Item, activeTags []string) (roots []*treeNode, orphans []*store.Item, total, orphanN int) {
|
|
for _, it := range items {
|
|
total++
|
|
if len(it.ParentIDs) == 0 && it.HasManagement("mai") {
|
|
orphans = append(orphans, it)
|
|
orphanN++
|
|
}
|
|
}
|
|
// Build a forest where every parent relationship creates a node — so
|
|
// multi-parent items are rendered under each parent. Root items get
|
|
// one node total.
|
|
nodeFor := func(it *store.Item) *treeNode { return &treeNode{Item: it} }
|
|
rootNodes := []*treeNode{}
|
|
childByParent := make(map[string][]*treeNode, len(items))
|
|
for _, it := range items {
|
|
if len(it.ParentIDs) == 0 {
|
|
rootNodes = append(rootNodes, nodeFor(it))
|
|
continue
|
|
}
|
|
for _, pid := range it.ParentIDs {
|
|
childByParent[pid] = append(childByParent[pid], nodeFor(it))
|
|
}
|
|
}
|
|
var attach func(n *treeNode)
|
|
attach = func(n *treeNode) {
|
|
n.Children = childByParent[n.Item.ID]
|
|
for _, c := range n.Children {
|
|
attach(c)
|
|
}
|
|
}
|
|
for _, r := range rootNodes {
|
|
attach(r)
|
|
}
|
|
roots = rootNodes
|
|
if len(activeTags) > 0 {
|
|
var keep func(n *treeNode) bool
|
|
keep = func(n *treeNode) bool {
|
|
filtered := n.Children[:0]
|
|
for _, c := range n.Children {
|
|
if keep(c) {
|
|
filtered = append(filtered, c)
|
|
}
|
|
}
|
|
n.Children = filtered
|
|
if nodeHasAllTags(n.Item, activeTags) {
|
|
return true
|
|
}
|
|
return len(n.Children) > 0
|
|
}
|
|
filtered := roots[:0]
|
|
for _, n := range roots {
|
|
if keep(n) {
|
|
filtered = append(filtered, n)
|
|
}
|
|
}
|
|
roots = filtered
|
|
}
|
|
sort.Slice(roots, func(i, j int) bool { return roots[i].Item.Slug < roots[j].Item.Slug })
|
|
sort.Slice(orphans, func(i, j int) bool { return orphans[i].Slug < orphans[j].Slug })
|
|
var sortChildren func(n *treeNode)
|
|
sortChildren = func(n *treeNode) {
|
|
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
|
|
for _, c := range n.Children {
|
|
sortChildren(c)
|
|
}
|
|
}
|
|
for _, r := range roots {
|
|
sortChildren(r)
|
|
}
|
|
return
|
|
}
|
|
|
|
func nodeHasAllTags(it *store.Item, want []string) bool {
|
|
for _, t := range want {
|
|
if !it.HasTag(t) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
|
|
t, ok := s.pages[name]
|
|
if !ok {
|
|
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
entry := "layout"
|
|
if name == "login" {
|
|
// Login page is intentionally standalone — no nav chrome.
|
|
entry = "login"
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := t.ExecuteTemplate(w, entry, data); err != nil {
|
|
s.Logger.Error("render", "page", name, "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
w.WriteHeader(status)
|
|
s.render(w, "error", map[string]any{
|
|
"Title": "error",
|
|
"Message": err.Error(),
|
|
})
|
|
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
|
|
}
|
|
|
|
// logging wraps the mux with a tiny access log.
|
|
func logging(logger *slog.Logger, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
next.ServeHTTP(w, r)
|
|
logger.Info("req", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
|
})
|
|
}
|