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:
@@ -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
|
||||
|
||||
3
internal/db/migrations/060_user_pinned_projects.down.sql
Normal file
3
internal/db/migrations/060_user_pinned_projects.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Reverse of 060_user_pinned_projects.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_pinned_projects;
|
||||
53
internal/db/migrations/060_user_pinned_projects.up.sql
Normal file
53
internal/db/migrations/060_user_pinned_projects.up.sql
Normal 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());
|
||||
@@ -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
91
internal/handlers/pins.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
114
internal/services/pin_service.go
Normal file
114
internal/services/pin_service.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
Reference in New Issue
Block a user