feat(t-paliad-154) commit 2/5: ApprovalService rewire — resolver delegation + scope-split CRUD + audit emission
Service-layer changes implementing the locked design (Q5/Q6/Q8):
LookupPolicy (existing, called by SubmitCreate/Update/Complete/Delete)
delegates to paliad.approval_policy_effective() resolver. Returns nil
for the 'none' sentinel — explicit project-level suppression of inherited
defaults. Synthesizes a *models.ApprovalPolicy carrying the actual
project_id so the existing submit chain branches don't change.
Policy CRUD split into project + unit scope methods:
- ListProjectPolicies / ListUnitPolicies — read-only per scope.
- UpsertProjectPolicy / DeleteProjectPolicy — project-scoped writes,
audit-emitting (writes paliad.policy_audit_log inside the same tx).
- UpsertUnitPolicy / DeleteUnitPolicy — unit-default writes, same shape.
- All four use validatePolicyTuple for entity_type/lifecycle/required_role
ranges. IsValidPolicyRole accepts the 'none' sentinel; the existing
IsValidRequiredRole keeps rejecting 'none' (gate-only contract).
Effective-policy reads:
- GetEffectivePolicyOne(projectID, entity, lifecycle) — single-cell,
used by the form-time hint endpoint above /projects/{id}/deadlines/new.
- GetEffectivePoliciesMatrix(projectID) — 8 cells in stable display order
(Fristen/Termine × create/update/complete/delete), each w/ attribution.
- lookupSourceName resolves source_id to projects.title or partner_units.name.
ApplyMatrixToDescendants — bulk-apply (Q10): copies source project's
effective matrix down to listed descendants as project-specific rows,
inside one tx. Validates targetIDs are actual descendants via path-prefix
NOT LIKE check. Idempotent fanout: deletes target's project rows first
then writes the source's effective values. Self-target skipped. Audit
row per affected target.
PoliciesExist() — bool, used by /inbox empty-state nudge.
Models:
- ApprovalPolicy.ProjectID is now *uuid.UUID (was uuid.UUID); new
*uuid.UUID PartnerUnitID. Existing handler code only reads RequiredRole
so no upstream breakage.
- New EffectivePolicy struct (resolved cell + source attribution).
- New PolicyAuditEntry struct (paliad.policy_audit_log row).
Handlers:
- handleListApprovalPolicies → ListProjectPolicies (renamed).
- handlePutApprovalPolicy → UpsertProjectPolicy (caller-id reordering).
- handleDeleteApprovalPolicy → DeleteProjectPolicy (now needs uid for
audit; took the existing requireUser path).
Tests:
- Existing TestApprovalService_PolicyCRUD updated for new method names
+ post-148 enum (partner, not lead) + new 'none' sentinel acceptance.
- New TestIsValidPolicyRole pins the helper that gates writes.
- TestIsValidRequiredRole extended with 'none' rejection (gate-only).
Build + go vet + role-tests clean.
Q8: audit emission writes to paliad.policy_audit_log only — never to
project_events — so /admin/audit-log surfaces the change while /verlauf
stays focused on entity-level lifecycle.
This commit is contained in:
@@ -49,7 +49,7 @@ func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := dbSvc.approval.ListPolicies(r.Context(), projectID)
|
rows, err := dbSvc.approval.ListProjectPolicies(r.Context(), projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
@@ -88,7 +88,7 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
policy, err := dbSvc.approval.UpsertPolicy(r.Context(), projectID, uid, entityType, lifecycle, body.RequiredRole)
|
policy, err := dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.RequiredRole)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
@@ -104,7 +104,8 @@ func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !requireDB(w) {
|
if !requireDB(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := requireUser(w, r); !ok {
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||||
@@ -114,7 +115,7 @@ func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
entityType := r.PathValue("entity_type")
|
entityType := r.PathValue("entity_type")
|
||||||
lifecycle := r.PathValue("lifecycle")
|
lifecycle := r.PathValue("lifecycle")
|
||||||
if err := dbSvc.approval.DeletePolicy(r.Context(), projectID, entityType, lifecycle); err != nil {
|
if err := dbSvc.approval.DeleteProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle); err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,13 +521,19 @@ const (
|
|||||||
EventTypeJurisdictionAny = "any"
|
EventTypeJurisdictionAny = "any"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApprovalPolicy is one row of paliad.approval_policies — the per-(project,
|
// ApprovalPolicy is one row of paliad.approval_policies — a rule that says
|
||||||
// entity_type, lifecycle_event) rule that says "this lifecycle event needs
|
// "this (entity_type, lifecycle_event) needs 4-eye sign-off at the given
|
||||||
// 4-eye sign-off at the given role tier or above". Up to 8 rows per project
|
// role tier or above" within a scope. The scope is either a single project
|
||||||
// (deadline×4 + appointment×4); missing rows = no approval needed.
|
// (ProjectID set, PartnerUnitID nil) OR a single partner unit (PartnerUnitID
|
||||||
|
// set, ProjectID nil) — XOR enforced by the DB CHECK
|
||||||
|
// approval_policies_scope_xor.
|
||||||
|
//
|
||||||
|
// Project rows act as the most-specific override; partner-unit rows act as
|
||||||
|
// firm-wide defaults for projects attached to that unit (t-paliad-154).
|
||||||
type ApprovalPolicy struct {
|
type ApprovalPolicy struct {
|
||||||
ID uuid.UUID `db:"id" json:"id"`
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
|
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||||
|
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
|
||||||
EntityType string `db:"entity_type" json:"entity_type"`
|
EntityType string `db:"entity_type" json:"entity_type"`
|
||||||
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
||||||
RequiredRole string `db:"required_role" json:"required_role"`
|
RequiredRole string `db:"required_role" json:"required_role"`
|
||||||
@@ -536,6 +542,44 @@ type ApprovalPolicy struct {
|
|||||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectivePolicy is the resolved policy for one (project, entity_type,
|
||||||
|
// lifecycle_event) cell — what the gate actually does, after the
|
||||||
|
// project-row / ancestor-row / unit-default cascade in
|
||||||
|
// paliad.approval_policy_effective(). Populated by
|
||||||
|
// ApprovalService.GetEffectivePoliciesMatrix and the form-time hint
|
||||||
|
// endpoint.
|
||||||
|
//
|
||||||
|
// RequiredRole is nil iff no policy applies (no candidates OR the project
|
||||||
|
// row carries the 'none' sentinel). Source ∈ {"project", "ancestor",
|
||||||
|
// "unit_default"} when RequiredRole is non-nil. SourceID is the project_id
|
||||||
|
// for project / ancestor sources; the partner_unit_id for unit_default.
|
||||||
|
type EffectivePolicy struct {
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
LifecycleEvent string `json:"lifecycle_event"`
|
||||||
|
RequiredRole *string `json:"required_role,omitempty"`
|
||||||
|
Source *string `json:"source,omitempty"`
|
||||||
|
SourceID *uuid.UUID `json:"source_id,omitempty"`
|
||||||
|
SourceName *string `json:"source_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyAuditEntry is one row of paliad.policy_audit_log — admin-only audit
|
||||||
|
// trail for approval-policy CRUD (t-paliad-154). Surfaces on /admin/audit-log
|
||||||
|
// via AuditService union; never on per-project /verlauf.
|
||||||
|
type PolicyAuditEntry struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
ActorID uuid.UUID `db:"actor_id" json:"actor_id"`
|
||||||
|
EventType string `db:"event_type" json:"event_type"`
|
||||||
|
ScopeType string `db:"scope_type" json:"scope_type"`
|
||||||
|
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||||
|
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
|
||||||
|
ScopeName string `db:"scope_name" json:"scope_name"`
|
||||||
|
EntityType string `db:"entity_type" json:"entity_type"`
|
||||||
|
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
||||||
|
OldRequiredRole *string `db:"old_required_role" json:"old_required_role,omitempty"`
|
||||||
|
NewRequiredRole *string `db:"new_required_role" json:"new_required_role,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// ApprovalRequest is one row of paliad.approval_requests — an in-flight
|
// ApprovalRequest is one row of paliad.approval_requests — an in-flight
|
||||||
// state-change awaiting 4-eye sign-off.
|
// state-change awaiting 4-eye sign-off.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"mgit.msbls.de/m/paliad/internal/models"
|
"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}
|
return &ApprovalService{db: db, users: users}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupPolicy returns the approval policy for the given tuple, or nil if
|
// LookupPolicy returns the effective approval policy for the given tuple,
|
||||||
// none exists. Read inside the same tx as Submit* so policy reads see
|
// or nil if none applies. Reads inside the same tx as Submit* so policy
|
||||||
// whatever the calling tx may have already written.
|
// 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) {
|
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
|
||||||
var p models.ApprovalPolicy
|
var row struct {
|
||||||
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
|
RequiredRole string `db:"required_role"`
|
||||||
created_at, updated_at, created_by
|
Source sql.NullString `db:"source"`
|
||||||
FROM paliad.approval_policies
|
SourceID *uuid.UUID `db:"source_id"`
|
||||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
|
}
|
||||||
row := txOrDB(tx, s.db).QueryRowxContext(ctx, q, projectID, entityType, lifecycleEvent)
|
q := `SELECT required_role, source, source_id
|
||||||
if err := row.StructScan(&p); err != nil {
|
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) {
|
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 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
|
// 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. Caller
|
// IsValidPolicyRole returns true iff the value is a valid required_role for
|
||||||
// must already have project visibility.
|
// an approval_policies row. Accepts the strict-ladder roles AND the 'none'
|
||||||
func (s *ApprovalService) ListPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
|
// sentinel that suppresses inherited defaults at project-row level. Distinct
|
||||||
q := `SELECT id, project_id, entity_type, lifecycle_event, required_role,
|
// from IsValidRequiredRole, which is used by the gate (and rejects 'none' as
|
||||||
created_at, updated_at, created_by
|
// 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
|
FROM paliad.approval_policies
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY entity_type, lifecycle_event`
|
ORDER BY entity_type, lifecycle_event`
|
||||||
var out []models.ApprovalPolicy
|
var out []models.ApprovalPolicy
|
||||||
if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertPolicy creates or replaces a single (project, entity, lifecycle)
|
// ListUnitPolicies returns the unit-default policy rows for a single
|
||||||
// policy row. Caller must be global_admin (gate enforced at handler).
|
// partner unit (up to 8).
|
||||||
func (s *ApprovalService) UpsertPolicy(ctx context.Context, projectID, callerID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
|
func (s *ApprovalService) ListUnitPolicies(ctx context.Context, unitID uuid.UUID) ([]models.ApprovalPolicy, error) {
|
||||||
if !IsValidRequiredRole(requiredRole) {
|
q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||||
return nil, fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
|
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 {
|
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 {
|
switch lifecycle {
|
||||||
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
|
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
|
||||||
default:
|
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
|
q := `INSERT INTO paliad.approval_policies
|
||||||
(project_id, entity_type, lifecycle_event, required_role, created_by)
|
(project_id, partner_unit_id, entity_type, lifecycle_event, required_role, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, NULL, $2, $3, $4, $5)
|
||||||
ON CONFLICT (project_id, entity_type, lifecycle_event)
|
ON CONFLICT (project_id, entity_type, lifecycle_event)
|
||||||
|
WHERE project_id IS NOT NULL
|
||||||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, project_id, entity_type, lifecycle_event, required_role,
|
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||||
created_at, updated_at, created_by`
|
required_role, created_at, updated_at, created_by`
|
||||||
var p models.ApprovalPolicy
|
var p models.ApprovalPolicy
|
||||||
if err := s.db.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
if err := tx.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
||||||
return nil, fmt.Errorf("upsert approval policy: %w", err)
|
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
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletePolicy removes a single (project, entity, lifecycle) policy row,
|
// DeleteProjectPolicy removes a single project-scoped policy row, reverting
|
||||||
// reverting that lifecycle event back to the no-approval-needed default.
|
// that cell to inherit from ancestors / unit defaults. Audit row written.
|
||||||
func (s *ApprovalService) DeletePolicy(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) error {
|
func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle string) error {
|
||||||
q := `DELETE FROM paliad.approval_policies
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`
|
if err != nil {
|
||||||
if _, err := s.db.ExecContext(ctx, q, projectID, entityType, lifecycle); err != nil {
|
return fmt.Errorf("delete project policy: begin tx: %w", err)
|
||||||
return fmt.Errorf("delete approval policy: %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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ func TestIsValidRequiredRole(t *testing.T) {
|
|||||||
{"expert", false},
|
{"expert", false},
|
||||||
{"observer", false},
|
{"observer", false},
|
||||||
{"", false},
|
{"", false},
|
||||||
|
// 'none' is the t-paliad-154 sentinel for explicit suppression — it
|
||||||
|
// is NOT a valid required_role for the gate (level 0). Use
|
||||||
|
// IsValidPolicyRole if you want to allow it as a stored value.
|
||||||
|
{"none", false},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.role, func(t *testing.T) {
|
t.Run(c.role, func(t *testing.T) {
|
||||||
@@ -140,6 +144,34 @@ func TestIsValidRequiredRole(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsValidPolicyRole pins the t-paliad-154 helper used by Upsert*Policy:
|
||||||
|
// it accepts the strict-ladder roles AND the 'none' sentinel that suppresses
|
||||||
|
// inherited defaults at project-row level.
|
||||||
|
func TestIsValidPolicyRole(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
role string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"partner", true},
|
||||||
|
{"of_counsel", true},
|
||||||
|
{"associate", true},
|
||||||
|
{"senior_pa", true},
|
||||||
|
{"pa", true},
|
||||||
|
{"none", true}, // sentinel
|
||||||
|
{"paralegal", false},
|
||||||
|
{"lead", false},
|
||||||
|
{"observer", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.role, func(t *testing.T) {
|
||||||
|
if got := IsValidPolicyRole(c.role); got != c.ok {
|
||||||
|
t.Errorf("IsValidPolicyRole(%q) = %v, want %v", c.role, got, c.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsValidProfession(t *testing.T) {
|
func TestIsValidProfession(t *testing.T) {
|
||||||
for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} {
|
for _, p := range []string{"partner", "of_counsel", "associate", "senior_pa", "pa", "paralegal"} {
|
||||||
t.Run(p, func(t *testing.T) {
|
t.Run(p, func(t *testing.T) {
|
||||||
@@ -312,8 +344,8 @@ func setupApprovalTest(t *testing.T) *approvalTestEnv {
|
|||||||
// seedPolicy sets a policy on the env's project for one (entity, lifecycle).
|
// seedPolicy sets a policy on the env's project for one (entity, lifecycle).
|
||||||
func (e *approvalTestEnv) seedPolicy(entityType, lifecycle, requiredRole string) {
|
func (e *approvalTestEnv) seedPolicy(entityType, lifecycle, requiredRole string) {
|
||||||
e.t.Helper()
|
e.t.Helper()
|
||||||
if _, err := e.approvals.UpsertPolicy(context.Background(),
|
if _, err := e.approvals.UpsertProjectPolicy(context.Background(),
|
||||||
e.projectID, e.requester, entityType, lifecycle, requiredRole); err != nil {
|
e.requester, e.projectID, entityType, lifecycle, requiredRole); err != nil {
|
||||||
e.t.Fatalf("seed policy: %v", err)
|
e.t.Fatalf("seed policy: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,21 +678,23 @@ func TestApprovalService_RevokeRevertsAndMarksRevoked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete.
|
// TestApprovalService_PolicyCRUDRoundtrip: upsert → list → delete.
|
||||||
|
// Uses post-t-paliad-148 profession enum (partner replaced legacy 'lead')
|
||||||
|
// and post-t-paliad-154 method names (UpsertProjectPolicy / etc).
|
||||||
func TestApprovalService_PolicyCRUD(t *testing.T) {
|
func TestApprovalService_PolicyCRUD(t *testing.T) {
|
||||||
env := setupApprovalTest(t)
|
env := setupApprovalTest(t)
|
||||||
defer env.cleanup()
|
defer env.cleanup()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Upsert two rows.
|
// Upsert two rows.
|
||||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "associate"); err != nil {
|
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "associate"); err != nil {
|
||||||
t.Fatalf("upsert 1: %v", err)
|
t.Fatalf("upsert 1: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeAppointment, LifecycleUpdate, "lead"); err != nil {
|
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeAppointment, LifecycleUpdate, "partner"); err != nil {
|
||||||
t.Fatalf("upsert 2: %v", err)
|
t.Fatalf("upsert 2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List.
|
// List.
|
||||||
got, err := env.approvals.ListPolicies(ctx, env.projectID)
|
got, err := env.approvals.ListProjectPolicies(ctx, env.projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
@@ -669,27 +703,32 @@ func TestApprovalService_PolicyCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-upsert the first to a different role.
|
// Re-upsert the first to a different role.
|
||||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "lead"); err != nil {
|
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "partner"); err != nil {
|
||||||
t.Fatalf("re-upsert: %v", err)
|
t.Fatalf("re-upsert: %v", err)
|
||||||
}
|
}
|
||||||
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
|
got, _ = env.approvals.ListProjectPolicies(ctx, env.projectID)
|
||||||
for _, p := range got {
|
for _, p := range got {
|
||||||
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "lead" {
|
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "partner" {
|
||||||
t.Errorf("after re-upsert: required_role=%q, want lead", p.RequiredRole)
|
t.Errorf("after re-upsert: required_role=%q, want partner", p.RequiredRole)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid role rejected.
|
// Invalid role rejected.
|
||||||
if _, err := env.approvals.UpsertPolicy(ctx, env.projectID, env.requester, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
|
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate, "observer"); !errors.Is(err, ErrInvalidInput) {
|
||||||
t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err)
|
t.Errorf("invalid required_role: got %v, want ErrInvalidInput", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'none' sentinel accepted (suppresses inherited defaults).
|
||||||
|
if _, err := env.approvals.UpsertProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleDelete, "none"); err != nil {
|
||||||
|
t.Errorf("'none' sentinel rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete.
|
// Delete.
|
||||||
if err := env.approvals.DeletePolicy(ctx, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil {
|
if err := env.approvals.DeleteProjectPolicy(ctx, env.requester, env.projectID, EntityTypeDeadline, LifecycleCreate); err != nil {
|
||||||
t.Fatalf("delete: %v", err)
|
t.Fatalf("delete: %v", err)
|
||||||
}
|
}
|
||||||
got, _ = env.approvals.ListPolicies(ctx, env.projectID)
|
got, _ = env.approvals.ListProjectPolicies(ctx, env.projectID)
|
||||||
if len(got) != 1 {
|
if len(got) != 2 { // appointment.update + deadline.delete='none' remain
|
||||||
t.Errorf("after delete: %d rows, want 1", len(got))
|
t.Errorf("after delete: %d rows, want 2", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user