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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user