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