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.
115 lines
3.5 KiB
Go
115 lines
3.5 KiB
Go
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
|
|
}
|
|
|