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

@@ -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) {