Files
paliad/internal/services/project_service.go
mAi ea0715a8c7 feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:55:55 +02:00

2102 lines
74 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"
"regexp"
"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")
// ErrLastProjectAdmin guards demoting / removing the last remaining
// effective_project_admin from a project + its ancestor chain. t-paliad-223
// invariant: every project should keep at least one admin somewhere in
// its ancestor chain so a non-global-admin can still manage the team.
// Handlers map to 409 Conflict.
ErrLastProjectAdmin = errors.New("cannot remove last project admin from project + ancestors")
// ErrInvalidProceedingTypeCategory signals that the caller supplied
// a proceeding_type_id pointing at a non-fristenrechner-category row.
// Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only
// fristenrechner-category codes may bind to a project. Handlers
// surface this as a 400 with a bilingual friendly message; the
// matching DB trigger (mig 088) is the defence-in-depth backstop.
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
)
// ProjectType values enumerated on the projects.type CHECK constraint.
// 'other' (mig 110, m/paliad#51) is the explicit "unclassified" bucket —
// previously this appeared as a synthetic "Empty" option in the type
// filter; the chip now offers it as a real selectable type.
const (
ProjectTypeClient = "client"
ProjectTypeLitigation = "litigation"
ProjectTypePatent = "patent"
ProjectTypeCase = "case"
ProjectTypeProject = "project"
ProjectTypeOther = "other"
)
// 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, our_side, opponent_code, counterclaim_of, instance_level, 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"`
OurSide *string `json:"our_side,omitempty"`
// OpponentCode is the litigation-only short slug used as the middle
// segment when BuildProjectCode assembles a project code from the
// ancestor tree (t-paliad-222 / m/paliad#50). Empty / nil → segment
// skipped. Only meaningful on type='litigation' rows; the form
// hides the field elsewhere and the DB CHECK rejects it.
OpponentCode *string `json:"opponent_code,omitempty"`
// InstanceLevel is the procedural instance the project sits at:
// 'first' (default once the picker UI lands) | 'appeal' | 'cassation'.
// NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the
// SmartTimeline + calculator combine this with proceeding_code +
// jurisdiction to pick the effective rule corpus (de.inf.lg + appeal →
// de.inf.olg, etc.). Validated against the mig 080 CHECK on the
// column; service surfaces ErrInvalidInput on a bad value.
InstanceLevel *string `json:"instance_level,omitempty"`
// CounterclaimOf marks this project as a CCR sub-project filed
// against the referenced parent project (t-paliad-174 Slice 3).
// Set by ProjectService.CreateCounterclaim — direct callers of
// Create rarely need it. The two-level-CCR rejection trigger
// (migration 077) will reject malformed shapes regardless.
CounterclaimOf *uuid.UUID `json:"counterclaim_of,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"`
OurSide *string `json:"our_side,omitempty"`
// OpponentCode — see CreateProjectInput.OpponentCode. UPDATE path:
// pointer to "" clears the column (NULL); pointer to a non-empty
// slug sets it.
OpponentCode *string `json:"opponent_code,omitempty"`
// InstanceLevel — see CreateProjectInput.InstanceLevel. UPDATE
// path: caller passes a pointer to the new value to swap; pass
// a pointer to "" to clear (NULL the column).
InstanceLevel *string `json:"instance_level,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)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, 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)
}
code, err := BuildProjectCode(ctx, s.db, p.ID)
if err != nil {
return nil, err
}
p.Code = code
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)
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
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)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, 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)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, 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
}
if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); 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 paliad.projects_sync_path() (BEFORE INSERT
// trigger from mig 018/021) overwrites it from id and parent path,
// so any non-null value satisfies the constraint. Use a literal
// placeholder rather than re-referencing $1 — reusing a parameter
// across columns with different SQL types (id is uuid, path is text)
// makes Postgres's planner reject the statement with 42P08
// "inconsistent types deduced for parameter" once the driver hands
// $1 across as an inferred type. The literal keeps the param list
// decoupled from the id column's type.
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
}
if input.OpponentCode != nil {
if err := validateOpponentCode(*input.OpponentCode, input.Type); err != nil {
return nil, err
}
}
if input.InstanceLevel != nil {
if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
return nil, err
}
}
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, our_side, opponent_code,
counterclaim_of, instance_level, metadata, created_at, updated_at)
VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, '{}'::jsonb, $25, $25)`,
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,
nullableOurSide(input.OurSide),
nullableOpponentCode(input.OpponentCode),
input.CounterclaimOf,
nullableInstanceLevel(input.InstanceLevel),
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 {
if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); err != nil {
return nil, err
}
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
}
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
}
appendSet("our_side", nullableOurSide(input.OurSide))
}
if input.OpponentCode != nil {
if err := validateOpponentCode(*input.OpponentCode, current.Type); err != nil {
return nil, err
}
appendSet("opponent_code", nullableOpponentCode(input.OpponentCode))
}
if input.InstanceLevel != nil {
if err := validateInstanceLevel(*input.InstanceLevel); err != nil {
return nil, err
}
appendSet("instance_level", nullableInstanceLevel(input.InstanceLevel))
}
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
}
}
// our_side change: log when the value (or its set/unset state) actually
// flips. Description follows the same value-only "old → new" pattern as
// status_changed; frontend renderer maps the slugs to localized labels
// (claimant / defendant / court / both / "—" for NULL).
if input.OurSide != nil {
nextOS := strings.TrimSpace(*input.OurSide)
prevOS := ""
if current.OurSide != nil {
prevOS = *current.OurSide
}
if nextOS != prevOS {
from := prevOS
if from == "" {
from = "none"
}
to := nextOS
if to == "" {
to = "none"
}
desc := fmt.Sprintf("%s → %s", from, to)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, id, userID, "our_side_changed", "Represented side changed", descPtr); 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)
}
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
// to a fristenrechner-category proceeding_types row. NULL passes
// through; the matching DB trigger (mig 088) is the defence-in-depth
// backstop should this slip somehow.
//
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
// 400 with a bilingual user-facing message.
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
if ptID == nil {
return nil
}
var category sql.NullString
if err := s.db.GetContext(ctx, &category,
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
}
return fmt.Errorf("lookup proceeding_type category: %w", err)
}
if !category.Valid || category.String != "fristenrechner" {
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
ErrInvalidProceedingTypeCategory, *ptID, category.String)
}
return nil
}
// 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()
}
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
// to the design defaults: proceeding_type_id = upc.rev.cfi, our_side = inverted
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
// patent reference is resolvable, else "<parent title> — Widerklage".
//
// FlipOurSide is a tri-state via *bool to distinguish "default-flip" (nil)
// from the explicit "Stimmt nicht?" override (false = keep parent's side,
// true = flip explicitly). The R.49.2.b CCI edge case is the reason this
// override exists (see docs/design-smart-timeline-2026-05-08.md §11 Q2).
type CounterclaimOpts struct {
ProceedingTypeID *int
FlipOurSide *bool
Title *string
CaseNumber *string
}
// LoadCounterclaimChildrenVisible returns the CCR sub-projects filed
// against parentID that the caller can see. Each row is a normal
// paliad.projects row with counterclaim_of=parentID. Used by the
// SmartTimeline to render parallel right-tracks (t-paliad-174 §4.5).
func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, userID, parentID uuid.UUID) ([]models.Project, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.Project{}, nil
}
rows := []models.Project{}
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
WHERE p.counterclaim_of = $1
AND ` + visibilityPredicatePositional("p", 2) + `
ORDER BY p.created_at ASC, p.id ASC`
if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
return nil, fmt.Errorf("load counterclaim children: %w", err)
}
if err := PopulateProjectCodes(ctx, s.db, rows); err != nil {
return nil, err
}
return rows, nil
}
// CreateCounterclaim creates a CCR sub-project against parentID. Atomic:
// project + creator-as-lead team membership + audit rows on parent AND
// child are all written in a single transaction.
//
// Placement (§4.4): the CCR child is a sibling under the same patent —
// child.parent_id = parent.parent_id. When the parent has no parent_id
// (root case at the top of its tree) we fall back to parent.id as the
// CCR child's parent so the row remains in the same subtree.
//
// our_side flip (§11 Q2): default-inverts claimant↔defendant; "court"
// and "both" pass through unchanged. The opts.FlipOurSide override
// supports the rare R.49.2.b CCI shape where flipping is wrong.
//
// proceeding_type_id default (§4.4): upc.rev.cfi for the standard CCR-on-
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
// explicitly when they want it.
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
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)
}
parent, err := s.GetByID(ctx, userID, parentID)
if err != nil {
return nil, err
}
if parent.CounterclaimOf != nil {
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
}
// Resolve proceeding_type_id default to upc.rev.cfi when caller didn't
// override. The DB row is required because the projection layer
// dereferences it (paliad.proceeding_types.code).
procTypeID := 0
if opts.ProceedingTypeID != nil {
procTypeID = *opts.ProceedingTypeID
} else {
err := s.db.GetContext(ctx, &procTypeID,
`SELECT id FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, CodeUPCRevocation)
if err != nil {
return nil, fmt.Errorf("resolve default %s proceeding type: %w", CodeUPCRevocation, err)
}
}
childOurSide := derivedCounterclaimOurSide(parent.OurSide, opts.FlipOurSide)
childParentID := parent.ParentID
if childParentID == nil {
// Parent has no parent_id (root case at the top of its tree).
// Fall back to parent.id so the CCR child stays in the same
// subtree rather than becoming a new root. The visibility
// predicate inherits cleanly either way.
fallback := parent.ID
childParentID = &fallback
}
// Resolve the best patent reference for the suggested title — when
// parent is a case, the patent_number lives on its patent ancestor.
patentRef := s.resolvePatentReferenceForTitle(ctx, userID, parent)
title := derivedCounterclaimTitle(parent, patentRef, opts.Title)
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 placeholder is overwritten by paliad.projects_sync_path();
// same rationale as ProjectService.Create — see comment there for
// why we use a literal '' instead of re-referencing $1.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, 'case', $2, '', $3, 'active', $4,
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
id, childParentID, title, userID,
parent.Court, opts.CaseNumber, procTypeID,
nullableOurSide(&childOurSide), parentID, now,
); err != nil {
return nil, fmt.Errorf("insert counterclaim project: %w", err)
}
// Auto-add creator as team lead on the new CCR row so RLS lets the
// caller see the project they just made. Mirrors Create.
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)
}
// Audit rows on both parent and child for symmetric trail. Both rows
// opt into the SmartTimeline via timeline_kind='milestone'. The
// bubble_up=true flag (t-paliad-175 §5.3 Q5) lets these structural
// milestones surface on Patent / Litigation / Client SmartTimelines
// even though the level policy filters out other milestones.
if err := insertCounterclaimEvent(ctx, tx, id, userID,
"Widerklage (CCR) angelegt",
map[string]any{
"counterclaim_of": parentID.String(),
"bubble_up": true,
},
); err != nil {
return nil, err
}
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
"Widerklage (CCR) angelegt",
map[string]any{
"counterclaim_id": id.String(),
"bubble_up": true,
},
); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create counterclaim: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// insertCounterclaimEvent writes a paliad.project_events row with
// event_type='counterclaim_created' AND timeline_kind='milestone' so
// the audit row surfaces on the SmartTimeline by default. Matches the
// pattern Slice 1 established for opt-in milestones (§2.2).
func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, title 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 counterclaim_created 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, timeline_kind)
VALUES ($1, $2, 'counterclaim_created', $3, NULL, $4, $5, $6, $4, $4, 'milestone')`,
uuid.New(), projectID, title, now, userID, metaJSON)
if err != nil {
return fmt.Errorf("insert counterclaim_created event: %w", err)
}
return nil
}
// derivedCounterclaimOurSide computes the child's our_side from the
// parent's our_side and the opts.FlipOurSide override.
//
// Default (override nil OR override=true): flip across the active /
// reactive axis using the t-paliad-222 sub-role table —
//
// claimant ↔ defendant
// applicant ↔ respondent
// appellant → respondent (the CCR-against-appellant is the
// defending position; appellant has no
// symmetric counter-role in the new set)
//
// Third Party / Other (third_party, other) and NULL pass through
// unchanged — the flip is meaningless without a clear active / reactive
// posture. Legacy 'court' / 'both' no longer exist in the column
// (mig 112) so they have no case arm; if a stale value sneaks in via a
// pre-migration in-memory row it falls through to the default branch
// and passes through unchanged, preserving previous behaviour.
//
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
// edge case where the CCR sub-project shares the parent's perspective.
func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
if parentSide == nil {
return ""
}
side := strings.TrimSpace(*parentSide)
flip := true
if override != nil {
flip = *override
}
if !flip {
return side
}
switch side {
case "claimant":
return "defendant"
case "defendant":
return "claimant"
case "applicant":
return "respondent"
case "respondent":
return "applicant"
case "appellant":
return "respondent"
default:
return side
}
}
// resolvePatentReferenceForTitle returns the closest patent_number /
// reference to use as the CCR title prefix. Parent is usually a case
// row (no patent_number on it) — walks up ancestors to find the patent
// hub. Best-effort: returns empty when no patent ancestor is visible.
func (s *ProjectService) resolvePatentReferenceForTitle(ctx context.Context, userID uuid.UUID, parent *models.Project) string {
if parent.PatentNumber != nil && strings.TrimSpace(*parent.PatentNumber) != "" {
return strings.TrimSpace(*parent.PatentNumber)
}
ancestors, err := s.ListAncestors(ctx, userID, parent.ID)
if err != nil || len(ancestors) == 0 {
return ""
}
for i := len(ancestors) - 1; i >= 0; i-- {
a := ancestors[i]
if a.PatentNumber != nil && strings.TrimSpace(*a.PatentNumber) != "" {
return strings.TrimSpace(*a.PatentNumber)
}
}
return ""
}
// derivedCounterclaimTitle picks the auto-suggested title for the CCR
// child. Override wins when supplied; otherwise prefers the patent
// reference, then parent.reference, then parent.title — each yields
// "<ref> — Widerklage (CCR)".
func derivedCounterclaimTitle(parent *models.Project, patentRef string, override *string) string {
if override != nil {
v := strings.TrimSpace(*override)
if v != "" {
return v
}
}
suffix := " — Widerklage (CCR)"
if patentRef != "" {
return patentRef + suffix
}
if parent.Reference != nil && strings.TrimSpace(*parent.Reference) != "" {
return strings.TrimSpace(*parent.Reference) + suffix
}
return strings.TrimSpace(parent.Title) + suffix
}
// 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()
// --- 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
// Include every pending deadline regardless of due_date — overdue
// deadlines are MORE urgent than upcoming ones, not less, so a card
// labelled "Nächste Termine" must surface them first. Sort ASC so the
// most-overdue lands at the top, naturally followed by today / soon
// (m, 2026-05-08 15:02 — "5 offen" was visible but Nächste Termine
// stayed empty because the >= today filter dropped overdue pending).
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'
)
SELECT project_id, id, title, due_date, status
FROM ranked WHERE rn <= 3
`
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). ---
// Past appointments stay excluded (they're history, not "next") —
// unlike deadlines where overdue-pending is more urgent than upcoming.
type rowAppointment struct {
ProjectID uuid.UUID `db:"project_id"`
ID uuid.UUID `db:"id"`
Title string `db:"title"`
StartsAt time.Time `db:"start_at"`
}
var as []rowAppointment
// paliad.appointments column is `start_at` (singular). Earlier this
// query used `starts_at` which does not exist — the resulting query
// error short-circuited CardsPreview, the handler 500'd, and every
// project card on /projects rendered "keine bevorstehenden Termine"
// regardless of how many deadlines were actually pending. Caught
// 2026-05-08 21:16 against m's UPC-CoA Berufung Huawei card (4 open
// deadlines visible from the count, zero in the preview).
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.start_at,
ROW_NUMBER() OVER (
PARTITION BY t.project_id
ORDER BY t.start_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.start_at >= $%d::timestamptz
)
SELECT project_id, id, title, start_at
FROM ranked WHERE rn <= 3
`
aArgs := make([]any, 0, len(args)+1)
aArgs = append(aArgs, args...)
aArgs = append(aArgs, now)
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, ProjectTypeOther:
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)
}
// validateOurSide checks the project-level "Client Role" enum
// (t-paliad-164, widened in t-paliad-222 / m/paliad#47). Empty string
// is the explicit "clear" sentinel — callers pass the value as-is
// from the form payload, and the helper accepts it so an Update can
// null the column. The DB-level CHECK constraint (mig 112) enforces
// the same set; this validation gives a clearer error than relying
// on the constraint to fire.
//
// Allowed sub-roles, grouped at display time:
// Active (we initiate) : claimant, applicant, appellant
// Reactive (we defend) : defendant, respondent
// Third Party / Other : third_party, other
//
// Legacy 'court' / 'both' are no longer accepted (mig 112 backfills
// existing rows to NULL); callers that still send them get a clear
// validation error rather than a constraint violation.
func validateOurSide(s string) error {
switch strings.TrimSpace(s) {
case "",
"claimant", "defendant",
"applicant", "appellant",
"respondent",
"third_party", "other":
return nil
}
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
}
// validateInstanceLevel checks the procedural-instance enum (Phase 3
// Slice 8, t-paliad-189, design §7). Empty string clears the column;
// the three named values map to the rule-corpus ladder de.inf.lg →
// de.inf.olg → de.inf.bgh that the SmartTimeline will surface in a
// follow-up calculator slice. The DB-level CHECK on mig 080 enforces
// the same set; this validation gives a clearer error than letting
// the trigger fire.
func validateInstanceLevel(s string) error {
switch strings.TrimSpace(s) {
case "", "first", "appeal", "cassation":
return nil
}
return fmt.Errorf("%w: invalid instance_level %q (allowed: first | appeal | cassation | <empty>)",
ErrInvalidInput, s)
}
// nullableInstanceLevel returns nil for an empty / whitespace value so
// the SQL driver writes NULL, otherwise the trimmed string. Mirrors
// nullableOurSide.
func nullableInstanceLevel(p *string) any {
if p == nil {
return nil
}
s := strings.TrimSpace(*p)
if s == "" {
return nil
}
return s
}
// nullableOurSide returns nil for an empty / whitespace value so the
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
// Update payload contract: empty string from the form clears the
// column, a value sets it.
func nullableOurSide(p *string) any {
if p == nil {
return nil
}
v := strings.TrimSpace(*p)
if v == "" {
return nil
}
return v
}
// opponentCodePattern matches the slug shape enforced by the
// projects_opponent_code_check constraint (mig 113): uppercase letters,
// digits, dashes, 1-16 chars. The DB CHECK is the source of truth; this
// helper surfaces a friendlier ErrInvalidInput error before the write.
var opponentCodePattern = regexp.MustCompile(`^[A-Z0-9-]{1,16}$`)
// validateOpponentCode checks the litigation-only opponent_code slug
// (t-paliad-222 / m/paliad#50). Empty string clears the column; a
// non-empty value must match opponentCodePattern AND the row must be
// type='litigation' (the DB CHECK enforces this pairing).
//
// projectType may be empty when the caller is doing a partial Update
// against the current row's type — in that case we skip the type gate
// (the Update layer passes current.Type instead, which always has it).
func validateOpponentCode(s, projectType string) error {
v := strings.TrimSpace(s)
if v == "" {
return nil
}
if projectType != "" && projectType != "litigation" {
return fmt.Errorf("%w: opponent_code only valid on type=litigation (got %q)",
ErrInvalidInput, projectType)
}
if !opponentCodePattern.MatchString(v) {
return fmt.Errorf("%w: invalid opponent_code %q (allowed: %s)",
ErrInvalidInput, s, "[A-Z0-9-]{1,16}")
}
return nil
}
// nullableOpponentCode mirrors nullableOurSide for opponent_code: nil
// or empty/whitespace → SQL NULL; otherwise the trimmed slug.
func nullableOpponentCode(p *string) any {
if p == nil {
return nil
}
v := strings.TrimSpace(*p)
if v == "" {
return nil
}
return v
}
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]
}
}
}