#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an inheritable role-edit gate via the materialised ltree path. - migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase. - services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate. - services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column). - services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError. - handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage. - handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip. - frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg. - i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs). - tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table. go build && go test -short ./internal/... && bun run build all clean.
1984 lines
70 KiB
Go
1984 lines
70 KiB
Go
package services
|
||
|
||
// ProjectService handles CRUD on paliad.projects — the hierarchical
|
||
// project tree that replaced the flat paliad.akten model in migration 018.
|
||
//
|
||
// Visibility (design v2, adjusted 2026-04-20): team-based only.
|
||
// A user can see a Project iff
|
||
// - user is admin, or
|
||
// - user is a direct member of the Project's team, or
|
||
// - user is a member of any ancestor Project's team (inherited via path).
|
||
//
|
||
// Office is no longer a visibility gate. Cases associate with lead partners,
|
||
// not offices (see paliad.project_teams role='lead').
|
||
//
|
||
// The canonical predicate lives in SQL (paliad.can_see_project) and is
|
||
// enforced by RLS policies. This service re-implements the same predicate
|
||
// at the application layer so the service-role DB connection (without an
|
||
// auth.uid() JWT) still gates correctly.
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jmoiron/sqlx"
|
||
"github.com/lib/pq"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/models"
|
||
)
|
||
|
||
// Sentinel errors.
|
||
var (
|
||
// ErrNotVisible indicates the Project exists but the user has no
|
||
// visibility. Handlers must map to 404 (never leak existence).
|
||
ErrNotVisible = errors.New("project not visible")
|
||
// ErrForbidden indicates the user is authenticated but lacks the role
|
||
// required for the operation (e.g., associate trying to delete).
|
||
ErrForbidden = errors.New("forbidden")
|
||
// ErrInvalidInput signals a bad request (empty required field etc.).
|
||
ErrInvalidInput = errors.New("invalid input")
|
||
// 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, 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"`
|
||
// 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"`
|
||
// 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)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// CanSee reports whether the user has visibility on the Project. Returns
|
||
// (false, nil) for invisible or missing — handlers must not distinguish.
|
||
// Cheaper than GetByID when only the visibility bit is needed (no projection
|
||
// of the full row); used by sibling services (NoteService, etc.) to gate on
|
||
// the parent without paying for a full SELECT.
|
||
func (s *ProjectService) CanSee(ctx context.Context, userID, id uuid.UUID) (bool, error) {
|
||
var visible bool
|
||
query := `SELECT EXISTS (SELECT 1 FROM paliad.projects p
|
||
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2) + `)`
|
||
if err := s.db.GetContext(ctx, &visible, query, id, userID); err != nil {
|
||
return false, fmt.Errorf("check project visibility: %w", err)
|
||
}
|
||
return visible, nil
|
||
}
|
||
|
||
// GetByID returns the Project if the user can see it. Returns (nil, ErrNotVisible)
|
||
// when invisible or missing — handlers must not distinguish.
|
||
func (s *ProjectService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Project, error) {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return nil, ErrNotVisible
|
||
}
|
||
var p models.Project
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE p.id = $1 AND ` + visibilityPredicatePositional("p", 2)
|
||
err = s.db.GetContext(ctx, &p, query, id, userID)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, ErrNotVisible
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get project: %w", err)
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// ListChildren returns direct children of a Project (visibility-checked on parent).
|
||
func (s *ProjectService) ListChildren(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
|
||
if _, err := s.GetByID(ctx, userID, id); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.List(ctx, userID, ProjectFilter{ParentID: &id})
|
||
}
|
||
|
||
// ListAncestors walks up the path and returns ancestors from root → parent
|
||
// (exclusive of the Project itself). Used for breadcrumbs.
|
||
func (s *ProjectService) ListAncestors(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
|
||
p, err := s.GetByID(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
labels := strings.Split(p.Path, ".")
|
||
if len(labels) <= 1 {
|
||
return []models.Project{}, nil
|
||
}
|
||
// All but last = ancestors.
|
||
ancestorIDs := labels[:len(labels)-1]
|
||
ids := make([]uuid.UUID, 0, len(ancestorIDs))
|
||
for _, s := range ancestorIDs {
|
||
u, err := uuid.Parse(s)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse ancestor uuid %q: %w", s, err)
|
||
}
|
||
ids = append(ids, u)
|
||
}
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return []models.Project{}, nil
|
||
}
|
||
// Ancestors are visible whenever the Project is (inheritance works both
|
||
// ways through team membership checks). We still apply the predicate
|
||
// for safety in case path is stale.
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE p.id = ANY($1::uuid[]) AND ` +
|
||
visibilityPredicatePositional("p", 2)
|
||
// lib/pq doesn't serialise []uuid.UUID natively; render as string array.
|
||
idStrs := make([]string, len(ids))
|
||
for i, u := range ids {
|
||
idStrs[i] = u.String()
|
||
}
|
||
rows := []models.Project{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, pq.StringArray(idStrs), userID); err != nil {
|
||
return nil, fmt.Errorf("list ancestors: %w", err)
|
||
}
|
||
// Re-order to match path order (root first).
|
||
order := make(map[uuid.UUID]int, len(ids))
|
||
for i, id := range ids {
|
||
order[id] = i
|
||
}
|
||
sortByOrder(rows, order)
|
||
return rows, nil
|
||
}
|
||
|
||
// ProjectTreeNode is one node of the nested tree returned by BuildTree.
|
||
// It embeds the full Project plus aggregated child nodes and deadline
|
||
// counts so the UI can render badges without per-row API calls.
|
||
//
|
||
// Subtree counts (OpenDeadlinesSubtree / OverdueDeadlinesSubtree) and
|
||
// the chip-driven flags (Pinned, InheritedVisibility, MatchKind) are
|
||
// only populated when BuildTreeWithOptions is called with the relevant
|
||
// options enabled. The legacy BuildTree(ctx, userID) call leaves them
|
||
// at zero / empty for back-compat.
|
||
type ProjectTreeNode struct {
|
||
models.Project
|
||
Children []*ProjectTreeNode `json:"children"`
|
||
OpenDeadlines int `json:"open_deadlines"`
|
||
OverdueDeadlines int `json:"overdue_deadlines"`
|
||
OpenDeadlinesSubtree int `json:"open_deadlines_subtree"`
|
||
OverdueDeadlinesSubtree int `json:"overdue_deadlines_subtree"`
|
||
Pinned bool `json:"pinned"`
|
||
InheritedVisibility bool `json:"inherited_visibility"`
|
||
// MatchKind is empty unless a search term is active. Values:
|
||
// "self" (direct match), "ancestor" (on the path to a match),
|
||
// "descendant" (under a match).
|
||
MatchKind string `json:"match_kind,omitempty"`
|
||
}
|
||
|
||
// TreeScope discriminates the chip-driven scope filter for BuildTreeWithOptions.
|
||
type TreeScope string
|
||
|
||
const (
|
||
// ScopeAll returns every visible project (default).
|
||
ScopeAll TreeScope = ""
|
||
// ScopeMine returns directly-staffed projects + their visible ancestors
|
||
// (ancestors flagged InheritedVisibility=true so the UI can render them
|
||
// greyed for context).
|
||
ScopeMine TreeScope = "mine"
|
||
// ScopePinned returns only projects in paliad.user_pinned_projects for
|
||
// the user. Ancestors are NOT auto-included (the chip is "show me the
|
||
// pinned set, period").
|
||
ScopePinned TreeScope = "pinned"
|
||
)
|
||
|
||
// BuildTreeOptions controls BuildTreeWithOptions. Zero value yields the
|
||
// legacy BuildTree behaviour (every visible project, per-node counts).
|
||
type BuildTreeOptions struct {
|
||
// Scope is the chip-driven scope filter ("Alle" / "Nur meine" / "Angepinnt").
|
||
Scope TreeScope
|
||
|
||
// PinnedSet is the user's pinned-project set, populated by the handler
|
||
// from PinService.PinnedSet so BuildTree doesn't need a PinService dep.
|
||
// nil → no pin information attached (Pinned=false on every node).
|
||
PinnedSet map[uuid.UUID]struct{}
|
||
|
||
// StatusIn narrows to rows whose status ∈ values. Empty = no narrowing.
|
||
StatusIn []string
|
||
|
||
// TypeIn narrows to rows whose type ∈ values. Empty = no narrowing.
|
||
TypeIn []string
|
||
|
||
// HasOpenDeadlines, when non-nil, narrows to rows with at least one
|
||
// pending deadline (true) or zero pending deadlines (false). Applied
|
||
// AFTER subtree-count computation so the count itself drives the gate.
|
||
HasOpenDeadlines *bool
|
||
|
||
// SearchTerm filters to nodes whose title / reference / clientmatter /
|
||
// ancestor title matches. Match-kind is tagged per node:
|
||
// "self" — direct hit
|
||
// "ancestor" — on the path to a hit
|
||
// "descendant" — under a hit (kept for context; same subtree)
|
||
// Empty = no search.
|
||
SearchTerm string
|
||
|
||
// IncludeSubtreeCounts populates OpenDeadlinesSubtree +
|
||
// OverdueDeadlinesSubtree. Default: true. Set false for the
|
||
// legacy per-node-only behaviour.
|
||
IncludeSubtreeCounts bool
|
||
}
|
||
|
||
// BuildTree returns the full nested tree of every Project the user can see,
|
||
// rooted at all parent_id-IS-NULL projects. Each node carries its open and
|
||
// overdue deadline counts (open=pending, overdue=pending&past-due) so the UI
|
||
// can render status badges with no extra round-trips. Path-sorted so callers
|
||
// get a stable deterministic ordering.
|
||
//
|
||
// This is a thin shim over BuildTreeWithOptions for back-compat with callers
|
||
// that just want every visible project. New callers (the /projects page
|
||
// post-t-paliad-149) should use BuildTreeWithOptions directly to access
|
||
// chip filters + subtree counts + pinning + search.
|
||
func (s *ProjectService) BuildTree(ctx context.Context, userID uuid.UUID) ([]*ProjectTreeNode, error) {
|
||
return s.BuildTreeWithOptions(ctx, userID, BuildTreeOptions{
|
||
// Default IncludeSubtreeCounts=false: BuildTree's existing callers
|
||
// (the existing tree view) read OpenDeadlines / OverdueDeadlines
|
||
// per-node. Subtree counts are opt-in by the new /projects page
|
||
// via BuildTreeWithOptions.
|
||
})
|
||
}
|
||
|
||
// BuildTreeWithOptions is the chip-aware tree builder. See BuildTreeOptions
|
||
// for the knobs. Returns nodes in path order.
|
||
func (s *ProjectService) BuildTreeWithOptions(ctx context.Context, userID uuid.UUID, opts BuildTreeOptions) ([]*ProjectTreeNode, error) {
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return []*ProjectTreeNode{}, nil
|
||
}
|
||
|
||
// Step 1 — load every visible project (path-ordered). The chip filters
|
||
// (status / type / search) narrow the selection, but Scope=Mine and
|
||
// the subtree-count aggregation BOTH need the full visible set so we
|
||
// can include greyed ancestors and aggregate counts up the tree. The
|
||
// final filtering happens in-memory after the tree is stitched.
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE ` + visibilityPredicatePositional("p", 1) + `
|
||
ORDER BY p.path`
|
||
rows := []models.Project{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil {
|
||
return nil, fmt.Errorf("build tree list: %w", err)
|
||
}
|
||
|
||
// Step 2 — per-node deadline counts (always; cheap one-shot query).
|
||
type deadlineCount struct {
|
||
ProjectID uuid.UUID `db:"project_id"`
|
||
Open int `db:"open"`
|
||
Overdue int `db:"overdue"`
|
||
}
|
||
now := time.Now().UTC()
|
||
today := now.Truncate(24 * time.Hour)
|
||
var counts []deadlineCount
|
||
if err := s.db.SelectContext(ctx, &counts, `
|
||
SELECT f.project_id,
|
||
COUNT(*) FILTER (WHERE f.status = 'pending') AS open,
|
||
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < $2::date) AS overdue
|
||
FROM paliad.deadlines f
|
||
JOIN paliad.projects p ON p.id = f.project_id
|
||
WHERE `+visibilityPredicatePositional("p", 1)+`
|
||
GROUP BY f.project_id`, userID, today); err != nil {
|
||
return nil, fmt.Errorf("build tree deadline counts: %w", err)
|
||
}
|
||
countByID := make(map[uuid.UUID]deadlineCount, len(counts))
|
||
for _, c := range counts {
|
||
countByID[c.ProjectID] = c
|
||
}
|
||
|
||
// Step 3 — for ScopeMine, load directly-staffed project IDs. For
|
||
// ScopePinned, the PinnedSet is the source of truth.
|
||
var directlyStaffed map[uuid.UUID]struct{}
|
||
if opts.Scope == ScopeMine {
|
||
var ids []uuid.UUID
|
||
if err := s.db.SelectContext(ctx, &ids, `
|
||
SELECT DISTINCT pt.project_id
|
||
FROM paliad.project_teams pt
|
||
WHERE pt.user_id = $1
|
||
`, userID); err != nil {
|
||
return nil, fmt.Errorf("build tree direct staffing: %w", err)
|
||
}
|
||
directlyStaffed = make(map[uuid.UUID]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
directlyStaffed[id] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// Step 4 — build node map + stitch the full tree.
|
||
nodes := make(map[uuid.UUID]*ProjectTreeNode, len(rows))
|
||
for i := range rows {
|
||
c := countByID[rows[i].ID]
|
||
n := &ProjectTreeNode{
|
||
Project: rows[i],
|
||
Children: []*ProjectTreeNode{},
|
||
OpenDeadlines: c.Open,
|
||
OverdueDeadlines: c.Overdue,
|
||
}
|
||
if opts.PinnedSet != nil {
|
||
if _, pinned := opts.PinnedSet[rows[i].ID]; pinned {
|
||
n.Pinned = true
|
||
}
|
||
}
|
||
nodes[rows[i].ID] = n
|
||
}
|
||
|
||
roots := []*ProjectTreeNode{}
|
||
for _, n := range nodes {
|
||
if n.ParentID == nil {
|
||
roots = append(roots, n)
|
||
continue
|
||
}
|
||
parent, ok := nodes[*n.ParentID]
|
||
if !ok {
|
||
roots = append(roots, n)
|
||
continue
|
||
}
|
||
parent.Children = append(parent.Children, n)
|
||
}
|
||
sortTreeByPath(roots)
|
||
|
||
// Step 5 — subtree-aggregated counts (post-order DFS sums each node's
|
||
// own counts plus every descendant's). Cheap (O(N)).
|
||
if opts.IncludeSubtreeCounts {
|
||
var aggregate func(n *ProjectTreeNode) (open, overdue int)
|
||
aggregate = func(n *ProjectTreeNode) (int, int) {
|
||
open := n.OpenDeadlines
|
||
overdue := n.OverdueDeadlines
|
||
for _, c := range n.Children {
|
||
co, cv := aggregate(c)
|
||
open += co
|
||
overdue += cv
|
||
}
|
||
n.OpenDeadlinesSubtree = open
|
||
n.OverdueDeadlinesSubtree = overdue
|
||
return open, overdue
|
||
}
|
||
for _, r := range roots {
|
||
aggregate(r)
|
||
}
|
||
}
|
||
|
||
// Step 6 — apply Scope filter. ScopeAll: no-op. ScopeMine: keep
|
||
// directly-staffed nodes + their ancestors (flagged InheritedVisibility
|
||
// for grey-rendering). ScopePinned: keep pinned nodes + their ancestors.
|
||
switch opts.Scope {
|
||
case ScopeMine:
|
||
keep := pathClosure(nodes, directlyStaffed)
|
||
markInherited(nodes, keep, directlyStaffed)
|
||
roots = filterTree(roots, keep)
|
||
case ScopePinned:
|
||
if opts.PinnedSet == nil {
|
||
// No pin set provided → empty tree.
|
||
roots = nil
|
||
break
|
||
}
|
||
keep := pathClosure(nodes, opts.PinnedSet)
|
||
markInherited(nodes, keep, opts.PinnedSet)
|
||
roots = filterTree(roots, keep)
|
||
}
|
||
|
||
// Step 7 — chip filters (status / type / has-open-deadlines). We keep
|
||
// nodes that match AND any ancestors needed to root them (so the tree
|
||
// shape is preserved). Directly-narrowing children would orphan them.
|
||
if len(opts.StatusIn) > 0 || len(opts.TypeIn) > 0 || opts.HasOpenDeadlines != nil {
|
||
match := func(n *ProjectTreeNode) bool {
|
||
if len(opts.StatusIn) > 0 && !containsString(opts.StatusIn, n.Status) {
|
||
return false
|
||
}
|
||
if len(opts.TypeIn) > 0 && !containsString(opts.TypeIn, n.Type) {
|
||
return false
|
||
}
|
||
if opts.HasOpenDeadlines != nil {
|
||
openCount := n.OpenDeadlines
|
||
if opts.IncludeSubtreeCounts {
|
||
openCount = n.OpenDeadlinesSubtree
|
||
}
|
||
if *opts.HasOpenDeadlines {
|
||
if openCount == 0 {
|
||
return false
|
||
}
|
||
} else {
|
||
if openCount != 0 {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
matched := matchSet(nodes, match)
|
||
keep := pathClosure(nodes, matched)
|
||
// Don't flip InheritedVisibility for chip filters — only Scope=Mine /
|
||
// Scope=Pinned want greyed ancestors. Chips should narrow cleanly.
|
||
roots = filterTree(roots, keep)
|
||
}
|
||
|
||
// Step 8 — search. Tags every visible node with match_kind and prunes
|
||
// to the union of {matches ∪ ancestors-of-matches ∪ descendants-of-matches}.
|
||
if term := strings.TrimSpace(opts.SearchTerm); term != "" {
|
||
applySearch(nodes, &roots, term)
|
||
}
|
||
|
||
return roots, nil
|
||
}
|
||
|
||
// pathClosure expands a seed set of project IDs into the closure that
|
||
// includes every ancestor (via the materialised path) so a filtered tree
|
||
// stays connected to its roots. The output set always contains every seed.
|
||
func pathClosure(nodes map[uuid.UUID]*ProjectTreeNode, seeds map[uuid.UUID]struct{}) map[uuid.UUID]struct{} {
|
||
keep := make(map[uuid.UUID]struct{}, len(seeds))
|
||
for id := range seeds {
|
||
n, ok := nodes[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
keep[id] = struct{}{}
|
||
// Walk path labels, skipping empty splits.
|
||
for label := range strings.SplitSeq(n.Path, ".") {
|
||
if label == "" {
|
||
continue
|
||
}
|
||
anc, err := uuid.Parse(label)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if _, vis := nodes[anc]; vis {
|
||
keep[anc] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
return keep
|
||
}
|
||
|
||
// markInherited flips InheritedVisibility=true on nodes that are in the
|
||
// keep set but NOT in the directly-staffed seed set. The UI greys these
|
||
// rows so users understand they're context-only (visibility derives from
|
||
// path closure, not direct staffing).
|
||
func markInherited(nodes map[uuid.UUID]*ProjectTreeNode, keep, seed map[uuid.UUID]struct{}) {
|
||
for id := range keep {
|
||
if _, direct := seed[id]; direct {
|
||
continue
|
||
}
|
||
if n, ok := nodes[id]; ok {
|
||
n.InheritedVisibility = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// filterTree returns a new root list containing only nodes in keep, with
|
||
// each surviving node's Children also pruned to keep. Children of pruned
|
||
// nodes are dropped entirely (the path-closure step is what guarantees
|
||
// matched nodes remain rooted).
|
||
func filterTree(roots []*ProjectTreeNode, keep map[uuid.UUID]struct{}) []*ProjectTreeNode {
|
||
out := make([]*ProjectTreeNode, 0, len(roots))
|
||
for _, r := range roots {
|
||
if _, ok := keep[r.ID]; !ok {
|
||
continue
|
||
}
|
||
r.Children = filterTree(r.Children, keep)
|
||
out = append(out, r)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// matchSet returns the set of node IDs for which match(node) returns true.
|
||
func matchSet(nodes map[uuid.UUID]*ProjectTreeNode, match func(*ProjectTreeNode) bool) map[uuid.UUID]struct{} {
|
||
out := make(map[uuid.UUID]struct{})
|
||
for id, n := range nodes {
|
||
if match(n) {
|
||
out[id] = struct{}{}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// applySearch tags MatchKind on the visible nodes and prunes the tree to
|
||
// keep only nodes whose subtree contains a match (or which are themselves
|
||
// a match). Match scope: case-fold contains on title, reference,
|
||
// client_number, matter_number. Ancestor titles match too via the
|
||
// pathClosure semantics.
|
||
func applySearch(nodes map[uuid.UUID]*ProjectTreeNode, roots *[]*ProjectTreeNode, term string) {
|
||
q := strings.ToLower(term)
|
||
matches := make(map[uuid.UUID]struct{})
|
||
for id, n := range nodes {
|
||
if matchesSearch(n, q) {
|
||
matches[id] = struct{}{}
|
||
}
|
||
}
|
||
if len(matches) == 0 {
|
||
*roots = []*ProjectTreeNode{}
|
||
return
|
||
}
|
||
// Path closure includes ancestors. Descendants of matches are also kept
|
||
// (rendered as "descendant" so the user sees the full sub-context).
|
||
keep := pathClosure(nodes, matches)
|
||
descSet := make(map[uuid.UUID]struct{})
|
||
addDescendants(nodes, matches, descSet)
|
||
for id := range descSet {
|
||
keep[id] = struct{}{}
|
||
}
|
||
|
||
for id := range keep {
|
||
n, ok := nodes[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch {
|
||
case isInSet(matches, id):
|
||
n.MatchKind = "self"
|
||
case isInSet(descSet, id):
|
||
n.MatchKind = "descendant"
|
||
default:
|
||
n.MatchKind = "ancestor"
|
||
}
|
||
}
|
||
|
||
*roots = filterTree(*roots, keep)
|
||
}
|
||
|
||
func matchesSearch(n *ProjectTreeNode, q string) bool {
|
||
if strings.Contains(strings.ToLower(n.Title), q) {
|
||
return true
|
||
}
|
||
if n.Reference != nil && strings.Contains(strings.ToLower(*n.Reference), q) {
|
||
return true
|
||
}
|
||
if n.ClientNumber != nil && strings.Contains(strings.ToLower(*n.ClientNumber), q) {
|
||
return true
|
||
}
|
||
if n.MatterNumber != nil && strings.Contains(strings.ToLower(*n.MatterNumber), q) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func addDescendants(nodes map[uuid.UUID]*ProjectTreeNode, seeds, out map[uuid.UUID]struct{}) {
|
||
for seedID := range seeds {
|
||
seed, ok := nodes[seedID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
prefix := seed.Path + "."
|
||
for id, n := range nodes {
|
||
if id == seedID {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(n.Path, prefix) {
|
||
out[id] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func isInSet(set map[uuid.UUID]struct{}, id uuid.UUID) bool {
|
||
_, ok := set[id]
|
||
return ok
|
||
}
|
||
|
||
func containsString(haystack []string, needle string) bool {
|
||
return slices.Contains(haystack, needle)
|
||
}
|
||
|
||
func sortTreeByPath(nodes []*ProjectTreeNode) {
|
||
for i := 1; i < len(nodes); i++ {
|
||
for j := i; j > 0 && nodes[j].Path < nodes[j-1].Path; j-- {
|
||
nodes[j], nodes[j-1] = nodes[j-1], nodes[j]
|
||
}
|
||
}
|
||
for _, n := range nodes {
|
||
sortTreeByPath(n.Children)
|
||
}
|
||
}
|
||
|
||
// GetTree returns every Project in the subtree rooted at id (inclusive),
|
||
// ordered depth-first. Visibility-checked at root; descendants that the
|
||
// user can see are returned (the predicate naturally gates sub-branches).
|
||
func (s *ProjectService) GetTree(ctx context.Context, userID, id uuid.UUID) ([]models.Project, error) {
|
||
root, err := s.GetByID(ctx, userID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// path LIKE root.path || '.%' OR path = root.path
|
||
prefix := root.Path + ".%"
|
||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||
WHERE (p.path = $1 OR p.path LIKE $2)
|
||
AND ` + visibilityPredicatePositional("p", 3) + `
|
||
ORDER BY p.path`
|
||
rows := []models.Project{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, root.Path, prefix, userID); err != nil {
|
||
return nil, fmt.Errorf("get tree: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// Create inserts a new Project. If parent_id is set, the creator must have
|
||
// visibility on the parent. The creator is auto-added to project_teams as
|
||
// role='lead' in the same transaction so post-create SELECT picks up the row.
|
||
func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input CreateProjectInput) (*models.Project, error) {
|
||
if strings.TrimSpace(input.Title) == "" {
|
||
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
||
}
|
||
if !isValidProjectType(input.Type) {
|
||
return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, input.Type)
|
||
}
|
||
user, err := s.users.GetByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if user == nil {
|
||
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
|
||
}
|
||
if input.ParentID != nil {
|
||
if _, err := s.GetByID(ctx, userID, *input.ParentID); err != nil {
|
||
return nil, fmt.Errorf("%w: parent not visible", ErrForbidden)
|
||
}
|
||
}
|
||
status := input.Status
|
||
if status == "" {
|
||
status = "active"
|
||
}
|
||
if err := validateProjectStatus(status); err != nil {
|
||
return nil, err
|
||
}
|
||
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.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, 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, '{}'::jsonb, $24, $24)`,
|
||
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),
|
||
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.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)
|
||
}
|
||
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): claimant ↔ defendant, court
|
||
// and both pass through unchanged. NULL parent yields NULL child — the
|
||
// flip is meaningless without a known starting side.
|
||
//
|
||
// 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"
|
||
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 "represented side" enum
|
||
// (t-paliad-164). 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 enforces the same set; this validation gives a clearer
|
||
// error than relying on the constraint to fire.
|
||
func validateOurSide(s string) error {
|
||
switch strings.TrimSpace(s) {
|
||
case "", "claimant", "defendant", "court", "both":
|
||
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
|
||
}
|
||
|
||
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]
|
||
}
|
||
}
|
||
}
|