feat(t-paliad-149) PR1 step 1/3: backend — migration 060 + PinService + BuildTreeWithOptions

Migration 060 (paliad.user_pinned_projects): per-user, RLS owner-only, ON
DELETE CASCADE on both FKs.

PinService (Pin / Unpin / IsPinned / PinnedSet / ListPinned): visibility-
gates pin (can't pin what you can't see) but not unpin (so users can clean
up after losing access). PinnedSet returns a map for O(1) lookups during
tree stitching.

ProjectService.BuildTreeWithOptions extends BuildTree with chip-driven
filtering. New ProjectTreeNode fields are additive (Pinned,
InheritedVisibility, OpenDeadlinesSubtree, OverdueDeadlinesSubtree,
MatchKind) so the old BuildTree(ctx, userID) call still works for legacy
callers. New options:

  Scope: All / Mine / Pinned (Mine + Pinned both expand to path-closure
  with InheritedVisibility flag on greyed ancestors)
  StatusIn / TypeIn: chip-narrowing whitelists
  HasOpenDeadlines: per-node or subtree-aggregated, depending on
  IncludeSubtreeCounts
  SearchTerm: case-fold contains on title/reference/clientmatter, then
  prune to {matches ∪ ancestors ∪ descendants} with match_kind tagged
  IncludeSubtreeCounts: post-order DFS sums, O(N)

GET /api/projects/tree gains query params: scope, status, type,
has_open_deadlines, q, subtree_counts. Zero query string preserves
legacy behaviour.

POST/DELETE /api/projects/{id}/pin and GET /api/user-pinned-projects
wired. Service registered in cmd/server/main.go and dbServices.

build + vet clean.

Design: docs/design-projects-page-2026-05-07.md §4.7, §8.1, §8.3.
This commit is contained in:
m
2026-05-07 22:21:45 +02:00
parent 438e73fd13
commit 8412328dec
8 changed files with 705 additions and 9 deletions

View File

@@ -161,6 +161,7 @@ func main() {
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
}
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL

View File

@@ -0,0 +1,3 @@
-- Reverse of 060_user_pinned_projects.up.sql.
DROP TABLE IF EXISTS paliad.user_pinned_projects;

View File

@@ -0,0 +1,53 @@
-- t-paliad-149 PR 1: per-user project pins.
--
-- Design: docs/design-projects-page-2026-05-07.md §4.7 (godel,
-- m-locked 2026-05-07).
--
-- Stores per-user pinned projects. A user pins a project to mark it
-- as a favourite for the /projects page (chip "Angepinnt" filters
-- the tree to pinned-only; star marker on every row toggles state).
--
-- RLS scopes every operation to the calling user — pins are personal
-- working state, not project-team metadata. There is no cross-user
-- visibility in v1; no global_admin override.
--
-- ON DELETE CASCADE on both FKs: project deletion removes pin rows;
-- user deletion removes their pins. No referential drift possible.
--
-- Sections:
-- 1. CREATE paliad.user_pinned_projects (with RLS).
-- 2. Indexes.
-- ============================================================================
-- 1. paliad.user_pinned_projects
-- ============================================================================
CREATE TABLE paliad.user_pinned_projects (
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
pinned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, project_id)
);
-- ============================================================================
-- 2. Indexes
-- ============================================================================
-- Hot path: list pins for a user, most-recently-pinned first. The PK
-- already covers (user_id, project_id), but a separate index ordered
-- by pinned_at DESC keeps the "Angepinnt" filter chip fast even as a
-- user accumulates many pins.
CREATE INDEX user_pinned_projects_user_idx
ON paliad.user_pinned_projects (user_id, pinned_at DESC);
-- ============================================================================
-- 3. RLS
-- ============================================================================
ALTER TABLE paliad.user_pinned_projects ENABLE ROW LEVEL SECURITY;
-- Owner-only access. No global_admin override.
CREATE POLICY user_pinned_projects_owner_all
ON paliad.user_pinned_projects FOR ALL
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());

