Mirror paliad.can_see_project's global-admin shortcut at the application
layer. The in-Go predicate previously relied on callers passing
user.GlobalRole as a separate :role / $roleArg parameter — the positional
variant compared against the literal 'admin' instead of 'global_admin',
so any global_admin without team membership got 404 from
/api/projects/{id} (and the other positional callsites: ListAncestors,
BuildTree, GetTree, deadline counts).
Fold the gate into a Go helper that resolves global_admin via EXISTS on
paliad.users, keyed only by userID. Callers no longer pass role, which
removes the foot-gun entirely. Drops the unused
visibilityPredicatePlaceholder dead helper.
Adds a regression test (visibility_test.go) covering global_admin +
standard user against GetByID and BuildTree without project_teams rows.
803 lines
27 KiB
Go
803 lines
27 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/patholo/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"
|
|
)
|
|
|
|
// ProjektRole 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 projektColumns = `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`
|
|
|
|
// CreateProjektInput is the payload for Create.
|
|
type CreateProjektInput 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"`
|
|
}
|
|
|
|
// UpdateProjektInput is the partial-update payload.
|
|
type UpdateProjektInput 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 + "%"
|
|
}
|
|
|
|
query := `SELECT ` + projektColumns + ` FROM paliad.projects p
|
|
WHERE ` + strings.Join(conds, " AND ") + `
|
|
ORDER BY p.updated_at DESC`
|
|
|
|
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
|
|
}
|
|
|
|
// 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 ` + projektColumns + ` 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 ` + projektColumns + ` 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 ` + projektColumns + ` 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 ` + projektColumns + ` 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 CreateProjektInput) (*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 := validateProjektStatus(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 UpdateProjektInput) (*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 := validateProjektStatus(*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)
|
|
}
|
|
|
|
if input.Status != nil && *input.Status != current.Status {
|
|
desc := fmt.Sprintf("Status %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("Type %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).
|
|
func (s *ProjectService) ListEvents(ctx context.Context, userID, id uuid.UUID, before *uuid.UUID, limit int) ([]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 events []models.ProjectEvent
|
|
err := s.db.SelectContext(ctx, &events,
|
|
`SELECT id, project_id, event_type, title, description, event_date,
|
|
created_by, metadata, created_at, updated_at
|
|
FROM paliad.project_events
|
|
WHERE project_id = $1
|
|
AND ($2::uuid IS NULL OR (created_at, id) < (
|
|
SELECT created_at, id FROM paliad.project_events WHERE id = $2::uuid
|
|
))
|
|
ORDER BY created_at DESC, 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, projektID, userID uuid.UUID, eventType, title string, description *string) error {
|
|
now := time.Now().UTC()
|
|
meta := json.RawMessage(`{}`)
|
|
_, 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(), projektID, eventType, title, description, now, userID, meta)
|
|
if err != nil {
|
|
return fmt.Errorf("insert projekt_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 validateProjektStatus(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]
|
|
}
|
|
}
|
|
}
|