Files
paliad/internal/services/project_service.go
m 4e1d311a9c feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
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.
2026-05-07 22:41:18 +02:00

1518 lines
52 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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