Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
labels.
- RLS owner-only (mirrors paliad.user_views from t-144).
LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}
DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).
CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.
ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.
Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).
Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.
Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
title-row-first invariant, hidden leading allowed, dup-key rejection,
unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
+ idempotent, first Create auto-becomes default, SetDefault clears
prior, Delete refuses active default, Delete non-default works,
duplicate name rejected, Update round-trips layout JSON.
go build / vet / test (short) clean.
Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
1518 lines
52 KiB
Go
1518 lines
52 KiB
Go
package services
|
||
|
||
// ProjectService handles CRUD on paliad.projects — the hierarchical
|
||
// project tree that replaced the flat paliad.akten model in migration 018.
|
||
//
|
||
// Visibility (design v2, adjusted 2026-04-20): team-based only.
|
||
// A user can see a Project iff
|
||
// - user is admin, or
|
||
// - user is a direct member of the Project's team, or
|
||
// - user is a member of any ancestor Project's team (inherited via path).
|
||
//
|
||
// Office is no longer a visibility gate. Cases associate with lead partners,
|
||
// not offices (see paliad.project_teams role='lead').
|
||
//
|
||
// The canonical predicate lives in SQL (paliad.can_see_project) and is
|
||
// enforced by RLS policies. This service re-implements the same predicate
|
||
// at the application layer so the service-role DB connection (without an
|
||
// auth.uid() JWT) still gates correctly.
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jmoiron/sqlx"
|
||
"github.com/lib/pq"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/models"
|
||
)
|
||
|
||
// Sentinel errors.
|
||
var (
|
||
// ErrNotVisible indicates the Project exists but the user has no
|
||
// visibility. Handlers must map to 404 (never leak existence).
|
||
ErrNotVisible = errors.New("project not visible")
|
||
// ErrForbidden indicates the user is authenticated but lacks the role
|
||
// required for the operation (e.g., associate trying to delete).
|
||
ErrForbidden = errors.New("forbidden")
|
||
// ErrInvalidInput signals a bad request (empty required field etc.).
|
||
ErrInvalidInput = errors.New("invalid input")
|
||
)
|
||
|
||
// ProjectType values enumerated on the projects.type CHECK constraint.
|
||
const (
|
||
ProjectTypeClient = "client"
|
||
ProjectTypeLitigation = "litigation"
|
||
ProjectTypePatent = "patent"
|
||
ProjectTypeCase = "case"
|
||
ProjectTypeProject = "project"
|
||
)
|
||
|
||
// Legacy ProjectRole values that used to live on paliad.project_teams.role.
|
||
//
|
||
// DEPRECATED (t-paliad-148): the role column has been split into
|
||
// users.profession (firm-tier) + project_teams.responsibility (per-
|
||
// project). New code should use ProfessionPartner / ResponsibilityLead /
|
||
// etc. from approval_levels.go. These constants stay defined for one
|
||
// release because the deprecated shadow column is still written by
|
||
// AddMember (mapped via legacyRoleFromResponsibility); follow-up
|
||
// migration 058 retires the column and these constants can be deleted
|
||
// then.
|
||
const (
|
||
RoleLead = "lead"
|
||
RoleAssociate = "associate"
|
||
RolePA = "pa"
|
||
RoleOfCounsel = "of_counsel"
|
||
RoleLocalCounsel = "local_counsel"
|
||
RoleExpert = "expert"
|
||
RoleObserver = "observer"
|
||
)
|
||
|
||
// ProjectService reads and writes paliad.projects + paliad.project_events.
|
||
type ProjectService struct {
|
||
db *sqlx.DB
|
||
users *UserService
|
||
}
|
||
|
||
// NewProjectService wires the service.
|
||
func NewProjectService(db *sqlx.DB, users *UserService) *ProjectService {
|
||
return &ProjectService{db: db, users: users}
|
||
}
|
||
|
||
// Users exposes the shared user service for downstream services that gate
|
||
// through ProjectService (DeadlineService, AppointmentService, NoteService, …).
|
||
func (s *ProjectService) Users() *UserService { return s.users }
|
||
|
||
// DB exposes the underlying connection pool for services that need to issue
|
||
// custom queries (dashboard aggregates, caldav sync). Read-only usage.
|
||
func (s *ProjectService) DB() *sqlx.DB { return s.db }
|
||
|
||
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
|
||
created_by, industry, country, billing_reference, client_number, matter_number,
|
||
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
|
||
proceeding_type_id, metadata, ai_summary, created_at, updated_at`
|
||
|
||
// CreateProjectInput is the payload for Create.
|
||
type CreateProjectInput struct {
|
||
Type string `json:"type"`
|
||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||
Title string `json:"title"`
|
||
Reference *string `json:"reference,omitempty"`
|
||
Description *string `json:"description,omitempty"`
|
||
Status string `json:"status,omitempty"` // default "active"
|
||
|
||
// Type-specific; service applies only the subset matching Type.
|
||
Industry *string `json:"industry,omitempty"`
|
||
Country *string `json:"country,omitempty"`
|
||
BillingReference *string `json:"billing_reference,omitempty"`
|
||
ClientNumber *string `json:"client_number,omitempty"`
|
||
MatterNumber *string `json:"matter_number,omitempty"`
|
||
NetDocumentsURL *string `json:"netdocuments_url,omitempty"`
|
||
PatentNumber *string `json:"patent_number,omitempty"`
|
||
FilingDate *time.Time `json:"filing_date,omitempty"`
|
||
GrantDate *time.Time `json:"grant_date,omitempty"`
|
||
Court *string `json:"court,omitempty"`
|
||
CaseNumber *string `json:"case_number,omitempty"`
|
||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||
}
|
||
|
||
// UpdateProjectInput is the partial-update payload.
|
||
type UpdateProjectInput struct {
|
||
Type *string `json:"type,omitempty"`
|
||
Title *string `json:"title,omitempty"`
|
||
Reference *string `json:"reference,omitempty"`
|
||
Description *string `json:"description,omitempty"`
|
||
Status *string `json:"status,omitempty"`
|
||
ParentID *uuid.UUID `json:"parent_id,omitempty"` // reparent; server recomputes path
|
||
|
||
Industry *string `json:"industry,omitempty"`
|
||
Country *string `json:"country,omitempty"`
|
||
BillingReference *string `json:"billing_reference,omitempty"`
|
||
ClientNumber *string `json:"client_number,omitempty"`
|
||
MatterNumber *string `json:"matter_number,omitempty"`
|
||
NetDocumentsURL *string `json:"netdocuments_url,omitempty"`
|
||
PatentNumber *string `json:"patent_number,omitempty"`
|
||
FilingDate *time.Time `json:"filing_date,omitempty"`
|
||
GrantDate *time.Time `json:"grant_date,omitempty"`
|
||
Court *string `json:"court,omitempty"`
|
||
CaseNumber *string `json:"case_number,omitempty"`
|
||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||
}
|
||
|
||
// ListFilter narrows List results. Zero-value → no filter.
|
||
type ProjectFilter struct {
|
||
Type string // "", or one of ProjectType* constants
|
||
Status string // "", "active", "archived", "closed"
|
||
ParentID *uuid.UUID // filter to direct children of the given parent; use ParentNullOnly for roots
|
||
// ParentNullOnly restricts to root-level rows (parent_id IS NULL).
|
||
// Mutually exclusive with ParentID.
|
||
ParentNullOnly bool
|
||
Search string // trigram / ILIKE on title, reference, client_number, matter_number
|
||
}
|
||
|
||
// List returns Projects visible to the user, filterable.
|
||
func (s *ProjectService) List(ctx context.Context, userID uuid.UUID, f ProjectFilter) ([]models.Project, error) {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return []models.Project{}, nil
|
||
}
|
||
|
||
conds := []string{visibilityPredicate("p")}
|
||
args := map[string]any{
|
||
"user_id": userID,
|
||
}
|
||
|
||
if f.Type != "" {
|
||
conds = append(conds, "p.type = :type")
|
||
args["type"] = f.Type
|
||
}
|
||
if f.Status != "" {
|
||
conds = append(conds, "p.status = :status")
|
||
args["status"] = f.Status
|
||
}
|
||
if f.ParentNullOnly {
|
||
conds = append(conds, "p.parent_id IS NULL")
|
||
} else if f.ParentID != nil {
|
||
conds = append(conds, "p.parent_id = :parent_id")
|
||
args["parent_id"] = *f.ParentID
|
||
}
|
||
if s := strings.TrimSpace(f.Search); s != "" {
|
||
conds = append(conds, `(p.title ILIKE :search OR p.reference ILIKE :search
|
||
OR p.client_number ILIKE :search OR p.matter_number ILIKE :search)`)
|
||
args["search"] = "%" + s + "%"
|
||
}
|
||
|
||
// Path order keeps every descendant immediately under its ancestor —
|
||
// the same ordering BuildTree produces — so list pickers (events filter,
|
||
// /deadlines/new, /appointments/new, …) can render the project tree as a
|
||
// flat indented list. Recency sort would interleave cousins by last-touch.
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE ` + strings.Join(conds, " AND ") + `
|
||
ORDER BY p.path`
|
||
|
||
stmt, err := s.db.PrepareNamedContext(ctx, query)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("prepare list projects: %w", err)
|
||
}
|
||
defer stmt.Close()
|
||
|
||
rows := []models.Project{}
|
||
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
|
||
return nil, fmt.Errorf("list projects: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// CanSee reports whether the user has visibility on the Project. Returns
|
||
// (false, nil) for invisible or missing — handlers must not distinguish.
|
||
// Cheaper than GetByID when only the visibility bit is needed (no projection
|
||
// of the full row); used by sibling services (NoteService, etc.) to gate on
|
||
// the parent without paying for a full SELECT.
|
||
func (s *ProjectService) CanSee(ctx context.Context, userID, id uuid.UUID) (bool, error) {
|
||
var visible bool
|
||
query := `SELECT EXISTS (SELECT 1 FROM paliad.projects p
|
||
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2) + `)`
|
||
if err := s.db.GetContext(ctx, &visible, query, id, userID); err != nil {
|
||
return false, fmt.Errorf("check project visibility: %w", err)
|
||
}
|
||
return visible, nil
|
||
}
|
||
|
||
// GetByID returns the Project if the user can see it. Returns (nil, ErrNotVisible)
|
||
// when invisible or missing — handlers must not distinguish.
|
||
func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Project, error) {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return nil, ErrNotVisible
|
||
}
|
||
var p models.Project
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2)
|
||
err = s.db.GetContext(ctx, &p, query, id, userID)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, ErrNotVisible
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get project: %w", err)
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// ListChildren returns direct children of a Project (visibility-checked on parent).
|
||
func (s *ProjectService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
|
||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.List(ctx, userID, ProjectFilter{ParentID: &id})
|
||
}
|
||
|
||
// ListAncestors walks up the path and returns ancestors from root → parent
|
||
// (exclusive of the Project itself). Used for breadcrumbs.
|
||
func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
|
||
p, err := s.GetByID(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
labels := strings.Split(p.Path, ".")
|
||
if len(labels) <= 1 {
|
||
return []models.Project{}, nil
|
||
}
|
||
// All but last = ancestors.
|
||
ancestorIDs := labels[:len(labels)-1]
|
||
ids := make([]uuid.UUID, 0, len(ancestorIDs))
|
||
for _, s := range ancestorIDs {
|
||
u, err := uuid.Parse(s)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse ancestor uuid %q: %w", s, err)
|
||
}
|
||
ids = append(ids, u)
|
||
}
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return []models.Project{}, nil
|
||
}
|
||
// Ancestors are visible whenever the Project is (inheritance works both
|
||
// ways through team membership checks). We still apply the predicate
|
||
// for safety in case path is stale.
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE p.id = ANY($1::uuid[]) AND ` +
|
||
visibilityPredicatePositional("p", 2)
|
||
// lib/pq doesn't serialise []uuid.UUID natively; render as string array.
|
||
idStrs := make([]string, len(ids))
|
||
for i, u := range ids {
|
||
idStrs[i] = u.String()
|
||
}
|
||
rows := []models.Project{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID); err != nil {
|
||
return nil, fmt.Errorf("list ancestors: %w", err)
|
||
}
|
||
// Re-order to match path order (root first).
|
||
order := make(map[uuid.UUID]int, len(ids))
|
||
for i, id := range ids {
|
||
order[id] = i
|
||
}
|
||
sortByOrder(rows, order)
|
||
return rows, nil
|
||
}
|
||
|
||
// ProjectTreeNode is one node of the nested tree returned by BuildTree.
|
||
// It embeds the full Project plus aggregated child nodes and deadline
|
||
// counts so the UI can render badges without per-row API calls.
|
||
//
|
||
// Subtree counts (OpenDeadlinesSubtree / OverdueDeadlinesSubtree) and
|
||
// the chip-driven flags (Pinned, InheritedVisibility, MatchKind) are
|
||
// only populated when BuildTreeWithOptions is called with the relevant
|
||
// options enabled. The legacy BuildTree(ctx, userID) call leaves them
|
||
// at zero / empty for back-compat.
|
||
type ProjectTreeNode struct {
|
||
models.Project
|
||
Children []*ProjectTreeNode `json:"children"`
|
||
OpenDeadlines int `json:"open_deadlines"`
|
||
OverdueDeadlines int `json:"overdue_deadlines"`
|
||
OpenDeadlinesSubtree int `json:"open_deadlines_subtree"`
|
||
OverdueDeadlinesSubtree int `json:"overdue_deadlines_subtree"`
|
||
Pinned bool `json:"pinned"`
|
||
InheritedVisibility bool `json:"inherited_visibility"`
|
||
// MatchKind is empty unless a search term is active. Values:
|
||
// "self" (direct match), "ancestor" (on the path to a match),
|
||
// "descendant" (under a match).
|
||
MatchKind string `json:"match_kind,omitempty"`
|
||
}
|
||
|
||
// TreeScope discriminates the chip-driven scope filter for BuildTreeWithOptions.
|
||
type TreeScope string
|
||
|
||
const (
|
||
// ScopeAll returns every visible project (default).
|
||
ScopeAll TreeScope = ""
|
||
// ScopeMine returns directly-staffed projects + their visible ancestors
|
||
// (ancestors flagged InheritedVisibility=true so the UI can render them
|
||
// greyed for context).
|
||
ScopeMine TreeScope = "mine"
|
||
// ScopePinned returns only projects in paliad.user_pinned_projects for
|
||
// the user. Ancestors are NOT auto-included (the chip is "show me the
|
||
// pinned set, period").
|
||
ScopePinned TreeScope = "pinned"
|
||
)
|
||
|
||
// BuildTreeOptions controls BuildTreeWithOptions. Zero value yields the
|
||
// legacy BuildTree behaviour (every visible project, per-node counts).
|
||
type BuildTreeOptions struct {
|
||
// Scope is the chip-driven scope filter ("Alle" / "Nur meine" / "Angepinnt").
|
||
Scope TreeScope
|
||
|
||
// PinnedSet is the user's pinned-project set, populated by the handler
|
||
// from PinService.PinnedSet so BuildTree doesn't need a PinService dep.
|
||
// nil → no pin information attached (Pinned=false on every node).
|
||
PinnedSet map[uuid.UUID]struct{}
|
||
|
||
// StatusIn narrows to rows whose status ∈ values. Empty = no narrowing.
|
||
StatusIn []string
|
||
|
||
// TypeIn narrows to rows whose type ∈ values. Empty = no narrowing.
|
||
TypeIn []string
|
||
|
||
// HasOpenDeadlines, when non-nil, narrows to rows with at least one
|
||
// pending deadline (true) or zero pending deadlines (false). Applied
|
||
// AFTER subtree-count computation so the count itself drives the gate.
|
||
HasOpenDeadlines *bool
|
||
|
||
// SearchTerm filters to nodes whose title / reference / clientmatter /
|
||
// ancestor title matches. Match-kind is tagged per node:
|
||
// "self" — direct hit
|
||
// "ancestor" — on the path to a hit
|
||
// "descendant" — under a hit (kept for context; same subtree)
|
||
// Empty = no search.
|
||
SearchTerm string
|
||
|
||
// IncludeSubtreeCounts populates OpenDeadlinesSubtree +
|
||
// OverdueDeadlinesSubtree. Default: true. Set false for the
|
||
// legacy per-node-only behaviour.
|
||
IncludeSubtreeCounts bool
|
||
}
|
||
|
||
// BuildTree returns the full nested tree of every Project the user can see,
|
||
// rooted at all parent_id-IS-NULL projects. Each node carries its open and
|
||
// overdue deadline counts (open=pending, overdue=pending&past-due) so the UI
|
||
// can render status badges with no extra round-trips. Path-sorted so callers
|
||
// get a stable deterministic ordering.
|
||
//
|
||
// This is a thin shim over BuildTreeWithOptions for back-compat with callers
|
||
// that just want every visible project. New callers (the /projects page
|
||
// post-t-paliad-149) should use BuildTreeWithOptions directly to access
|
||
// chip filters + subtree counts + pinning + search.
|
||
func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*ProjectTreeNode, error) {
|
||
return s.BuildTreeWithOptions(ctx, userID, BuildTreeOptions{
|
||
// Default IncludeSubtreeCounts=false: BuildTree's existing callers
|
||
// (the existing tree view) read OpenDeadlines / OverdueDeadlines
|
||
// per-node. Subtree counts are opt-in by the new /projects page
|
||
// via BuildTreeWithOptions.
|
||
})
|
||
}
|
||
|
||
// BuildTreeWithOptions is the chip-aware tree builder. See BuildTreeOptions
|
||
// for the knobs. Returns nodes in path order.
|
||
func (s *ProjectService) BuildTreeWithOptions(ctx context.Context, userID uuid.UUID, opts BuildTreeOptions) ([]*ProjectTreeNode, error) {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return []*ProjectTreeNode{}, nil
|
||
}
|
||
|
||
// Step 1 — load every visible project (path-ordered). The chip filters
|
||
// (status / type / search) narrow the selection, but Scope=Mine and
|
||
// the subtree-count aggregation BOTH need the full visible set so we
|
||
// can include greyed ancestors and aggregate counts up the tree. The
|
||
// final filtering happens in-memory after the tree is stitched.
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE ` + visibilityPredicatePositional("p", 1) + `
|
||
ORDER BY p.path`
|
||
rows := []models.Project{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil {
|
||
return nil, fmt.Errorf("build tree list: %w", err)
|
||
}
|
||
|
||
// Step 2 — per-node deadline counts (always; cheap one-shot query).
|
||
type deadlineCount struct {
|
||
ProjectID uuid.UUID `db:"project_id"`
|
||
Open int `db:"open"`
|
||
Overdue int `db:"overdue"`
|
||
}
|
||
now := time.Now().UTC()
|
||
today := now.Truncate(24 * time.Hour)
|
||
var counts []deadlineCount
|
||
if err := s.db.SelectContext(ctx, &counts, `
|
||
SELECT f.project_id,
|
||
COUNT(*) FILTER (WHERE f.status = 'pending') AS open,
|
||
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < $2::date) AS overdue
|
||
FROM paliad.deadlines f
|
||
JOIN paliad.projects p ON p.id = f.project_id
|
||
WHERE `+visibilityPredicatePositional("p", 1)+`
|
||
GROUP BY f.project_id`, userID, today); err != nil {
|
||
return nil, fmt.Errorf("build tree deadline counts: %w", err)
|
||
}
|
||
countByID := make(map[uuid.UUID]deadlineCount, len(counts))
|
||
for _, c := range counts {
|
||
countByID[c.ProjectID] = c
|
||
}
|
||
|
||
// Step 3 — for ScopeMine, load directly-staffed project IDs. For
|
||
// ScopePinned, the PinnedSet is the source of truth.
|
||
var directlyStaffed map[uuid.UUID]struct{}
|
||
if opts.Scope == ScopeMine {
|
||
var ids []uuid.UUID
|
||
if err := s.db.SelectContext(ctx, &ids, `
|
||
SELECT DISTINCT pt.project_id
|
||
FROM paliad.project_teams pt
|
||
WHERE pt.user_id = $1
|
||
`, userID); err != nil {
|
||
return nil, fmt.Errorf("build tree direct staffing: %w", err)
|
||
}
|
||
directlyStaffed = make(map[uuid.UUID]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
directlyStaffed[id] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// Step 4 — build node map + stitch the full tree.
|
||
nodes := make(map[uuid.UUID]*ProjectTreeNode, len(rows))
|
||
for i := range rows {
|
||
c := countByID[rows[i].ID]
|
||
n := &ProjectTreeNode{
|
||
Project: rows[i],
|
||
Children: []*ProjectTreeNode{},
|
||
OpenDeadlines: c.Open,
|
||
OverdueDeadlines: c.Overdue,
|
||
}
|
||
if opts.PinnedSet != nil {
|
||
if _, pinned := opts.PinnedSet[rows[i].ID]; pinned {
|
||
n.Pinned = true
|
||
}
|
||
}
|
||
nodes[rows[i].ID] = n
|
||
}
|
||
|
||
roots := []*ProjectTreeNode{}
|
||
for _, n := range nodes {
|
||
if n.ParentID == nil {
|
||
roots = append(roots, n)
|
||
continue
|
||
}
|
||
parent, ok := nodes[*n.ParentID]
|
||
if !ok {
|
||
roots = append(roots, n)
|
||
continue
|
||
}
|
||
parent.Children = append(parent.Children, n)
|
||
}
|
||
sortTreeByPath(roots)
|
||
|
||
// Step 5 — subtree-aggregated counts (post-order DFS sums each node's
|
||
// own counts plus every descendant's). Cheap (O(N)).
|
||
if opts.IncludeSubtreeCounts {
|
||
var aggregate func(n *ProjectTreeNode) (open, overdue int)
|
||
aggregate = func(n *ProjectTreeNode) (int, int) {
|
||
open := n.OpenDeadlines
|
||
overdue := n.OverdueDeadlines
|
||
for _, c := range n.Children {
|
||
co, cv := aggregate(c)
|
||
open += co
|
||
overdue += cv
|
||
}
|
||
n.OpenDeadlinesSubtree = open
|
||
n.OverdueDeadlinesSubtree = overdue
|
||
return open, overdue
|
||
}
|
||
for _, r := range roots {
|
||
aggregate(r)
|
||
}
|
||
}
|
||
|
||
// Step 6 — apply Scope filter. ScopeAll: no-op. ScopeMine: keep
|
||
// directly-staffed nodes + their ancestors (flagged InheritedVisibility
|
||
// for grey-rendering). ScopePinned: keep pinned nodes + their ancestors.
|
||
switch opts.Scope {
|
||
case ScopeMine:
|
||
keep := pathClosure(nodes, directlyStaffed)
|
||
markInherited(nodes, keep, directlyStaffed)
|
||
roots = filterTree(roots, keep)
|
||
case ScopePinned:
|
||
if opts.PinnedSet == nil {
|
||
// No pin set provided → empty tree.
|
||
roots = nil
|
||
break
|
||
}
|
||
keep := pathClosure(nodes, opts.PinnedSet)
|
||
markInherited(nodes, keep, opts.PinnedSet)
|
||
roots = filterTree(roots, keep)
|
||
}
|
||
|
||
// Step 7 — chip filters (status / type / has-open-deadlines). We keep
|
||
// nodes that match AND any ancestors needed to root them (so the tree
|
||
// shape is preserved). Directly-narrowing children would orphan them.
|
||
if len(opts.StatusIn) > 0 || len(opts.TypeIn) > 0 || opts.HasOpenDeadlines != nil {
|
||
match := func(n *ProjectTreeNode) bool {
|
||
if len(opts.StatusIn) > 0 && !containsString(opts.StatusIn, n.Status) {
|
||
return false
|
||
}
|
||
if len(opts.TypeIn) > 0 && !containsString(opts.TypeIn, n.Type) {
|
||
return false
|
||
}
|
||
if opts.HasOpenDeadlines != nil {
|
||
openCount := n.OpenDeadlines
|
||
if opts.IncludeSubtreeCounts {
|
||
openCount = n.OpenDeadlinesSubtree
|
||
}
|
||
if *opts.HasOpenDeadlines {
|
||
if openCount == 0 {
|
||
return false
|
||
}
|
||
} else {
|
||
if openCount != 0 {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
matched := matchSet(nodes, match)
|
||
keep := pathClosure(nodes, matched)
|
||
// Don't flip InheritedVisibility for chip filters — only Scope=Mine /
|
||
// Scope=Pinned want greyed ancestors. Chips should narrow cleanly.
|
||
roots = filterTree(roots, keep)
|
||
}
|
||
|
||
// Step 8 — search. Tags every visible node with match_kind and prunes
|
||
// to the union of {matches ∪ ancestors-of-matches ∪ descendants-of-matches}.
|
||
if term := strings.TrimSpace(opts.SearchTerm); term != "" {
|
||
applySearch(nodes, &roots, term)
|
||
}
|
||
|
||
return roots, nil
|
||
}
|
||
|
||
// pathClosure expands a seed set of project IDs into the closure that
|
||
// includes every ancestor (via the materialised path) so a filtered tree
|
||
// stays connected to its roots. The output set always contains every seed.
|
||
func pathClosure(nodes map[uuid.UUID]*ProjectTreeNode, seeds map[uuid.UUID]struct{}) map[uuid.UUID]struct{} {
|
||
keep := make(map[uuid.UUID]struct{}, len(seeds))
|
||
for id := range seeds {
|
||
n, ok := nodes[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
keep[id] = struct{}{}
|
||
// Walk path labels, skipping empty splits.
|
||
for label := range strings.SplitSeq(n.Path, ".") {
|
||
if label == "" {
|
||
continue
|
||
}
|
||
anc, err := uuid.Parse(label)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if _, vis := nodes[anc]; vis {
|
||
keep[anc] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
return keep
|
||
}
|
||
|
||
// markInherited flips InheritedVisibility=true on nodes that are in the
|
||
// keep set but NOT in the directly-staffed seed set. The UI greys these
|
||
// rows so users understand they're context-only (visibility derives from
|
||
// path closure, not direct staffing).
|
||
func markInherited(nodes map[uuid.UUID]*ProjectTreeNode, keep, seed map[uuid.UUID]struct{}) {
|
||
for id := range keep {
|
||
if _, direct := seed[id]; direct {
|
||
continue
|
||
}
|
||
if n, ok := nodes[id]; ok {
|
||
n.InheritedVisibility = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// filterTree returns a new root list containing only nodes in keep, with
|
||
// each surviving node's Children also pruned to keep. Children of pruned
|
||
// nodes are dropped entirely (the path-closure step is what guarantees
|
||
// matched nodes remain rooted).
|
||
func filterTree(roots []*ProjectTreeNode, keep map[uuid.UUID]struct{}) []*ProjectTreeNode {
|
||
out := make([]*ProjectTreeNode, 0, len(roots))
|
||
for _, r := range roots {
|
||
if _, ok := keep[r.ID]; !ok {
|
||
continue
|
||
}
|
||
r.Children = filterTree(r.Children, keep)
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matchSet returns the set of node IDs for which match(node) returns true.
|
||
func matchSet(nodes map[uuid.UUID]*ProjectTreeNode, match func(*ProjectTreeNode) bool) map[uuid.UUID]struct{} {
|
||
out := make(map[uuid.UUID]struct{})
|
||
for id, n := range nodes {
|
||
if match(n) {
|
||
out[id] = struct{}{}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// applySearch tags MatchKind on the visible nodes and prunes the tree to
|
||
// keep only nodes whose subtree contains a match (or which are themselves
|
||
// a match). Match scope: case-fold contains on title, reference,
|
||
// client_number, matter_number. Ancestor titles match too via the
|
||
// pathClosure semantics.
|
||
func applySearch(nodes map[uuid.UUID]*ProjectTreeNode, roots *[]*ProjectTreeNode, term string) {
|
||
q := strings.ToLower(term)
|
||
matches := make(map[uuid.UUID]struct{})
|
||
for id, n := range nodes {
|
||
if matchesSearch(n, q) {
|
||
matches[id] = struct{}{}
|
||
}
|
||
}
|
||
if len(matches) == 0 {
|
||
*roots = []*ProjectTreeNode{}
|
||
return
|
||
}
|
||
// Path closure includes ancestors. Descendants of matches are also kept
|
||
// (rendered as "descendant" so the user sees the full sub-context).
|
||
keep := pathClosure(nodes, matches)
|
||
descSet := make(map[uuid.UUID]struct{})
|
||
addDescendants(nodes, matches, descSet)
|
||
for id := range descSet {
|
||
keep[id] = struct{}{}
|
||
}
|
||
|
||
for id := range keep {
|
||
n, ok := nodes[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch {
|
||
case isInSet(matches, id):
|
||
n.MatchKind = "self"
|
||
case isInSet(descSet, id):
|
||
n.MatchKind = "descendant"
|
||
default:
|
||
n.MatchKind = "ancestor"
|
||
}
|
||
}
|
||
|
||
*roots = filterTree(*roots, keep)
|
||
}
|
||
|
||
func matchesSearch(n *ProjectTreeNode, q string) bool {
|
||
if strings.Contains(strings.ToLower(n.Title), q) {
|
||
return true
|
||
}
|
||
if n.Reference != nil && strings.Contains(strings.ToLower(*n.Reference), q) {
|
||
return true
|
||
}
|
||
if n.ClientNumber != nil && strings.Contains(strings.ToLower(*n.ClientNumber), q) {
|
||
return true
|
||
}
|
||
if n.MatterNumber != nil && strings.Contains(strings.ToLower(*n.MatterNumber), q) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func addDescendants(nodes map[uuid.UUID]*ProjectTreeNode, seeds, out map[uuid.UUID]struct{}) {
|
||
for seedID := range seeds {
|
||
seed, ok := nodes[seedID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
prefix := seed.Path + "."
|
||
for id, n := range nodes {
|
||
if id == seedID {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(n.Path, prefix) {
|
||
out[id] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func isInSet(set map[uuid.UUID]struct{}, id uuid.UUID) bool {
|
||
_, ok := set[id]
|
||
return ok
|
||
}
|
||
|
||
func containsString(haystack []string, needle string) bool {
|
||
return slices.Contains(haystack, needle)
|
||
}
|
||
|
||
func sortTreeByPath(nodes []*ProjectTreeNode) {
|
||
for i := 1; i < len(nodes); i++ {
|
||
for j := i; j > 0 && nodes[j].Path < nodes[j-1].Path; j-- {
|
||
nodes[j], nodes[j-1] = nodes[j-1], nodes[j]
|
||
}
|
||
}
|
||
for _, n := range nodes {
|
||
sortTreeByPath(n.Children)
|
||
}
|
||
}
|
||
|
||
// GetTree returns every Project in the subtree rooted at id (inclusive),
|
||
// ordered depth-first. Visibility-checked at root; descendants that the
|
||
// user can see are returned (the predicate naturally gates sub-branches).
|
||
func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
|
||
root, err := s.GetByID(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// path LIKE root.path || '.%' OR path = root.path
|
||
prefix := root.Path + ".%"
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE (p.path = $1 OR p.path LIKE $2)
|
||
AND ` + visibilityPredicatePositional("p", 3) + `
|
||
ORDER BY p.path`
|
||
rows := []models.Project{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID); err != nil {
|
||
return nil, fmt.Errorf("get tree: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// Create inserts a new Project. If parent_id is set, the creator must have
|
||
// visibility on the parent. The creator is auto-added to project_teams as
|
||
// role='lead' in the same transaction so post-create SELECT picks up the row.
|
||
func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input CreateProjectInput) (*models.Project, error) {
|
||
if strings.TrimSpace(input.Title) == "" {
|
||
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
||
}
|
||
if !isValidProjectType(input.Type) {
|
||
return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, input.Type)
|
||
}
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
|
||
}
|
||
if input.ParentID != nil {
|
||
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
|
||
return nil, fmt.Errorf("%w: parent not visible", ErrForbidden)
|
||
}
|
||
}
|
||
status := input.Status
|
||
if status == "" {
|
||
status = "active"
|
||
}
|
||
if err := validateProjectStatus(status); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
id := uuid.New()
|
||
now := time.Now().UTC()
|
||
|
||
// path is NOT NULL but the trigger populates it; supply a placeholder
|
||
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO paliad.projects
|
||
(id, type, parent_id, path, title, reference, description, status,
|
||
created_by, industry, country, billing_reference, client_number,
|
||
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
|
||
court, case_number, proceeding_type_id, metadata, created_at, updated_at)
|
||
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||
$14, $15, $16, $17, $18, $19, $20, '{}'::jsonb, $21, $21)`,
|
||
id, input.Type, input.ParentID,
|
||
input.Title, input.Reference, input.Description, status,
|
||
userID,
|
||
input.Industry, input.Country, input.BillingReference,
|
||
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
|
||
input.PatentNumber, input.FilingDate, input.GrantDate,
|
||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||
now,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("insert project: %w", err)
|
||
}
|
||
|
||
// Auto-add creator as team lead so they (and RLS) can see the row.
|
||
// Writes both the legacy `role` and the new `responsibility` so the
|
||
// deprecated shadow column stays in sync until migration 058.
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||
VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
|
||
return nil, fmt.Errorf("insert creator team row: %w", err)
|
||
}
|
||
|
||
if err := insertProjectEvent(ctx, tx, id, userID, "project_created", "Project created", nil); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, fmt.Errorf("commit create project: %w", err)
|
||
}
|
||
return s.GetByID(ctx, userID, id)
|
||
}
|
||
|
||
// Update applies a partial update. Reparenting triggers path rewrite for the
|
||
// subtree (handled by the AFTER UPDATE trigger on paliad.projects).
|
||
func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateProjectInput) (*models.Project, error) {
|
||
current, err := s.GetByID(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if input.ParentID != nil {
|
||
// Verify new parent is visible (reparenting under invisible node would
|
||
// leak the whole subtree to the new parent's team — reject).
|
||
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
|
||
return nil, fmt.Errorf("%w: new parent not visible", ErrForbidden)
|
||
}
|
||
}
|
||
|
||
// Type change: validate up-front and collect the columns that were
|
||
// specific to the old type. Those get force-NULL'd at the end of the SET
|
||
// list and the per-field appendSet calls below skip them — Postgres
|
||
// rejects duplicate column assignments in a single UPDATE, and the
|
||
// type-change clear has to win regardless of what the client sent.
|
||
typeChanged := false
|
||
clearOnTypeChange := map[string]bool{}
|
||
if input.Type != nil && *input.Type != current.Type {
|
||
if !isValidProjectType(*input.Type) {
|
||
return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, *input.Type)
|
||
}
|
||
for _, col := range typeSpecificColumns(current.Type) {
|
||
clearOnTypeChange[col] = true
|
||
}
|
||
typeChanged = true
|
||
}
|
||
|
||
sets := []string{}
|
||
args := []any{}
|
||
next := 1
|
||
appendSet := func(col string, val any) {
|
||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||
args = append(args, val)
|
||
next++
|
||
}
|
||
// appendSetSkippable is for per-field user input that must yield to the
|
||
// type-change clear when the column is being forced to NULL.
|
||
appendSetSkippable := func(col string, val any) {
|
||
if clearOnTypeChange[col] {
|
||
return
|
||
}
|
||
appendSet(col, val)
|
||
}
|
||
|
||
if typeChanged {
|
||
appendSet("type", *input.Type)
|
||
}
|
||
if input.Title != nil {
|
||
t := strings.TrimSpace(*input.Title)
|
||
if t == "" {
|
||
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
|
||
}
|
||
appendSet("title", t)
|
||
}
|
||
if input.Reference != nil {
|
||
appendSet("reference", *input.Reference)
|
||
}
|
||
if input.Description != nil {
|
||
appendSet("description", *input.Description)
|
||
}
|
||
if input.Status != nil {
|
||
if err := validateProjectStatus(*input.Status); err != nil {
|
||
return nil, err
|
||
}
|
||
appendSet("status", *input.Status)
|
||
}
|
||
if input.ParentID != nil {
|
||
appendSet("parent_id", *input.ParentID)
|
||
}
|
||
if input.Industry != nil {
|
||
appendSetSkippable("industry", *input.Industry)
|
||
}
|
||
if input.Country != nil {
|
||
appendSetSkippable("country", *input.Country)
|
||
}
|
||
if input.BillingReference != nil {
|
||
appendSet("billing_reference", *input.BillingReference)
|
||
}
|
||
if input.ClientNumber != nil {
|
||
appendSetSkippable("client_number", *input.ClientNumber)
|
||
}
|
||
if input.MatterNumber != nil {
|
||
appendSet("matter_number", *input.MatterNumber)
|
||
}
|
||
if input.NetDocumentsURL != nil {
|
||
appendSet("netdocuments_url", *input.NetDocumentsURL)
|
||
}
|
||
if input.PatentNumber != nil {
|
||
appendSetSkippable("patent_number", *input.PatentNumber)
|
||
}
|
||
if input.FilingDate != nil {
|
||
appendSetSkippable("filing_date", *input.FilingDate)
|
||
}
|
||
if input.GrantDate != nil {
|
||
appendSetSkippable("grant_date", *input.GrantDate)
|
||
}
|
||
if input.Court != nil {
|
||
appendSetSkippable("court", *input.Court)
|
||
}
|
||
if input.CaseNumber != nil {
|
||
appendSetSkippable("case_number", *input.CaseNumber)
|
||
}
|
||
if input.ProceedingTypeID != nil {
|
||
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
|
||
}
|
||
if typeChanged {
|
||
for _, col := range typeSpecificColumns(current.Type) {
|
||
appendSet(col, nil)
|
||
}
|
||
}
|
||
if len(sets) == 0 {
|
||
return current, nil
|
||
}
|
||
appendSet("updated_at", time.Now().UTC())
|
||
|
||
args = append(args, id)
|
||
query := fmt.Sprintf("UPDATE paliad.projects SET %s WHERE id = $%d",
|
||
strings.Join(sets, ", "), next)
|
||
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
||
return nil, fmt.Errorf("update project: %w", err)
|
||
}
|
||
|
||
// Descriptions carry the value-only payload (`old → new`); the frontend
|
||
// renderer translates both slugs and prepends the localized prefix.
|
||
if input.Status != nil && *input.Status != current.Status {
|
||
desc := fmt.Sprintf("%s → %s", current.Status, *input.Status)
|
||
descPtr := &desc
|
||
if err := insertProjectEvent(ctx, tx, id, userID, "status_changed", "Status changed", descPtr); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if typeChanged {
|
||
desc := fmt.Sprintf("%s → %s", current.Type, *input.Type)
|
||
descPtr := &desc
|
||
if err := insertProjectEvent(ctx, tx, id, userID, "project_type_changed", "Project type changed", descPtr); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if input.ParentID != nil {
|
||
if err := insertProjectEvent(ctx, tx, id, userID, "project_reparented", "Project re-parented", nil); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, fmt.Errorf("commit update project: %w", err)
|
||
}
|
||
return s.GetByID(ctx, userID, id)
|
||
}
|
||
|
||
// Delete archives the Project (soft-delete, status='archived'). Partner/admin only.
|
||
// Hard-delete cascades through FK; we prefer archival for audit.
|
||
func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if user == nil {
|
||
return ErrNotVisible
|
||
}
|
||
if user.GlobalRole != "global_admin" {
|
||
return fmt.Errorf("%w: only partners/admins can archive Projects", ErrForbidden)
|
||
}
|
||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||
return err
|
||
}
|
||
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`UPDATE paliad.projects SET status = 'archived', updated_at = $1
|
||
WHERE id = $2 AND status != 'archived'`, time.Now().UTC(), id)
|
||
if err != nil {
|
||
return fmt.Errorf("archive project: %w", err)
|
||
}
|
||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||
return tx.Commit()
|
||
}
|
||
if err := insertProjectEvent(ctx, tx, id, userID, "project_archived", "Project archived", nil); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// MaxEventsPageLimit caps ListEvents page size.
|
||
const MaxEventsPageLimit = 200
|
||
|
||
// DefaultEventsPageLimit is the page size when ?limit= is omitted.
|
||
const DefaultEventsPageLimit = 50
|
||
|
||
// ListEvents returns the audit trail for the Project, newest first, with
|
||
// cursor pagination (before = uuid of last seen event).
|
||
//
|
||
// When directOnly is false (default), the result aggregates events from
|
||
// the Project itself AND every descendant Project (per the t-paliad-139
|
||
// hierarchy aggregation contract — Verlauf on a Client should show the
|
||
// matter's complete history, not just rows attached at the root). When
|
||
// directOnly is true, only events whose project_id exactly equals the
|
||
// filter are returned.
|
||
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int, directOnly bool) ([]models.ProjectEvent, error) {
|
||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||
return nil, err
|
||
}
|
||
if limit <= 0 {
|
||
limit = DefaultEventsPageLimit
|
||
}
|
||
if limit > MaxEventsPageLimit {
|
||
limit = MaxEventsPageLimit
|
||
}
|
||
var beforeArg any
|
||
if before != nil {
|
||
beforeArg = *before
|
||
}
|
||
var projectFilter string
|
||
if directOnly {
|
||
projectFilter = `project_id = $1`
|
||
} else {
|
||
// Inner alias `pp` to avoid shadowing the outer `p` JOIN below.
|
||
projectFilter = `project_id IN (
|
||
SELECT pp.id FROM paliad.projects pp
|
||
WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[]))`
|
||
}
|
||
var events []models.ProjectEvent
|
||
err := s.db.SelectContext(ctx, &events,
|
||
`SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date,
|
||
pe.created_by, pe.metadata, pe.created_at, pe.updated_at,
|
||
p.title AS project_title
|
||
FROM paliad.project_events pe
|
||
LEFT JOIN paliad.projects p ON p.id = pe.project_id
|
||
WHERE pe.`+projectFilter+`
|
||
AND ($2::uuid IS NULL OR (pe.created_at, pe.id) < (
|
||
SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid
|
||
))
|
||
ORDER BY pe.created_at DESC, pe.id DESC
|
||
LIMIT $3`, id, beforeArg, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list project events: %w", err)
|
||
}
|
||
return events, nil
|
||
}
|
||
|
||
// ResolveClientNumber walks up the path to find the first non-null client_number
|
||
// (inherited convention). Returns nil if none in the ancestor chain.
|
||
func (s *ProjectService) ResolveClientNumber(ctx context.Context, userID, id uuid.UUID) (*string, error) {
|
||
p, err := s.GetByID(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if p.ClientNumber != nil {
|
||
return p.ClientNumber, nil
|
||
}
|
||
ancestors, err := s.ListAncestors(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Ancestors returned root→parent; scan from closest ancestor outward —
|
||
// but client_number is conceptually set at the root, so walking either
|
||
// direction is fine. Closest wins for override.
|
||
for i := len(ancestors) - 1; i >= 0; i-- {
|
||
if ancestors[i].ClientNumber != nil {
|
||
return ancestors[i].ClientNumber, nil
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// Cards preview (t-paliad-149 PR 2)
|
||
// ============================================================================
|
||
|
||
// CardEventPreview is one event row inside a card's "Nächste Termine" or
|
||
// "Zuletzt" section. Hoverable + clickable in the UI; route is the
|
||
// computed in-app navigation target.
|
||
type CardEventPreview struct {
|
||
Kind string `json:"kind"` // "deadline" | "appointment" | "project_event"
|
||
ID uuid.UUID `json:"id"`
|
||
Title string `json:"title"`
|
||
EventDate time.Time `json:"event_date"`
|
||
Status *string `json:"status,omitempty"` // populated for kind=deadline
|
||
ActorName *string `json:"actor_name,omitempty"` // populated for kind=project_event
|
||
Route string `json:"route"` // /projects/{pid}?focus=...
|
||
}
|
||
|
||
// ProjectCardPreview is the per-project rollup for the Cards view. One row
|
||
// per visible project; team_initials capped at 3 + team_count for the
|
||
// total. last_activity_at is the most recent event timestamp (deadline /
|
||
// appointment / project_event) across own + descendants, used by the
|
||
// orchestrator to sort cards.
|
||
type ProjectCardPreview struct {
|
||
ProjectID uuid.UUID `json:"project_id"`
|
||
NextEvents []CardEventPreview `json:"next_events"`
|
||
RecentVerlauf []CardEventPreview `json:"recent_verlauf"`
|
||
TeamInitials []string `json:"team_initials"`
|
||
TeamCount int `json:"team_count"`
|
||
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
||
}
|
||
|
||
// CardsPreview returns the per-project rollup for the Cards view across
|
||
// every project the user can see. The optional projectIDs slice narrows
|
||
// the rollup to a subset (used by IntersectionObserver lazy fetches).
|
||
//
|
||
// Performance: a single SQL per source (deadlines, appointments, project
|
||
// events) using ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY
|
||
// event_date) to slice top-3 each direction without N round-trips. Caller
|
||
// can wrap in a per-user TTL cache (handler does this v1).
|
||
func (s *ProjectService) CardsPreview(ctx context.Context, userID uuid.UUID, projectIDs []uuid.UUID) (map[uuid.UUID]*ProjectCardPreview, error) {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return map[uuid.UUID]*ProjectCardPreview{}, nil
|
||
}
|
||
|
||
// Determine the visible-project set. When projectIDs is non-empty, we
|
||
// still gate every row through the visibility predicate.
|
||
out := map[uuid.UUID]*ProjectCardPreview{}
|
||
|
||
// Optional narrowing as a SQL ANY clause.
|
||
narrow := ""
|
||
args := []any{userID}
|
||
if len(projectIDs) > 0 {
|
||
idStrs := make([]string, len(projectIDs))
|
||
for i, id := range projectIDs {
|
||
idStrs[i] = id.String()
|
||
}
|
||
narrow = " AND p.id = ANY($2::uuid[])"
|
||
args = append(args, pq.StringArray(idStrs))
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
today := now.Truncate(24 * time.Hour)
|
||
|
||
// --- Source 1: upcoming Deadlines (top 3 per project, ascending). ---
|
||
type rowDeadline struct {
|
||
ProjectID uuid.UUID `db:"project_id"`
|
||
ID uuid.UUID `db:"id"`
|
||
Title string `db:"title"`
|
||
DueDate time.Time `db:"due_date"`
|
||
Status string `db:"status"`
|
||
}
|
||
var ds []rowDeadline
|
||
dq := `
|
||
WITH visible AS (
|
||
SELECT p.id FROM paliad.projects p
|
||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||
), ranked AS (
|
||
SELECT f.project_id, f.id, f.title, f.due_date, f.status,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY f.project_id
|
||
ORDER BY f.due_date ASC, f.id ASC
|
||
) AS rn
|
||
FROM paliad.deadlines f
|
||
JOIN visible v ON v.id = f.project_id
|
||
WHERE f.status = 'pending' AND f.due_date >= $%d::date
|
||
)
|
||
SELECT project_id, id, title, due_date, status
|
||
FROM ranked WHERE rn <= 3
|
||
`
|
||
dq = fmt.Sprintf(dq, len(args)+1)
|
||
args = append(args, today)
|
||
if err := s.db.SelectContext(ctx, &ds, dq, args...); err != nil {
|
||
return nil, fmt.Errorf("cards preview deadlines: %w", err)
|
||
}
|
||
|
||
// --- Source 2: upcoming Appointments (top 3 per project, ascending). ---
|
||
type rowAppointment struct {
|
||
ProjectID uuid.UUID `db:"project_id"`
|
||
ID uuid.UUID `db:"id"`
|
||
Title string `db:"title"`
|
||
StartsAt time.Time `db:"starts_at"`
|
||
}
|
||
var as []rowAppointment
|
||
aq := `
|
||
WITH visible AS (
|
||
SELECT p.id FROM paliad.projects p
|
||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||
), ranked AS (
|
||
SELECT t.project_id, t.id, t.title, t.starts_at,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY t.project_id
|
||
ORDER BY t.starts_at ASC, t.id ASC
|
||
) AS rn
|
||
FROM paliad.appointments t
|
||
JOIN visible v ON v.id = t.project_id
|
||
WHERE t.project_id IS NOT NULL AND t.starts_at >= $%d::timestamptz
|
||
)
|
||
SELECT project_id, id, title, starts_at
|
||
FROM ranked WHERE rn <= 3
|
||
`
|
||
// args already has [userID, projectIDs?, today]; reuse $%d for now.
|
||
aArgs := make([]any, len(args))
|
||
copy(aArgs, args)
|
||
aArgs[len(aArgs)-1] = now // last arg is the temporal bound
|
||
aq = fmt.Sprintf(aq, len(aArgs))
|
||
if err := s.db.SelectContext(ctx, &as, aq, aArgs...); err != nil {
|
||
return nil, fmt.Errorf("cards preview appointments: %w", err)
|
||
}
|
||
|
||
// --- Source 3: recent project_events (Verlauf, top 3 per project, descending). ---
|
||
type rowEvent struct {
|
||
ProjectID uuid.UUID `db:"project_id"`
|
||
ID uuid.UUID `db:"id"`
|
||
Title string `db:"title"`
|
||
CreatedAt time.Time `db:"created_at"`
|
||
ActorName *string `db:"actor_name"`
|
||
}
|
||
var es []rowEvent
|
||
eq := `
|
||
WITH visible AS (
|
||
SELECT p.id FROM paliad.projects p
|
||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||
), ranked AS (
|
||
SELECT pe.project_id, pe.id, pe.title, pe.created_at,
|
||
u.display_name AS actor_name,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY pe.project_id
|
||
ORDER BY pe.created_at DESC, pe.id DESC
|
||
) AS rn
|
||
FROM paliad.project_events pe
|
||
JOIN visible v ON v.id = pe.project_id
|
||
LEFT JOIN paliad.users u ON u.id = pe.created_by
|
||
)
|
||
SELECT project_id, id, title, created_at, actor_name
|
||
FROM ranked WHERE rn <= 3
|
||
`
|
||
eArgs := []any{userID}
|
||
if len(projectIDs) > 0 {
|
||
idStrs := make([]string, len(projectIDs))
|
||
for i, id := range projectIDs {
|
||
idStrs[i] = id.String()
|
||
}
|
||
eArgs = append(eArgs, pq.StringArray(idStrs))
|
||
}
|
||
if err := s.db.SelectContext(ctx, &es, eq, eArgs...); err != nil {
|
||
return nil, fmt.Errorf("cards preview project events: %w", err)
|
||
}
|
||
|
||
// --- Source 4: team chips per project (initials + count). ---
|
||
type rowTeam struct {
|
||
ProjectID uuid.UUID `db:"project_id"`
|
||
DisplayName string `db:"display_name"`
|
||
}
|
||
var ts []rowTeam
|
||
tq := `
|
||
WITH visible AS (
|
||
SELECT p.id FROM paliad.projects p
|
||
WHERE ` + visibilityPredicatePositional("p", 1) + narrow + `
|
||
)
|
||
SELECT pt.project_id, u.display_name
|
||
FROM paliad.project_teams pt
|
||
JOIN visible v ON v.id = pt.project_id
|
||
JOIN paliad.users u ON u.id = pt.user_id
|
||
ORDER BY pt.project_id, u.display_name
|
||
`
|
||
if err := s.db.SelectContext(ctx, &ts, tq, eArgs...); err != nil {
|
||
return nil, fmt.Errorf("cards preview teams: %w", err)
|
||
}
|
||
|
||
// Stitch into per-project structs.
|
||
get := func(pid uuid.UUID) *ProjectCardPreview {
|
||
if p, ok := out[pid]; ok {
|
||
return p
|
||
}
|
||
p := &ProjectCardPreview{
|
||
ProjectID: pid,
|
||
NextEvents: []CardEventPreview{},
|
||
RecentVerlauf: []CardEventPreview{},
|
||
TeamInitials: []string{},
|
||
}
|
||
out[pid] = p
|
||
return p
|
||
}
|
||
for _, r := range ds {
|
||
p := get(r.ProjectID)
|
||
st := r.Status
|
||
p.NextEvents = append(p.NextEvents, CardEventPreview{
|
||
Kind: "deadline",
|
||
ID: r.ID,
|
||
Title: r.Title,
|
||
EventDate: r.DueDate,
|
||
Status: &st,
|
||
Route: fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
|
||
})
|
||
bumpActivity(p, r.DueDate)
|
||
}
|
||
for _, r := range as {
|
||
p := get(r.ProjectID)
|
||
p.NextEvents = append(p.NextEvents, CardEventPreview{
|
||
Kind: "appointment",
|
||
ID: r.ID,
|
||
Title: r.Title,
|
||
EventDate: r.StartsAt,
|
||
Route: fmt.Sprintf("/projects/%s?focus=%s", r.ProjectID, r.ID),
|
||
})
|
||
bumpActivity(p, r.StartsAt)
|
||
}
|
||
for _, r := range es {
|
||
p := get(r.ProjectID)
|
||
p.RecentVerlauf = append(p.RecentVerlauf, CardEventPreview{
|
||
Kind: "project_event",
|
||
ID: r.ID,
|
||
Title: r.Title,
|
||
EventDate: r.CreatedAt,
|
||
ActorName: r.ActorName,
|
||
Route: fmt.Sprintf("/projects/%s?tab=verlauf&focus=%s", r.ProjectID, r.ID),
|
||
})
|
||
bumpActivity(p, r.CreatedAt)
|
||
}
|
||
for _, r := range ts {
|
||
p := get(r.ProjectID)
|
||
p.TeamCount++
|
||
if len(p.TeamInitials) < 3 {
|
||
p.TeamInitials = append(p.TeamInitials, initialsFromName(r.DisplayName))
|
||
}
|
||
}
|
||
|
||
// Sort NextEvents per project ascending, RecentVerlauf descending,
|
||
// then truncate to 3 (the SQL caps at 3 per source, but the union of
|
||
// deadline+appointment can be 6 — re-sort + cap to 3).
|
||
for _, p := range out {
|
||
sortByEventDateAsc(p.NextEvents)
|
||
if len(p.NextEvents) > 3 {
|
||
p.NextEvents = p.NextEvents[:3]
|
||
}
|
||
// RecentVerlauf is single-source already-bounded; nothing else to do.
|
||
_ = p.RecentVerlauf
|
||
}
|
||
|
||
return out, nil
|
||
}
|
||
|
||
func bumpActivity(p *ProjectCardPreview, ts time.Time) {
|
||
if p.LastActivityAt == nil || ts.After(*p.LastActivityAt) {
|
||
t := ts
|
||
p.LastActivityAt = &t
|
||
}
|
||
}
|
||
|
||
func sortByEventDateAsc(events []CardEventPreview) {
|
||
for i := 1; i < len(events); i++ {
|
||
for j := i; j > 0 && events[j].EventDate.Before(events[j-1].EventDate); j-- {
|
||
events[j], events[j-1] = events[j-1], events[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
func initialsFromName(name string) string {
|
||
parts := strings.Fields(name)
|
||
if len(parts) == 0 {
|
||
return "?"
|
||
}
|
||
if len(parts) == 1 {
|
||
r := []rune(parts[0])
|
||
if len(r) == 0 {
|
||
return "?"
|
||
}
|
||
return strings.ToUpper(string(r[0]))
|
||
}
|
||
first := []rune(parts[0])
|
||
last := []rune(parts[len(parts)-1])
|
||
if len(first) == 0 || len(last) == 0 {
|
||
return strings.ToUpper(string(first) + string(last))
|
||
}
|
||
return strings.ToUpper(string(first[0]) + string(last[0]))
|
||
}
|
||
|
||
// ============================================================================
|
||
// Helpers
|
||
// ============================================================================
|
||
|
||
// insertProjectEvent appends one audit row in the given tx.
|
||
func insertProjectEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, eventType, title string, description *string) error {
|
||
return insertProjectEventWithMeta(ctx, tx, projectID, userID, eventType, title, description, nil)
|
||
}
|
||
|
||
// insertProjectEventWithMeta appends an audit row with structured metadata
|
||
// (e.g. {"checklist_instance_id": "..."}). The metadata column is a free-form
|
||
// jsonb; readers query specific keys when present (see Verlauf rendering of
|
||
// checklist_* events) and ignore unknown keys.
|
||
func insertProjectEventWithMeta(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, eventType, title string, description *string, meta map[string]any) error {
|
||
now := time.Now().UTC()
|
||
metaJSON := json.RawMessage(`{}`)
|
||
if len(meta) > 0 {
|
||
b, err := json.Marshal(meta)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal project_event metadata: %w", err)
|
||
}
|
||
metaJSON = b
|
||
}
|
||
_, err := tx.ExecContext(ctx,
|
||
`INSERT INTO paliad.project_events
|
||
(id, project_id, event_type, title, description, event_date,
|
||
created_by, metadata, created_at, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $6, $6)`,
|
||
uuid.New(), projectID, eventType, title, description, now, userID, metaJSON)
|
||
if err != nil {
|
||
return fmt.Errorf("insert project_event: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// typeSpecificColumns returns the DB columns that only make sense for the
|
||
// given project type. When a project's type changes away from `t`, callers
|
||
// NULL these columns so the row doesn't carry stale data from the old type.
|
||
// Litigation/project have no specific columns.
|
||
func typeSpecificColumns(t string) []string {
|
||
switch t {
|
||
case ProjectTypeClient:
|
||
return []string{"industry", "country", "client_number"}
|
||
case ProjectTypePatent:
|
||
return []string{"patent_number", "filing_date", "grant_date"}
|
||
case ProjectTypeCase:
|
||
return []string{"court", "case_number", "proceeding_type_id"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isValidProjectType(t string) bool {
|
||
switch t {
|
||
case ProjectTypeClient, ProjectTypeLitigation, ProjectTypePatent,
|
||
ProjectTypeCase, ProjectTypeProject:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validateProjectStatus(s string) error {
|
||
switch s {
|
||
case "active", "archived", "closed":
|
||
return nil
|
||
}
|
||
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
|
||
}
|
||
|
||
func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
|
||
// Insertion sort — ancestor lists are short (<20).
|
||
for i := 1; i < len(xs); i++ {
|
||
for j := i; j > 0 && order[xs[j].ID] < order[xs[j-1].ID]; j-- {
|
||
xs[j], xs[j-1] = xs[j-1], xs[j]
|
||
}
|
||
}
|
||
}
|