Files
paliad/internal/handlers/pins.go
m 8412328dec 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.
2026-05-07 22:21:45 +02:00

92 lines
2.4 KiB
Go

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)
}