View File

@@ -66,6 +66,7 @@ type Services struct {
Derivation *services.DerivationService
UserView *services.UserViewService
Broadcast *services.BroadcastService
Pin *services.PinService
// Paliadin is wired only when PALIADIN_ENABLED=true at boot
// (PoC; m's laptop only). On prod it stays nil and all /paliadin*
@@ -113,6 +114,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
derivation: svc.Derivation,
userView: svc.UserView,
broadcast: svc.Broadcast,
pin: svc.Pin,
}
}
@@ -210,6 +212,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents)
protected.HandleFunc("GET /api/projects/{id}/children", handleListProjectChildren)
protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjectTree)
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)
protected.HandleFunc("DELETE /api/projects/{id}/pin", handleUnpinProject)
protected.HandleFunc("GET /api/user-pinned-projects", handleListPinnedProjects)
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)

91
internal/handlers/pins.go Normal file
View File

@@ -0,0 +1,91 @@
package handlers
import (
"net/http"
"github.com/google/uuid"
)
// POST /api/projects/{id}/pin — idempotent pin. Returns 201 (or 200 if
// already pinned). Empty body. Visibility-gated by PinService.
func handlePinProject(w http.ResponseWriter, r *http.Request) {
if !requireDB(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 dbSvc.pin == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pin service not configured"})
return
}
if err := dbSvc.pin.Pin(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusCreated)
}
// DELETE /api/projects/{id}/pin — idempotent unpin. Always returns 204.
// Does NOT gate on visibility (so users can clean up pins on projects
// they've lost access to).
func handleUnpinProject(w http.ResponseWriter, r *http.Request) {
if !requireDB(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 dbSvc.pin == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pin service not configured"})
return
}
if err := dbSvc.pin.Unpin(r.Context(), uid, id); err != nil {
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GET /api/user-pinned-projects — flat list of project IDs the user has
// pinned, most-recent-pin first. Used by the (deferred) sidebar pin widget;
// the /projects tree response carries `pinned: bool` per node, so the
// /projects page itself doesn't hit this endpoint.
func handleListPinnedProjects(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.pin == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pin service not configured"})
return
}
ids, err := dbSvc.pin.ListPinned(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
type row struct {
ProjectID uuid.UUID `json:"project_id"`
}
out := make([]row, len(ids))
for i, id := range ids {
out[i] = row{ProjectID: id}
}
writeJSON(w, http.StatusOK, out)
}

View File

@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
@@ -46,6 +47,7 @@ type dbServices struct {
derivation *services.DerivationService
userView *services.UserViewService
broadcast *services.BroadcastService
pin *services.PinService
}
var dbSvc *dbServices
@@ -245,6 +247,17 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
// GET /api/projects/tree — nested tree of every visible Project. Each node
// carries open/overdue deadline counts and embedded children so the UI can
// render the full hierarchy in one round-trip. Visibility-scoped.
//
// Query parameters (all optional, additive):
// ?scope=all|mine|pinned — chip-driven scope (default "all")
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
// ?type=client,litigation,patent,case,project — type whitelist
// ?has_open_deadlines=true|false — narrow by deadline activity
// ?q=<term> — search title / reference / clientmatter
// ?subtree_counts=true|false — populate *_subtree fields (default true)
//
// Zero query string preserves the legacy behaviour for back-compat (existing
// callers that just want every visible project).
func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -253,7 +266,41 @@ func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
tree, err := dbSvc.projects.BuildTree(r.Context(), uid)
q := r.URL.Query()
opts := services.BuildTreeOptions{
IncludeSubtreeCounts: parseBoolQuery(q.Get("subtree_counts"), true),
SearchTerm: q.Get("q"),
StatusIn: splitCSV(q.Get("status")),
TypeIn: splitCSV(q.Get("type")),
}
switch q.Get("scope") {
case "mine":
opts.Scope = services.ScopeMine
case "pinned":
opts.Scope = services.ScopePinned
}
if v := q.Get("has_open_deadlines"); v != "" {
b := parseBoolQuery(v, false)
opts.HasOpenDeadlines = &b
}
// Pin set is needed when the response carries `pinned: bool` per node
// (always, when PinService is wired) AND when scope=pinned narrows.
if dbSvc.pin != nil {
set, err := dbSvc.pin.PinnedSet(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
opts.PinnedSet = set
} else if opts.Scope == services.ScopePinned {
// scope=pinned without PinService can never have hits.
writeJSON(w, http.StatusOK, []any{})
return
}
tree, err := dbSvc.projects.BuildTreeWithOptions(r.Context(), uid, opts)
if err != nil {
writeServiceError(w, err)
return
@@ -261,6 +308,39 @@ func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, tree)
}
// parseBoolQuery accepts true/false/1/0/yes/no/on/off (case-insensitive).
// Falls back to def for empty / unrecognised input.
func parseBoolQuery(v string, def bool) bool {
switch v {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return def
}
}
// splitCSV splits a comma-separated query value into trimmed non-empty
// tokens. Empty input → nil so callers can branch on `len(out) > 0`.
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
// GET /api/projects/{id}/tree — full subtree depth-first (path-ordered).
func handleGetProjectTree(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {

View File

@@ -0,0 +1,114 @@
package services
// PinService manages paliad.user_pinned_projects — per-user project pins
// for the /projects page (chip "Angepinnt" + star marker).
//
// Design: docs/design-projects-page-2026-05-07.md §4.7.
//
// Visibility: pin/unpin gate on can_see_project — a user can't pin what
// they can't see. Reads of the pinned-set are RLS-scoped (user_id = auth.uid())
// AND code-checked for defense-in-depth.
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// PinService manages paliad.user_pinned_projects.
type PinService struct {
db *sqlx.DB
projects *ProjectService
}
// NewPinService wires the service.
func NewPinService(db *sqlx.DB, projects *ProjectService) *PinService {
return &PinService{db: db, projects: projects}
}
// Pin records (userID, projectID) as a pin. Idempotent — re-pinning is a
// no-op (existing pinned_at preserved). Returns ErrNotVisible if the user
// cannot see the project.
func (s *PinService) Pin(ctx context.Context, userID, projectID uuid.UUID) error {
visible, err := s.projects.CanSee(ctx, userID, projectID)
if err != nil {
return err
}
if !visible {
return ErrNotVisible
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO paliad.user_pinned_projects (user_id, project_id)
VALUES ($1, $2)
ON CONFLICT (user_id, project_id) DO NOTHING
`, userID, projectID)
if err != nil {
return fmt.Errorf("pin project: %w", err)
}
return nil
}
// Unpin removes the pin. Idempotent — unpinning a non-pinned project is a
// no-op. Does NOT gate on visibility (unpinning a project you've lost
// access to should still work — otherwise the pin row leaks forever).
func (s *PinService) Unpin(ctx context.Context, userID, projectID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, `
DELETE FROM paliad.user_pinned_projects
WHERE user_id = $1 AND project_id = $2
`, userID, projectID)
if err != nil {
return fmt.Errorf("unpin project: %w", err)
}
return nil
}
// IsPinned reports whether the user has pinned the project.
func (s *PinService) IsPinned(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
var pinned bool
err := s.db.GetContext(ctx, &pinned, `
SELECT EXISTS (SELECT 1 FROM paliad.user_pinned_projects
WHERE user_id = $1 AND project_id = $2)
`, userID, projectID)
if err != nil {
return false, fmt.Errorf("check pin: %w", err)
}
return pinned, nil
}
// PinnedSet returns the set of project_ids the user has pinned. Used by
// BuildTree to populate per-node `pinned: bool`.
func (s *PinService) PinnedSet(ctx context.Context, userID uuid.UUID) (map[uuid.UUID]struct{}, error) {
var ids []uuid.UUID
err := s.db.SelectContext(ctx, &ids, `
SELECT project_id FROM paliad.user_pinned_projects
WHERE user_id = $1
ORDER BY pinned_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list pinned: %w", err)
}
set := make(map[uuid.UUID]struct{}, len(ids))
for _, id := range ids {
set[id] = struct{}{}
}
return set, nil
}
// ListPinned returns project_ids in pinned-at-DESC order. Used by the
// (deferred) sidebar pin widget; not used by the tree response (which
// prefers the set form via PinnedSet for O(1) lookups during stitching).
func (s *PinService) ListPinned(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := s.db.SelectContext(ctx, &ids, `
SELECT project_id FROM paliad.user_pinned_projects
WHERE user_id = $1
ORDER BY pinned_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list pinned ordered: %w", err)
}
return ids, nil
}

View File

@@ -23,6 +23,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
@@ -313,11 +314,77 @@ func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID
// ProjectTreeNode is one node of the nested tree returned by BuildTree.
// It embeds the full Project plus aggregated child nodes and deadline
// counts so the UI can render badges without per-row API calls.
//
// Subtree counts (OpenDeadlinesSubtree / OverdueDeadlinesSubtree) and
// the chip-driven flags (Pinned, InheritedVisibility, MatchKind) are
// only populated when BuildTreeWithOptions is called with the relevant
// options enabled. The legacy BuildTree(ctx, userID) call leaves them
// at zero / empty for back-compat.
type ProjectTreeNode struct {
models.Project
Children []*ProjectTreeNode `json:"children"`
OpenDeadlines int `json:"open_deadlines"`
OverdueDeadlines int `json:"overdue_deadlines"`
Children []*ProjectTreeNode `json:"children"`
OpenDeadlines int `json:"open_deadlines"`
OverdueDeadlines int `json:"overdue_deadlines"`
OpenDeadlinesSubtree int `json:"open_deadlines_subtree"`
OverdueDeadlinesSubtree int `json:"overdue_deadlines_subtree"`
Pinned bool `json:"pinned"`
InheritedVisibility bool `json:"inherited_visibility"`
// MatchKind is empty unless a search term is active. Values:
// "self" (direct match), "ancestor" (on the path to a match),
// "descendant" (under a match).
MatchKind string `json:"match_kind,omitempty"`
}
// TreeScope discriminates the chip-driven scope filter for BuildTreeWithOptions.
type TreeScope string
const (
// ScopeAll returns every visible project (default).
ScopeAll TreeScope = ""
// ScopeMine returns directly-staffed projects + their visible ancestors
// (ancestors flagged InheritedVisibility=true so the UI can render them
// greyed for context).
ScopeMine TreeScope = "mine"
// ScopePinned returns only projects in paliad.user_pinned_projects for
// the user. Ancestors are NOT auto-included (the chip is "show me the
// pinned set, period").
ScopePinned TreeScope = "pinned"
)
// BuildTreeOptions controls BuildTreeWithOptions. Zero value yields the
// legacy BuildTree behaviour (every visible project, per-node counts).
type BuildTreeOptions struct {
// Scope is the chip-driven scope filter ("Alle" / "Nur meine" / "Angepinnt").
Scope TreeScope
// PinnedSet is the user's pinned-project set, populated by the handler
// from PinService.PinnedSet so BuildTree doesn't need a PinService dep.
// nil → no pin information attached (Pinned=false on every node).
PinnedSet map[uuid.UUID]struct{}
// StatusIn narrows to rows whose status ∈ values. Empty = no narrowing.
StatusIn []string
// TypeIn narrows to rows whose type ∈ values. Empty = no narrowing.
TypeIn []string
// HasOpenDeadlines, when non-nil, narrows to rows with at least one
// pending deadline (true) or zero pending deadlines (false). Applied
// AFTER subtree-count computation so the count itself drives the gate.
HasOpenDeadlines *bool
// SearchTerm filters to nodes whose title / reference / clientmatter /
// ancestor title matches. Match-kind is tagged per node:
// "self" — direct hit
// "ancestor" — on the path to a hit
// "descendant" — under a hit (kept for context; same subtree)
// Empty = no search.
SearchTerm string
// IncludeSubtreeCounts populates OpenDeadlinesSubtree +
// OverdueDeadlinesSubtree. Default: true. Set false for the
// legacy per-node-only behaviour.
IncludeSubtreeCounts bool
}
// BuildTree returns the full nested tree of every Project the user can see,
@@ -325,7 +392,23 @@ type ProjectTreeNode struct {
// overdue deadline counts (open=pending, overdue=pending&past-due) so the UI
// can render status badges with no extra round-trips. Path-sorted so callers
// get a stable deterministic ordering.
//
// This is a thin shim over BuildTreeWithOptions for back-compat with callers
// that just want every visible project. New callers (the /projects page
// post-t-paliad-149) should use BuildTreeWithOptions directly to access
// chip filters + subtree counts + pinning + search.
func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*ProjectTreeNode, error) {
return s.BuildTreeWithOptions(ctx, userID, BuildTreeOptions{
// Default IncludeSubtreeCounts=false: BuildTree's existing callers
// (the existing tree view) read OpenDeadlines / OverdueDeadlines
// per-node. Subtree counts are opt-in by the new /projects page
// via BuildTreeWithOptions.
})
}
// BuildTreeWithOptions is the chip-aware tree builder. See BuildTreeOptions
// for the knobs. Returns nodes in path order.
func (s *ProjectService) BuildTreeWithOptions(ctx context.Context, userID uuid.UUID, opts BuildTreeOptions) ([]*ProjectTreeNode, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
@@ -334,6 +417,11 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
return []*ProjectTreeNode{}, nil
}
// Step 1 — load every visible project (path-ordered). The chip filters
// (status / type / search) narrow the selection, but Scope=Mine and
// the subtree-count aggregation BOTH need the full visible set so we
// can include greyed ancestors and aggregate counts up the tree. The
// final filtering happens in-memory after the tree is stitched.
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
WHERE ` + visibilityPredicatePositional("p", 1) + `
ORDER BY p.path`
@@ -342,6 +430,7 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
return nil, fmt.Errorf("build tree list: %w", err)
}
// Step 2 — per-node deadline counts (always; cheap one-shot query).
type deadlineCount struct {
ProjectID uuid.UUID `db:"project_id"`
Open int `db:"open"`
@@ -365,20 +454,42 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
countByID[c.ProjectID] = c
}
// Step 3 — for ScopeMine, load directly-staffed project IDs. For
// ScopePinned, the PinnedSet is the source of truth.
var directlyStaffed map[uuid.UUID]struct{}
if opts.Scope == ScopeMine {
var ids []uuid.UUID
if err := s.db.SelectContext(ctx, &ids, `
SELECT DISTINCT pt.project_id
FROM paliad.project_teams pt
WHERE pt.user_id = $1
`, userID); err != nil {
return nil, fmt.Errorf("build tree direct staffing: %w", err)
}
directlyStaffed = make(map[uuid.UUID]struct{}, len(ids))
for _, id := range ids {
directlyStaffed[id] = struct{}{}
}
}
// Step 4 — build node map + stitch the full tree.
nodes := make(map[uuid.UUID]*ProjectTreeNode, len(rows))
for i := range rows {
c := countByID[rows[i].ID]
nodes[rows[i].ID] = &ProjectTreeNode{
n := &ProjectTreeNode{
Project: rows[i],
Children: []*ProjectTreeNode{},
OpenDeadlines: c.Open,
OverdueDeadlines: c.Overdue,
}
if opts.PinnedSet != nil {
if _, pinned := opts.PinnedSet[rows[i].ID]; pinned {
n.Pinned = true
}
}
nodes[rows[i].ID] = n
}
// Stitch children into parents. Roots are projects whose parent_id is
// NULL or whose parent is invisible (orphaned in the user's view) —
// promote those to root so the user still sees them.
roots := []*ProjectTreeNode{}
for _, n := range nodes {
if n.ParentID == nil {
@@ -392,11 +503,249 @@ func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*Pr
}
parent.Children = append(parent.Children, n)
}
sortTreeByPath(roots)
// Step 5 — subtree-aggregated counts (post-order DFS sums each node's
// own counts plus every descendant's). Cheap (O(N)).
if opts.IncludeSubtreeCounts {
var aggregate func(n *ProjectTreeNode) (open, overdue int)
aggregate = func(n *ProjectTreeNode) (int, int) {
open := n.OpenDeadlines
overdue := n.OverdueDeadlines
for _, c := range n.Children {
co, cv := aggregate(c)
open += co
overdue += cv
}
n.OpenDeadlinesSubtree = open
n.OverdueDeadlinesSubtree = overdue
return open, overdue
}
for _, r := range roots {
aggregate(r)
}
}
// Step 6 — apply Scope filter. ScopeAll: no-op. ScopeMine: keep
// directly-staffed nodes + their ancestors (flagged InheritedVisibility
// for grey-rendering). ScopePinned: keep pinned nodes + their ancestors.
switch opts.Scope {
case ScopeMine:
keep := pathClosure(nodes, directlyStaffed)
markInherited(nodes, keep, directlyStaffed)
roots = filterTree(roots, keep)
case ScopePinned:
if opts.PinnedSet == nil {
// No pin set provided → empty tree.
roots = nil
break
}
keep := pathClosure(nodes, opts.PinnedSet)
markInherited(nodes, keep, opts.PinnedSet)
roots = filterTree(roots, keep)
}
// Step 7 — chip filters (status / type / has-open-deadlines). We keep
// nodes that match AND any ancestors needed to root them (so the tree
// shape is preserved). Directly-narrowing children would orphan them.
if len(opts.StatusIn) > 0 || len(opts.TypeIn) > 0 || opts.HasOpenDeadlines != nil {
match := func(n *ProjectTreeNode) bool {
if len(opts.StatusIn) > 0 && !containsString(opts.StatusIn, n.Status) {
return false
}
if len(opts.TypeIn) > 0 && !containsString(opts.TypeIn, n.Type) {
return false
}
if opts.HasOpenDeadlines != nil {
openCount := n.OpenDeadlines
if opts.IncludeSubtreeCounts {
openCount = n.OpenDeadlinesSubtree
}
if *opts.HasOpenDeadlines {
if openCount == 0 {
return false
}
} else {
if openCount != 0 {
return false
}
}
}
return true
}
matched := matchSet(nodes, match)
keep := pathClosure(nodes, matched)
// Don't flip InheritedVisibility for chip filters — only Scope=Mine /
// Scope=Pinned want greyed ancestors. Chips should narrow cleanly.
roots = filterTree(roots, keep)
}
// Step 8 — search. Tags every visible node with match_kind and prunes
// to the union of {matches ancestors-of-matches descendants-of-matches}.
if term := strings.TrimSpace(opts.SearchTerm); term != "" {
applySearch(nodes, &roots, term)
}
return roots, nil
}
// pathClosure expands a seed set of project IDs into the closure that
// includes every ancestor (via the materialised path) so a filtered tree
// stays connected to its roots. The output set always contains every seed.
func pathClosure(nodes map[uuid.UUID]*ProjectTreeNode, seeds map[uuid.UUID]struct{}) map[uuid.UUID]struct{} {
keep := make(map[uuid.UUID]struct{}, len(seeds))
for id := range seeds {
n, ok := nodes[id]
if !ok {
continue
}
keep[id] = struct{}{}
// Walk path labels, skipping empty splits.
for label := range strings.SplitSeq(n.Path, ".") {
if label == "" {
continue
}
anc, err := uuid.Parse(label)
if err != nil {
continue
}
if _, vis := nodes[anc]; vis {
keep[anc] = struct{}{}
}
}
}
return keep
}
// markInherited flips InheritedVisibility=true on nodes that are in the
// keep set but NOT in the directly-staffed seed set. The UI greys these
// rows so users understand they're context-only (visibility derives from
// path closure, not direct staffing).
func markInherited(nodes map[uuid.UUID]*ProjectTreeNode, keep, seed map[uuid.UUID]struct{}) {
for id := range keep {
if _, direct := seed[id]; direct {
continue
}
if n, ok := nodes[id]; ok {
n.InheritedVisibility = true
}
}
}
// filterTree returns a new root list containing only nodes in keep, with
// each surviving node's Children also pruned to keep. Children of pruned
// nodes are dropped entirely (the path-closure step is what guarantees
// matched nodes remain rooted).
func filterTree(roots []*ProjectTreeNode, keep map[uuid.UUID]struct{}) []*ProjectTreeNode {
out := make([]*ProjectTreeNode, 0, len(roots))
for _, r := range roots {
if _, ok := keep[r.ID]; !ok {
continue
}
r.Children = filterTree(r.Children, keep)
out = append(out, r)
}
return out
}
// matchSet returns the set of node IDs for which match(node) returns true.
func matchSet(nodes map[uuid.UUID]*ProjectTreeNode, match func(*ProjectTreeNode) bool) map[uuid.UUID]struct{} {
out := make(map[uuid.UUID]struct{})
for id, n := range nodes {
if match(n) {
out[id] = struct{}{}
}
}
return out
}
// applySearch tags MatchKind on the visible nodes and prunes the tree to
// keep only nodes whose subtree contains a match (or which are themselves
// a match). Match scope: case-fold contains on title, reference,
// client_number, matter_number. Ancestor titles match too via the
// pathClosure semantics.
func applySearch(nodes map[uuid.UUID]*ProjectTreeNode, roots *[]*ProjectTreeNode, term string) {
q := strings.ToLower(term)
matches := make(map[uuid.UUID]struct{})
for id, n := range nodes {
if matchesSearch(n, q) {
matches[id] = struct{}{}
}
}
if len(matches) == 0 {
*roots = []*ProjectTreeNode{}
return
}
// Path closure includes ancestors. Descendants of matches are also kept
// (rendered as "descendant" so the user sees the full sub-context).
keep := pathClosure(nodes, matches)
descSet := make(map[uuid.UUID]struct{})
addDescendants(nodes, matches, descSet)
for id := range descSet {
keep[id] = struct{}{}
}
for id := range keep {
n, ok := nodes[id]
if !ok {
continue
}
switch {
case isInSet(matches, id):
n.MatchKind = "self"
case isInSet(descSet, id):
n.MatchKind = "descendant"
default:
n.MatchKind = "ancestor"
}
}
*roots = filterTree(*roots, keep)
}
func matchesSearch(n *ProjectTreeNode, q string) bool {
if strings.Contains(strings.ToLower(n.Title), q) {
return true
}
if n.Reference != nil && strings.Contains(strings.ToLower(*n.Reference), q) {
return true
}
if n.ClientNumber != nil && strings.Contains(strings.ToLower(*n.ClientNumber), q) {
return true
}
if n.MatterNumber != nil && strings.Contains(strings.ToLower(*n.MatterNumber), q) {
return true
}
return false
}
func addDescendants(nodes map[uuid.UUID]*ProjectTreeNode, seeds, out map[uuid.UUID]struct{}) {
for seedID := range seeds {
seed, ok := nodes[seedID]
if !ok {
continue
}
prefix := seed.Path + "."
for id, n := range nodes {
if id == seedID {
continue
}
if strings.HasPrefix(n.Path, prefix) {
out[id] = struct{}{}
}
}
}
}
func isInSet(set map[uuid.UUID]struct{}, id uuid.UUID) bool {
_, ok := set[id]
return ok
}
func containsString(haystack []string, needle string) bool {
return slices.Contains(haystack, needle)
}
func sortTreeByPath(nodes []*ProjectTreeNode) {
for i := 1; i < len(nodes); i++ {
for j := i; j > 0 && nodes[j].Path < nodes[j-1].Path; j-- {