Files
paliad/internal/services/pin_service.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

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
}