@@ -45,6 +45,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -63,23 +64,47 @@ func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
return & ApprovalService { db : db , users : users }
}
// LookupPolicy returns the approval policy for the given tuple, or nil if
// none exist s. Read inside the same tx as Submit* so policy reads see
// whatever the calling tx may have already written.
// LookupPolicy returns the effective approval policy for the given tuple,
// or nil if none applie s. Reads inside the same tx as Submit* so policy
// reads see whatever the calling tx may have already written.
//
// Resolution (t-paliad-154): delegates to paliad.approval_policy_effective(),
// which returns at most one row after walking the project-row → ancestor-row
// → unit-default cascade and picking most-restrictive across candidates.
//
// 'none' short-circuit: when the resolver yields required_role='none' (only
// possible from a project-specific row, since unit/ancestor candidates with
// 'none' lose MAX to any non-none), this returns nil — the gate is
// suppressed and no approval request is created.
//
// The returned ApprovalPolicy is synthetic when source != 'project': it
// carries the resolved required_role + the actual project_id (so downstream
// code that branches on ProjectID still works), but no DB id since the
// effective rule may have been computed across multiple rows.
func ( s * ApprovalService ) LookupPolicy ( ctx context . Context , tx * sqlx . Tx , projectID uuid . UUID , entityType , lifecycleEvent string ) ( * models . ApprovalPolicy , error ) {
var p models . ApprovalPolicy
q := ` SELECT id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3 `
row := txOrDB ( tx , s . db ) . QueryRowxContext ( ctx , q , projectID , entityType , lifecycleEvent )
if err := row . StructScan ( & p ) ; err != nil {
var row struct {
RequiredRole string ` db:" required_role" `
Source sql . NullString ` db:"source" `
SourceID * uuid . UUID ` db:"source_id" `
}
q := ` SELECT required_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3) `
if err := txOrDB ( tx , s . db ) . GetContext ( ctx , & row , q , projectID , entityType , lifecycleEvent ) ; err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
return nil , nil
return nil , nil // no candidates → no policy applies
}
return nil , fmt . Errorf ( "lookup approval policy: %w" , err )
}
return & p , nil
if row . RequiredRole == "none" {
return nil , nil // explicit suppression at project-row level
}
pid := projectID
return & models . ApprovalPolicy {
ProjectID : & pid ,
EntityType : entityType ,
LifecycleEvent : lifecycleEvent ,
RequiredRole : row . RequiredRole ,
} , nil
}
// hasQualifiedApprover counts users on the project's team-membership path
@@ -890,61 +915,584 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
}
// ============================================================================
// Policy CRUD — paliad.approval_policies.
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154) .
//
// Two scopes coexist:
//
// - Project rows (project_id IS NOT NULL, partner_unit_id IS NULL):
// the most-specific override for that one project.
// - Unit defaults (project_id IS NULL, partner_unit_id IS NOT NULL):
// firm-wide defaults applied to every project attached
// to that partner unit (via paliad.project_partner_units).
//
// XOR enforced by approval_policies_scope_xor in migration 062.
//
// Audit emission: every set / cleared writes one row to paliad.policy_audit_log
// (Q8 of the locked design — surfaces on /admin/audit-log only, never on
// per-project /verlauf). The actor is always a global_admin.
// ============================================================================
// ListPolicies returns the (up to 8) policy rows for a project. Calle r
// must already have project visibility.
func ( s * ApprovalService ) ListPolicies ( ctx context . Context , projectID uuid . UUID ) ( [ ] models . ApprovalPolicy , error ) {
q := ` SELECT id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by
// IsValidPolicyRole returns true iff the value is a valid required_role fo r
// an approval_policies row. Accepts the strict-ladder roles AND the 'none'
// sentinel that suppresses inherited defaults at project-row level. Distinct
// from IsValidRequiredRole, which is used by the gate (and rejects 'none' as
// a level-0 ineligible value).
func IsValidPolicyRole ( role string ) bool {
switch role {
case ProfessionPartner , ProfessionOfCounsel , ProfessionAssociate ,
ProfessionSeniorPA , ProfessionPA , "none" :
return true
}
return false
}
// ListProjectPolicies returns the project-specific policy rows for a single
// project (up to 8: deadline× 4 + appointment× 4). Does NOT include inherited
// rows or unit defaults — those come via GetEffectivePoliciesMatrix.
func ( s * ApprovalService ) ListProjectPolicies ( ctx context . Context , projectID uuid . UUID ) ( [ ] models . ApprovalPolicy , error ) {
q := ` SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1
ORDER BY entity_type, lifecycle_event `
var out [ ] models . ApprovalPolicy
if err := s . db . SelectContext ( ctx , & out , q , projectID ) ; err != nil {
return nil , fmt . Errorf ( "list approval policies: %w" , err )
return nil , fmt . Errorf ( "list project approval policies: %w" , err )
}
return out , nil
}
// UpsertPolicy creates or replaces a single (project, entity, lifecycle)
// policy row. Caller must be global_admin (gate enforced at handler ).
func ( s * ApprovalService ) Upser tPolicy ( ctx context . Context , projectID , callerID uuid . UUID , entityType , lifecycle , requiredRole string ) ( * models . ApprovalPolicy , error ) {
if ! IsValidRequiredRole ( requiredRole ) {
return nil , fmt . Errorf ( "%w: required_role %q" , ErrInvalidInput , requiredRole )
// ListUnitPolicies returns the unit-default policy rows for a single
// partner unit (up to 8 ).
func ( s * ApprovalService ) ListUni tPolicies ( ctx context . Context , unitID uuid . UUID ) ( [ ] models . ApprovalPolicy , error ) {
q := ` SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE partner_unit_id = $1
ORDER BY entity_type, lifecycle_event `
var out [ ] models . ApprovalPolicy
if err := s . db . SelectContext ( ctx , & out , q , unitID ) ; err != nil {
return nil , fmt . Errorf ( "list unit approval policies: %w" , err )
}
return out , nil
}
// validatePolicyTuple returns ErrInvalidInput if any of the three policy
// strings are out of range for the underlying CHECK constraints.
func validatePolicyTuple ( entityType , lifecycle , requiredRole string ) error {
if ! IsValidPolicyRole ( requiredRole ) {
return fmt . Errorf ( "%w: required_role %q" , ErrInvalidInput , requiredRole )
}
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
return nil , fmt . Errorf ( "%w: entity_type %q" , ErrInvalidInput , entityType )
return fmt . Errorf ( "%w: entity_type %q" , ErrInvalidInput , entityType )
}
switch lifecycle {
case LifecycleCreate , LifecycleUpdate , LifecycleComplete , LifecycleDelete :
default :
return nil , fmt . Errorf ( "%w: lifecycle_event %q" , ErrInvalidInput , lifecycle )
return fmt . Errorf ( "%w: lifecycle_event %q" , ErrInvalidInput , lifecycle )
}
return nil
}
// UpsertProjectPolicy creates or replaces a single project-scoped policy
// row. Caller must be global_admin (gate enforced at the handler layer).
// Audit row written via writePolicyAudit. 'none' as required_role is
// allowed and suppresses inherited defaults explicitly.
func ( s * ApprovalService ) UpsertProjectPolicy ( ctx context . Context , callerID , projectID uuid . UUID , entityType , lifecycle , requiredRole string ) ( * models . ApprovalPolicy , error ) {
if err := validatePolicyTuple ( entityType , lifecycle , requiredRole ) ; err != nil {
return nil , err
}
tx , err := s . db . BeginTxx ( ctx , nil )
if err != nil {
return nil , fmt . Errorf ( "upsert project policy: begin tx: %w" , err )
}
defer tx . Rollback ( ) //nolint:errcheck
// Snapshot pre-existing required_role for the audit row.
var oldRole sql . NullString
if err := tx . GetContext ( ctx , & oldRole ,
` SELECT required_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3 ` ,
projectID , entityType , lifecycle ) ; err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return nil , fmt . Errorf ( "upsert project policy: read pre-image: %w" , err )
}
q := ` INSERT INTO paliad.approval_policies
(project_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, $2, $3, $4, $5)
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5)
ON CONFLICT (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL
DO UPDATE SET required_role = EXCLUDED.required_role,
updated_at = now()
RETURNING id, project_id, entity_type, lifecycle_event, required_role,
created_at, updated_at, created_by `
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by `
var p models . ApprovalPolicy
if err := s . db . GetContext ( ctx , & p , q , projectID , entityType , lifecycle , requiredRole , callerID ) ; err != nil {
return nil , fmt . Errorf ( "upsert ap proval policy: %w" , err )
if err := tx . GetContext ( ctx , & p , q , projectID , entityType , lifecycle , requiredRole , callerID ) ; err != nil {
return nil , fmt . Errorf ( "upsert project policy: %w" , err )
}
// Snapshot project name for the audit row (so cascade-set-null doesn't
// lose the human label).
var scopeName string
if err := tx . GetContext ( ctx , & scopeName ,
` SELECT title FROM paliad.projects WHERE id = $1 ` , projectID ) ; err != nil {
// Tolerate name lookup failure — still audit with empty scope_name.
scopeName = ""
}
if err := s . writePolicyAudit ( ctx , tx , callerID , "approval_policy_set" ,
"project" , & projectID , nil , scopeName , entityType , lifecycle ,
nullToPtr ( oldRole ) , & requiredRole ) ; err != nil {
return nil , err
}
if err := tx . Commit ( ) ; err != nil {
return nil , fmt . Errorf ( "upsert project policy: commit: %w" , err )
}
return & p , nil
}
// DeletePolicy removes a single ( project, entity, lifecycle) policy row,
// reverting that lifecycle event back to the no-approval-needed default .
func ( s * ApprovalService ) DeletePolicy ( ctx context . Context , projectID uuid . UUID , entityType , lifecycle string ) error {
q := ` DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3 `
if _ , err := s . db . ExecContext ( ctx , q , projectID , entityType , lifecycle ) ; err != nil {
return fmt . Errorf ( "delete approval policy: %w" , err )
// DeleteProjectP olicy removes a single project-scoped policy row, reverting
// that cell to inherit from ancestors / unit defaults. Audit row written .
func ( s * ApprovalService ) DeleteProjectP olicy ( ctx context . Context , callerID , projectID uuid . UUID , entityType , lifecycle string ) error {
tx , err := s . db . BeginTxx ( ctx , nil )
if err != nil {
return fmt . Errorf ( "delete project policy: begin tx: %w" , err )
}
defer tx . Rollback ( ) //nolint:errcheck
var oldRole sql . NullString
if err := tx . GetContext ( ctx , & oldRole ,
` SELECT required_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3 ` ,
projectID , entityType , lifecycle ) ; err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
// Nothing to delete — exit cleanly without auditing a no-op.
return nil
}
return fmt . Errorf ( "delete project policy: read pre-image: %w" , err )
}
if _ , err := tx . ExecContext ( ctx ,
` DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3 ` ,
projectID , entityType , lifecycle ) ; err != nil {
return fmt . Errorf ( "delete project policy: %w" , err )
}
var scopeName string
if err := tx . GetContext ( ctx , & scopeName ,
` SELECT title FROM paliad.projects WHERE id = $1 ` , projectID ) ; err != nil {
scopeName = ""
}
if err := s . writePolicyAudit ( ctx , tx , callerID , "approval_policy_cleared" ,
"project" , & projectID , nil , scopeName , entityType , lifecycle ,
nullToPtr ( oldRole ) , nil ) ; err != nil {
return err
}
return tx . Commit ( )
}
// UpsertUnitPolicy creates or replaces a single unit-default policy row.
func ( s * ApprovalService ) UpsertUnitPolicy ( ctx context . Context , callerID , unitID uuid . UUID , entityType , lifecycle , requiredRole string ) ( * models . ApprovalPolicy , error ) {
if err := validatePolicyTuple ( entityType , lifecycle , requiredRole ) ; err != nil {
return nil , err
}
tx , err := s . db . BeginTxx ( ctx , nil )
if err != nil {
return nil , fmt . Errorf ( "upsert unit policy: begin tx: %w" , err )
}
defer tx . Rollback ( ) //nolint:errcheck
var oldRole sql . NullString
if err := tx . GetContext ( ctx , & oldRole ,
` SELECT required_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3 ` ,
unitID , entityType , lifecycle ) ; err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return nil , fmt . Errorf ( "upsert unit policy: read pre-image: %w" , err )
}
q := ` INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
VALUES (NULL, $1, $2, $3, $4, $5)
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO UPDATE SET required_role = EXCLUDED.required_role,
updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by `
var p models . ApprovalPolicy
if err := tx . GetContext ( ctx , & p , q , unitID , entityType , lifecycle , requiredRole , callerID ) ; err != nil {
return nil , fmt . Errorf ( "upsert unit policy: %w" , err )
}
var scopeName string
if err := tx . GetContext ( ctx , & scopeName ,
` SELECT name FROM paliad.partner_units WHERE id = $1 ` , unitID ) ; err != nil {
scopeName = ""
}
if err := s . writePolicyAudit ( ctx , tx , callerID , "approval_policy_set" ,
"unit" , nil , & unitID , scopeName , entityType , lifecycle ,
nullToPtr ( oldRole ) , & requiredRole ) ; err != nil {
return nil , err
}
if err := tx . Commit ( ) ; err != nil {
return nil , fmt . Errorf ( "upsert unit policy: commit: %w" , err )
}
return & p , nil
}
// DeleteUnitPolicy removes a single unit-default policy row.
func ( s * ApprovalService ) DeleteUnitPolicy ( ctx context . Context , callerID , unitID uuid . UUID , entityType , lifecycle string ) error {
tx , err := s . db . BeginTxx ( ctx , nil )
if err != nil {
return fmt . Errorf ( "delete unit policy: begin tx: %w" , err )
}
defer tx . Rollback ( ) //nolint:errcheck
var oldRole sql . NullString
if err := tx . GetContext ( ctx , & oldRole ,
` SELECT required_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3 ` ,
unitID , entityType , lifecycle ) ; err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
return nil
}
return fmt . Errorf ( "delete unit policy: read pre-image: %w" , err )
}
if _ , err := tx . ExecContext ( ctx ,
` DELETE FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3 ` ,
unitID , entityType , lifecycle ) ; err != nil {
return fmt . Errorf ( "delete unit policy: %w" , err )
}
var scopeName string
if err := tx . GetContext ( ctx , & scopeName ,
` SELECT name FROM paliad.partner_units WHERE id = $1 ` , unitID ) ; err != nil {
scopeName = ""
}
if err := s . writePolicyAudit ( ctx , tx , callerID , "approval_policy_cleared" ,
"unit" , nil , & unitID , scopeName , entityType , lifecycle ,
nullToPtr ( oldRole ) , nil ) ; err != nil {
return err
}
return tx . Commit ( )
}
// allLifecycleEvents enumerates the 8 (entity_type, lifecycle) cells in
// stable display order: Fristen first (create / update / complete / delete),
// then Termine.
var allLifecycleEvents = [ ] struct {
EntityType string
Lifecycle string
} {
{ EntityTypeDeadline , LifecycleCreate } ,
{ EntityTypeDeadline , LifecycleUpdate } ,
{ EntityTypeDeadline , LifecycleComplete } ,
{ EntityTypeDeadline , LifecycleDelete } ,
{ EntityTypeAppointment , LifecycleCreate } ,
{ EntityTypeAppointment , LifecycleUpdate } ,
{ EntityTypeAppointment , LifecycleComplete } ,
{ EntityTypeAppointment , LifecycleDelete } ,
}
// GetEffectivePoliciesMatrix returns one EffectivePolicy per (entity_type,
// lifecycle_event) cell — 8 rows in stable display order. Each row carries
// the resolved required_role + attribution (source ∈ {project, ancestor,
// unit_default}) + a human-readable source name (project title or partner
// unit name).
//
// RequiredRole is nil iff no policy applies to that cell. 'none' surfaces
// as required_role='none' with source='project' so the admin UI can render
// "Keine Genehmigung erforderlich (projektspezifisch)".
func ( s * ApprovalService ) GetEffectivePoliciesMatrix ( ctx context . Context , projectID uuid . UUID ) ( [ ] models . EffectivePolicy , error ) {
out := make ( [ ] models . EffectivePolicy , 0 , len ( allLifecycleEvents ) )
for _ , c := range allLifecycleEvents {
row , err := s . GetEffectivePolicyOne ( ctx , projectID , c . EntityType , c . Lifecycle )
if err != nil {
return nil , err
}
out = append ( out , * row )
}
return out , nil
}
// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
func ( s * ApprovalService ) GetEffectivePolicyOne ( ctx context . Context , projectID uuid . UUID , entityType , lifecycle string ) ( * models . EffectivePolicy , error ) {
var row struct {
RequiredRole sql . NullString ` db:"required_role" `
Source sql . NullString ` db:"source" `
SourceID * uuid . UUID ` db:"source_id" `
}
q := ` SELECT required_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3) `
if err := s . db . GetContext ( ctx , & row , q , projectID , entityType , lifecycle ) ; err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
return & models . EffectivePolicy {
EntityType : entityType ,
LifecycleEvent : lifecycle ,
} , nil
}
return nil , fmt . Errorf ( "effective policy: %w" , err )
}
res := & models . EffectivePolicy {
EntityType : entityType ,
LifecycleEvent : lifecycle ,
}
if row . RequiredRole . Valid {
rr := row . RequiredRole . String
res . RequiredRole = & rr
}
if row . Source . Valid {
src := row . Source . String
res . Source = & src
}
if row . SourceID != nil {
res . SourceID = row . SourceID
// Best-effort source-name lookup. Failure is non-fatal — chip just
// renders the unattributed source label.
if name , err := s . lookupSourceName ( ctx , * row . SourceID , row . Source . String ) ; err == nil {
res . SourceName = & name
}
}
return res , nil
}
// lookupSourceName resolves a source_id to a human label depending on the
// source kind. project / ancestor → projects.title; unit_default →
// partner_units.name. Returns ("", err) if the row vanished.
func ( s * ApprovalService ) lookupSourceName ( ctx context . Context , id uuid . UUID , source string ) ( string , error ) {
var q string
switch source {
case "project" , "ancestor" :
q = ` SELECT title FROM paliad.projects WHERE id = $1 `
case "unit_default" :
q = ` SELECT name FROM paliad.partner_units WHERE id = $1 `
default :
return "" , fmt . Errorf ( "unknown source %q" , source )
}
var name string
if err := s . db . GetContext ( ctx , & name , q , id ) ; err != nil {
return "" , err
}
return name , nil
}
// PoliciesExist returns true iff any approval_policies row exists firm-wide
// (project or unit, any cell). Used by the /inbox empty-state nudge to hide
// the "configure policies" card once any policy is set.
func ( s * ApprovalService ) PoliciesExist ( ctx context . Context ) ( bool , error ) {
var ok bool
if err := s . db . GetContext ( ctx , & ok ,
` SELECT EXISTS(SELECT 1 FROM paliad.approval_policies LIMIT 1) ` ) ; err != nil {
return false , fmt . Errorf ( "policies exist check: %w" , err )
}
return ok , nil
}
// ApplyMatrixToDescendants copies the source project's effective matrix
// down to every project in `targetIDs` as project-specific rows. Idempotent
// fanout — each target's existing project rows for the 8 cells are first
// DELETEd, then the source's effective values INSERTed (excluding cells
// where the source resolves to no policy and the target already has none).
//
// Validates every target is an actual descendant of source via the project
// path. Self-target (source ∈ targetIDs) is silently skipped. Caller must
// be global_admin (handler-layer gate). Audit row per affected target+cell.
//
// Returns the number of policy-cell writes performed (INSERTs + post-clear
// re-applies).
func ( s * ApprovalService ) ApplyMatrixToDescendants ( ctx context . Context , callerID , sourceProjectID uuid . UUID , targetIDs [ ] uuid . UUID ) ( int , error ) {
if len ( targetIDs ) == 0 {
return 0 , nil
}
// Resolve source's effective matrix (fold inherited values into the
// target's project-scoped rows for predictable behaviour).
matrix , err := s . GetEffectivePoliciesMatrix ( ctx , sourceProjectID )
if err != nil {
return 0 , fmt . Errorf ( "apply matrix: source resolve: %w" , err )
}
tx , err := s . db . BeginTxx ( ctx , nil )
if err != nil {
return 0 , fmt . Errorf ( "apply matrix: begin tx: %w" , err )
}
defer tx . Rollback ( ) //nolint:errcheck
// Validate each target_id is a descendant of source. Anything else =
// caller-bug → ErrInvalidInput.
if err := s . validateDescendants ( ctx , tx , sourceProjectID , targetIDs ) ; err != nil {
return 0 , err
}
writes := 0
for _ , target := range targetIDs {
if target == sourceProjectID {
continue // skip self
}
// Snapshot pre-existing project rows for audit.
oldRows , err := s . snapshotProjectRows ( ctx , tx , target )
if err != nil {
return 0 , err
}
// Wipe the target's 8 cells (project-scoped only — leaves unit-default
// inheritance intact).
if _ , err := tx . ExecContext ( ctx ,
` DELETE FROM paliad.approval_policies
WHERE project_id = $1 ` , target ) ; err != nil {
return 0 , fmt . Errorf ( "apply matrix: clear target %s: %w" , target , err )
}
// Apply source's effective values as project-scoped rows.
for _ , cell := range matrix {
if cell . RequiredRole == nil {
continue
}
if _ , err := tx . ExecContext ( ctx ,
` INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5) ` ,
target , cell . EntityType , cell . LifecycleEvent , * cell . RequiredRole , callerID ) ; err != nil {
return 0 , fmt . Errorf ( "apply matrix: write target %s cell %s/%s: %w" ,
target , cell . EntityType , cell . LifecycleEvent , err )
}
writes ++
}
// Audit one row per target (set-event with a synthesised payload —
// individual cells are too noisy for the audit timeline).
var scopeName string
if err := tx . GetContext ( ctx , & scopeName ,
` SELECT title FROM paliad.projects WHERE id = $1 ` , target ) ; err != nil {
scopeName = ""
}
// Use lifecycle='create' as a stand-in marker for the bulk apply
// audit row — the meaningful payload is "matrix copied from source".
// The audit row is informational; the per-cell set/clear are not
// re-emitted for bulk to avoid log spam.
_ = oldRows // pre-image not currently surfaced; reserved for future
if err := s . writePolicyAuditRaw ( ctx , tx , callerID , "approval_policy_set" ,
"project" , & target , nil , scopeName , "deadline" , "create" ,
nil , strPtr ( fmt . Sprintf ( "bulk-apply from source=%s" , sourceProjectID ) ) ) ; err != nil {
return 0 , err
}
}
if err := tx . Commit ( ) ; err != nil {
return 0 , fmt . Errorf ( "apply matrix: commit: %w" , err )
}
return writes , nil
}
// validateDescendants checks that every target_id is on the source's
// descendant subtree (path LIKE source.path || '.%'). Returns ErrInvalidInput
// listing offending IDs if any are not descendants.
func ( s * ApprovalService ) validateDescendants ( ctx context . Context , tx * sqlx . Tx , sourceID uuid . UUID , targetIDs [ ] uuid . UUID ) error {
if len ( targetIDs ) == 0 {
return nil
}
q := ` WITH src AS (
SELECT path FROM paliad.projects WHERE id = $1
)
SELECT p.id::text
FROM paliad.projects p, src
WHERE p.id = ANY($2)
AND p.path NOT LIKE src.path || '.%' `
rows , err := tx . QueryxContext ( ctx , q , sourceID , pqUUIDArray ( targetIDs ) )
if err != nil {
return fmt . Errorf ( "apply matrix: validate descendants: %w" , err )
}
defer rows . Close ( )
var bad [ ] string
for rows . Next ( ) {
var id string
if err := rows . Scan ( & id ) ; err != nil {
return err
}
bad = append ( bad , id )
}
if len ( bad ) > 0 {
return fmt . Errorf ( "%w: not descendants of %s: %s" ,
ErrInvalidInput , sourceID , strings . Join ( bad , ", " ) )
}
return nil
}
// snapshotProjectRows reads the current project-scoped policy rows for a
// project. Used as audit pre-image during ApplyMatrixToDescendants.
func ( s * ApprovalService ) snapshotProjectRows ( ctx context . Context , tx * sqlx . Tx , projectID uuid . UUID ) ( [ ] models . ApprovalPolicy , error ) {
var rows [ ] models . ApprovalPolicy
if err := tx . SelectContext ( ctx , & rows ,
` SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
required_role, created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1 ` , projectID ) ; err != nil {
return nil , fmt . Errorf ( "snapshot project rows: %w" , err )
}
return rows , nil
}
// writePolicyAudit writes one paliad.policy_audit_log row inside the calling
// tx. tx may be nil in which case we run on s.db directly.
func ( s * ApprovalService ) writePolicyAudit (
ctx context . Context , tx * sqlx . Tx , actorID uuid . UUID ,
eventType , scopeType string , projectID , partnerUnitID * uuid . UUID ,
scopeName , entityType , lifecycle string ,
oldRole , newRole * string ,
) error {
return s . writePolicyAuditRaw ( ctx , tx , actorID , eventType , scopeType ,
projectID , partnerUnitID , scopeName , entityType , lifecycle , oldRole , newRole )
}
// writePolicyAuditRaw expects a non-nil tx (the audit row must commit
// atomically with the data mutation).
func ( s * ApprovalService ) writePolicyAuditRaw (
ctx context . Context , tx * sqlx . Tx , actorID uuid . UUID ,
eventType , scopeType string , projectID , partnerUnitID * uuid . UUID ,
scopeName , entityType , lifecycle string ,
oldRole , newRole * string ,
) error {
q := ` INSERT INTO paliad.policy_audit_log
(actor_id, event_type, scope_type, project_id, partner_unit_id,
scope_name, entity_type, lifecycle_event,
old_required_role, new_required_role)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `
if _ , err := tx . ExecContext ( ctx , q ,
actorID , eventType , scopeType , projectID , partnerUnitID ,
scopeName , entityType , lifecycle , oldRole , newRole ) ; err != nil {
return fmt . Errorf ( "write policy audit: %w" , err )
}
return nil
}
// nullToPtr converts a sql.NullString to a *string pointer.
func nullToPtr ( s sql . NullString ) * string {
if ! s . Valid {
return nil
}
v := s . String
return & v
}
// strPtr is a small helper for inline string literals.
func strPtr ( s string ) * string { return & s }
// pqUUIDArray converts a []uuid.UUID to the pq array format used by the
// sqlx driver. Reuses the github.com/lib/pq Array helper.
func pqUUIDArray ( ids [ ] uuid . UUID ) any {
strs := make ( [ ] string , len ( ids ) )
for i , id := range ids {
strs [ i ] = id . String ( )
}
return pq . Array ( strs )
}