refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column

Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.

`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.

Go side:
  - models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
    The MinRole pointer is now the only seniority-threshold surface.
  - LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
    drop the required_role SELECT projection.
  - UpsertProjectPolicySplit / UpsertUnitPolicySplit /
    DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
    drop the required_role write. The audit-log row still uses the
    legacy string format ('partner|...|none'); composed via
    legacyFromSplit() from the new columns so the audit table layout
    keeps working without a parallel migration.
  - submit() reads policy.MinRole directly (LookupPolicy guarantees
    non-nil when a non-nil policy is returned).
  - nullToPtr helper retired (no remaining callers).

Frontend side:
  - admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
    legacy required_role optional. The 2-control UI was already on the
    split-grammar path.
  - deadlines-new.ts + appointments-new.ts form-time hint readers prefer
    requires_approval+min_role. They keep a soft-fall back to the
    legacy required_role for one cycle in case any cached pre-M2 server
    is still serving the old shape — that path is dead-code post-deploy
    and can be dropped later.

Test:
  - TestApprovalService_PolicyCRUD asserts MinRole instead of
    RequiredRole after re-upsert.

Build: bun build OK, go build ./... OK, go test ./... OK.

Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
This commit is contained in:
m
2026-05-08 17:15:05 +02:00
parent 073af975f7
commit aec6cf6104
8 changed files with 378 additions and 108 deletions

View File

@@ -20,12 +20,9 @@ interface UnitPolicy {
project_id: string | null;
entity_type: string;
lifecycle_event: string;
// t-paliad-160 split-grammar — present on every fresh response. The
// legacy required_role string is still served for one release as a
// dual-read mirror (M1 → M2 in migration 064).
// t-paliad-160 split-grammar.
requires_approval: boolean;
min_role?: string | null;
required_role?: string | null;
}
interface EffectivePolicy {
@@ -33,8 +30,6 @@ interface EffectivePolicy {
lifecycle_event: string;
requires_approval: boolean;
min_role?: string | null;
// Legacy mirror, kept for the M1 dual-read window. Drop in M2.
required_role?: string | null;
source?: string | null;
source_id?: string | null;
source_name?: string | null;

View File

@@ -126,16 +126,23 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar — read requires_approval + min_role.
// Fall back to the legacy required_role mirror (M1 dual-read window
// only — drops in M2).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
if (!eff.required_role || eff.required_role === "none") {
const role = eff.min_role || eff.required_role || null;
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
if (!required || !role) {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";

View File

@@ -193,16 +193,21 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar (with M1 legacy fallback).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
if (!eff.required_role || eff.required_role === "none") {
const role = eff.min_role || eff.required_role || null;
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
if (!required || !role) {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";