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.
92 lines
2.4 KiB
Go
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)
|
|
}
|