Files
paliad/internal/services/project_service.go
m d41fc49809 feat(t-paliad-139): Phase 1 — /projects/{id} aggregation bug fix
m's bug: /projects/{client_id} renders "Keine Fristen" / "Keine Termine" /
"Noch keine Ereignisse" even when descendant Cases carry deadlines, appts,
and audit events. Live verification on Siemens AG client
(61e3fb9e-29fb-44aa-867e-a89469e2cacb): 9 descendant projects, 19
deadlines, 37 project_events, 4 appointments — none on the Client row,
all invisible until now.

Root cause: 3 legacy per-project read paths used WHERE project_id = $1
(exact match), bypassing the projectDescendantPredicate primitive that
internal/services/visibility.go:68 already provides and that the t-124
union endpoints (DeadlineService.ListVisibleForUser etc.) already use.

Backend
-------
- DeadlineService.ListForProject(..., directOnly bool): subtree by
  default via WHERE project_id IN (SELECT pp.id FROM paliad.projects pp
  WHERE $1 = ANY(string_to_array(pp.path, '.')::uuid[])); collapses to
  WHERE project_id = $1 when directOnly=true.
- AppointmentService.ListForProject: same shape.
- ProjectService.ListEvents(..., directOnly bool): same shape, plus
  LEFT JOIN paliad.projects to surface project_title for the Verlauf
  attribution chip on /projects/{id}. Inner subquery aliased pp to
  avoid shadowing the outer join's p.
- models.ProjectEvent: new optional ProjectTitle string for the Verlauf
  enrichment. Other readers leave it nil and the JSON serialiser omits
  it (json:"project_title,omitempty").
- handlers/{deadlines,appointments,projects}.go: handler reads
  ?direct_only=true|false and passes through to the service. New
  handlers.parseDirectOnly helper centralises the parse.
- project_filter_descendants_test.go: extended to also pin
  DeadlineService.ListForProject + AppointmentService.ListForProject
  + ProjectService.ListEvents (live-DB test, skipped without
  TEST_DATABASE_URL).

Frontend
--------
- projects-detail.ts: switched the deadline + appointment fetches from
  /api/projects/{id}/deadlines + /appointments (legacy narrow) to
  /api/events?type=deadline|appointment&project_id={id} (the union
  endpoints, already aggregating + enriching with project_title). The
  Verlauf still uses /api/projects/{id}/events but with the new
  direct_only flag wiring.
- New subtreeMode state machine + URL param ?subtree=false. Default =
  subtree (true). persistSubtreeMode replaceState keeps back-button
  friendly.
- 3 new .subtree-toggle buttons in /projects/{id} History, Deadlines,
  Appointments sections. Shared state across the three; clicking any
  toggle reloads all three sections at once.
- attributionChip(rowProjectID, rowProjectTitle): inline chip "auf:
  Case 14-vs-Müller" rendered when row.project_id !== currentProjectID.
  Suppressed for direct rows.
- Deadline / Appointment / ProjectEvent interfaces gained an optional
  project_title for the chip data path.
- 3 new i18n keys: aggregation.toggle.subtree (Inkl. Unterprojekte /
  Incl. sub-projects), aggregation.toggle.direct_only (Nur direkt /
  Direct only), aggregation.attribution.on (auf / on). DE+EN.
- global.css: .subtree-toggle, .subtree-toggle--active,
  .aggregation-chip — small additive styling.

No schema. No migration. Phases 2 + 3 stack on top per design §7.
2026-05-06 16:24:31 +02:00

857 lines
30 KiB
Go

package services
// ProjectService handles CRUD on paliad.projects — the hierarchical
// project tree that replaced the flat paliad.akten model in migration 018.
//
// Visibility (design v2, adjusted 2026-04-20): team-based only.
// A user can see a Project iff
// - user is admin, or
// - user is a direct member of the Project's team, or
// - user is a member of any ancestor Project's team (inherited via path).
//
// Office is no longer a visibility gate. Cases associate with lead partners,
// not offices (see paliad.project_teams role='lead').
//
// The canonical predicate lives in SQL (paliad.can_see_project) and is
// enforced by RLS policies. This service re-implements the same predicate
// at the application layer so the service-role DB connection (without an
// auth.uid() JWT) still gates correctly.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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"
)
// ProjectRole values allowed on project_teams.role.
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.
type ProjectTreeNode struct {
models.Project
Children []*ProjectTreeNode `json:"children"`
OpenDeadlines int `json:"open_deadlines"`
OverdueDeadlines int `json:"overdue_deadlines"`
}
// 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.
func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*ProjectTreeNode, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []*ProjectTreeNode{}, nil
}
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)
}
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
}
nodes := make(map[uuid.UUID]*ProjectTreeNode, len(rows))
for i := range rows {
c := countByID[rows[i].ID]
nodes[rows[i].ID] = &ProjectTreeNode{
Project: rows[i],
Children: []*ProjectTreeNode{},
OpenDeadlines: c.Open,
OverdueDeadlines: c.Overdue,
}
}
// Stitch children into parents. Roots are projects whose parent_id is
// NULL or whose parent is invisible (orphaned in the user's view) —
// promote those to root so the user still sees them.
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)
return roots, nil
}
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.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
VALUES ($1, $2, '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
}
// ============================================================================
// 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]
}
}
}