Merge: t-paliad-160 — Approval system rework (shannon, 3 slices + renumber:
- Slice 1+2 (3a41aa9): schema split required_role → requires_approval boolean + min_role text via mig 066 with two-step dual-read; resolver most-strict-wins via approval_role_level(); ErrAlreadyPendingApproval / ErrNoQualifiedApprover / ErrInvalidInput → 409 Conflict with structured body via mapApprovalError helper. - Slice 2 (073af97): /admin/approval-policies UI flip — 2-control checkbox + role dropdown replacing the 'none' sentinel option; pending-approval badge on deadline + appointment detail pages; 'Genehmigungsanfrage zurückziehen' button wired to existing Revoke endpoint; /approvals 'Meine Anfragen' visibility hardening (filter regression + inbox count badge sync). - Slice 3 / M2 (aec6cf6): drop required_role column once writers cut over via mig 067. -3368aa5: renumber 064/065 → 066/067 to avoid collision with feynman's t-157 migrations 064 (users.forum_pref) + 065 (event_categories.forums) that landed first. -9350cd0: merge origin/main into branch to absorb feynman's slices. Closes m's open dogfood gap from t-138 (cronus) + t-154 (hilbert): the 500 on already-pending now becomes a friendly 409, the entity detail page surfaces the pending state, the user can withdraw their own request explicitly, and /approvals lists their pending-mine entries. m's locked redesign (2026-05-08 16:40) of split + most-strict-wins shipped end-to-end. NOT cronus.)
This commit is contained in:
@@ -35,6 +35,9 @@ export function renderAppointmentsDetail(): string {
|
||||
<div id="appointment-body" style="display:none">
|
||||
<div className="tool-header">
|
||||
<span className="termin-type-badge" id="appointment-type-badge" />
|
||||
<span id="appointment-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
|
||||
Wartet auf Genehmigung
|
||||
</span>
|
||||
<h1 id="appointment-title-display" />
|
||||
<p className="tool-subtitle" id="appointment-time-display" />
|
||||
</div>
|
||||
@@ -94,6 +97,7 @@ export function renderAppointmentsDetail(): string {
|
||||
<p className="form-msg" id="appointment-edit-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" id="appointment-withdraw-btn" className="btn-secondary" style="display:none" data-i18n="approvals.withdraw.cta">Genehmigungsanfrage zurückziehen</button>
|
||||
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin löschen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">Änderungen speichern</button>
|
||||
</div>
|
||||
|
||||
@@ -20,13 +20,16 @@ interface UnitPolicy {
|
||||
project_id: string | null;
|
||||
entity_type: string;
|
||||
lifecycle_event: string;
|
||||
required_role: string;
|
||||
// t-paliad-160 split-grammar.
|
||||
requires_approval: boolean;
|
||||
min_role?: string | null;
|
||||
}
|
||||
|
||||
interface EffectivePolicy {
|
||||
entity_type: string;
|
||||
lifecycle_event: string;
|
||||
required_role?: string | null;
|
||||
requires_approval: boolean;
|
||||
min_role?: string | null;
|
||||
source?: string | null;
|
||||
source_id?: string | null;
|
||||
source_name?: string | null;
|
||||
@@ -43,13 +46,15 @@ interface ProjectNode {
|
||||
|
||||
const ENTITY_TYPES = ["deadline", "appointment"] as const;
|
||||
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
|
||||
// Strict-ladder roles only. The legacy "none" sentinel is gone — its job
|
||||
// (suppress the gate) is now done by the requires_approval=false checkbox
|
||||
// (t-paliad-160 §A).
|
||||
const ROLE_OPTIONS = [
|
||||
"partner",
|
||||
"of_counsel",
|
||||
"associate",
|
||||
"senior_pa",
|
||||
"pa",
|
||||
"none",
|
||||
];
|
||||
|
||||
let partnerUnits: PartnerUnit[] = [];
|
||||
@@ -150,27 +155,68 @@ function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): U
|
||||
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
|
||||
}
|
||||
|
||||
function renderRoleSelect(currentValue: string | null, dataAttrs: string): string {
|
||||
const opts: string[] = [];
|
||||
// "Keine Regel" sentinel — distinct from 'none' (which is the explicit
|
||||
// suppression value). Empty string maps to DELETE.
|
||||
opts.push(`<option value=""${currentValue === null ? " selected" : ""}>${esc(t("admin.approval_policies.role.no_rule") || "— keine Regel —")}</option>`);
|
||||
for (const r of ROLE_OPTIONS) {
|
||||
opts.push(`<option value="${esc(r)}"${currentValue === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`);
|
||||
}
|
||||
return `<select class="ap-cell-select" ${dataAttrs}>${opts.join("")}</select>`;
|
||||
// Cell control state, t-paliad-160 §A.
|
||||
// none → no project-specific rule authored (the cell inherits).
|
||||
// off → requires_approval=false explicitly authored.
|
||||
// on(role) → requires_approval=true with the given min_role.
|
||||
//
|
||||
// rendered as: [✓] requires approval [role select]
|
||||
// - checkbox unchecked → role select disabled (greyed).
|
||||
// - checkbox checked → role select enabled, min_role required.
|
||||
// - "no rule" — surfaced as a third button next to the controls so the
|
||||
// admin can explicitly clear an authored cell back to inheritance.
|
||||
type CellAuthored =
|
||||
| { kind: "none" }
|
||||
| { kind: "off" }
|
||||
| { kind: "on"; role: string };
|
||||
|
||||
function authoredFromUnitPolicy(p: UnitPolicy | undefined): CellAuthored {
|
||||
if (!p) return { kind: "none" };
|
||||
if (!p.requires_approval) return { kind: "off" };
|
||||
return { kind: "on", role: p.min_role || "associate" };
|
||||
}
|
||||
|
||||
function authoredFromEffective(r: EffectivePolicy): CellAuthored {
|
||||
if (r.source !== "project") return { kind: "none" };
|
||||
if (!r.requires_approval) return { kind: "off" };
|
||||
return { kind: "on", role: r.min_role || "associate" };
|
||||
}
|
||||
|
||||
function renderCellControls(authored: CellAuthored, dataAttrs: string): string {
|
||||
const checked = authored.kind === "on";
|
||||
const disabled = authored.kind !== "on";
|
||||
const role = authored.kind === "on" ? authored.role : "associate";
|
||||
const opts = ROLE_OPTIONS.map((r) =>
|
||||
`<option value="${esc(r)}"${role === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`
|
||||
).join("");
|
||||
const reqLabel = esc(t("admin.approval_policies.cell.requires") || "Genehmigung");
|
||||
const clearLabel = esc(t("admin.approval_policies.cell.clear") || "—");
|
||||
const clearTitle = esc(t("admin.approval_policies.cell.clear.title") || "Regel zurücksetzen (erben)");
|
||||
const cleared = authored.kind === "none";
|
||||
return `
|
||||
<label class="ap-cell-toggle">
|
||||
<input type="checkbox" class="ap-cell-requires" ${dataAttrs}${checked ? " checked" : ""} aria-label="${reqLabel}" />
|
||||
<span class="ap-cell-toggle-label">${reqLabel}</span>
|
||||
</label>
|
||||
<select class="ap-cell-role" ${dataAttrs}${disabled ? " disabled" : ""}>${opts}</select>
|
||||
<button type="button" class="ap-cell-clear" ${dataAttrs} title="${clearTitle}"${cleared ? " disabled" : ""}>${clearLabel}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUnitMatrix(unit: PartnerUnit): string {
|
||||
const rows = unitPolicies[unit.id] || [];
|
||||
const buildCell = (e: string, l: string): string => {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const authored = authoredFromUnitPolicy(p);
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
return `<div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>`;
|
||||
};
|
||||
|
||||
let cells = "";
|
||||
for (const e of ENTITY_TYPES) {
|
||||
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const v = p ? p.required_role : null;
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
cells += `<td class="ap-matrix-cell">${renderRoleSelect(v, attrs)}</td>`;
|
||||
cells += `<td class="ap-matrix-cell">${buildCell(e, l)}</td>`;
|
||||
}
|
||||
cells += `</tr>`;
|
||||
}
|
||||
@@ -180,12 +226,9 @@ function renderUnitMatrix(unit: PartnerUnit): string {
|
||||
for (const e of ENTITY_TYPES) {
|
||||
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
|
||||
for (const l of LIFECYCLES) {
|
||||
const p = policyForCell(rows, e, l);
|
||||
const v = p ? p.required_role : null;
|
||||
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
||||
stacked += `<div class="ap-matrix-row">
|
||||
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
|
||||
${renderRoleSelect(v, attrs)}
|
||||
${buildCell(e, l)}
|
||||
</div>`;
|
||||
}
|
||||
stacked += `</div>`;
|
||||
@@ -239,21 +282,39 @@ function renderUnits(): void {
|
||||
|
||||
function renderProjectMatrix(rows: EffectivePolicy[]): string {
|
||||
const cell = (r: EffectivePolicy): string => {
|
||||
const v = r.required_role || null;
|
||||
const own = r.source === "project";
|
||||
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
|
||||
// The controls show the AUTHORED state — the project row's own values
|
||||
// when there is one, else the inherited state is rendered via the
|
||||
// attribution chip and the controls sit unset (kind="none"). Most-
|
||||
// strict-wins inheritance from ancestors / unit defaults is purely
|
||||
// informational on this row; flipping the controls writes a new
|
||||
// project-specific row.
|
||||
const authored = authoredFromEffective(r);
|
||||
let chip = "";
|
||||
if (r.source && !own && r.required_role) {
|
||||
if (r.source && !own && r.requires_approval) {
|
||||
// Inherited from ancestor or unit default. Surface attribution +
|
||||
// the inherited min_role so the admin sees what the cell is
|
||||
// resolving to before they author an override.
|
||||
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
||||
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
|
||||
"admin.approval_policies.source.project";
|
||||
const label = tDyn(sourceKey) || r.source;
|
||||
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}</span>`;
|
||||
const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}${role}</span>`;
|
||||
} else if (r.source && !own && !r.requires_approval) {
|
||||
// Inherited "no approval needed" — distinct from "no rule at all".
|
||||
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
||||
"admin.approval_policies.source.unit_default";
|
||||
const label = tDyn(sourceKey) || r.source;
|
||||
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
||||
const offLabel = esc(t("admin.approval_policies.source.no_approval") || "keine Genehmigung");
|
||||
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name} · ${offLabel}</span>`;
|
||||
} else if (own) {
|
||||
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
|
||||
}
|
||||
return `<div class="ap-cell-wrap">${renderRoleSelect(own ? v : null, attrs)}${chip}</div>`;
|
||||
return `<div class="ap-cell-wrap"><div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>${chip}</div>`;
|
||||
};
|
||||
|
||||
const byCell = new Map<string, EffectivePolicy>();
|
||||
@@ -347,64 +408,117 @@ function renderProjectResults(filter: string): void {
|
||||
// ============================================================================
|
||||
|
||||
function bindCellChangeHandlers(scope: HTMLElement): void {
|
||||
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-select").forEach((sel) => {
|
||||
sel.addEventListener("change", () => void onCellChange(sel));
|
||||
// Each cell now has THREE controls — the requires-approval checkbox,
|
||||
// the role select, and the explicit "clear / inherit" button. They all
|
||||
// share data-* attrs so onCellChange can derive the URL + intended
|
||||
// post-state from any of them.
|
||||
scope.querySelectorAll<HTMLInputElement>(".ap-cell-requires").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
// Toggle the sibling role select disabled state immediately for
|
||||
// visual feedback — the server PUT might lag.
|
||||
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
||||
if (sel) sel.disabled = !cb.checked;
|
||||
void onCellChangeFromCheckbox(cb);
|
||||
});
|
||||
});
|
||||
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-role").forEach((sel) => {
|
||||
sel.addEventListener("change", () => void onCellChangeFromRole(sel));
|
||||
});
|
||||
scope.querySelectorAll<HTMLButtonElement>(".ap-cell-clear").forEach((btn) => {
|
||||
btn.addEventListener("click", () => void onCellClear(btn));
|
||||
});
|
||||
}
|
||||
|
||||
async function onCellChange(sel: HTMLSelectElement): Promise<void> {
|
||||
const scope = sel.dataset.scope;
|
||||
const entity = sel.dataset.entity || "";
|
||||
const lifecycle = sel.dataset.lifecycle || "";
|
||||
const value = sel.value;
|
||||
|
||||
let url = "";
|
||||
function cellEndpointURL(el: HTMLElement): string | null {
|
||||
const scope = el.dataset.scope;
|
||||
const entity = el.dataset.entity || "";
|
||||
const lifecycle = el.dataset.lifecycle || "";
|
||||
if (scope === "unit") {
|
||||
const unitID = sel.dataset.unitId || "";
|
||||
url = `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
} else if (scope === "project") {
|
||||
const projectID = sel.dataset.projectId || "";
|
||||
url = `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
} else {
|
||||
return;
|
||||
const unitID = el.dataset.unitId || "";
|
||||
return `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
}
|
||||
if (scope === "project") {
|
||||
const projectID = el.dataset.projectId || "";
|
||||
return `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function onCellChangeFromCheckbox(cb: HTMLInputElement): Promise<void> {
|
||||
// Checkbox flipped — write the cell as (requires_approval, min_role).
|
||||
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
||||
const requires = cb.checked;
|
||||
const minRole = requires ? (sel?.value || "associate") : null;
|
||||
await putCellSplit(cb, requires, minRole);
|
||||
}
|
||||
|
||||
async function onCellChangeFromRole(sel: HTMLSelectElement): Promise<void> {
|
||||
// Role select changed — only meaningful when the checkbox is on (the
|
||||
// disabled state would block this on a real interaction, but pin it
|
||||
// for safety).
|
||||
const wrap = sel.closest(".ap-cell-controls") as HTMLElement | null;
|
||||
const cb = wrap?.querySelector<HTMLInputElement>(".ap-cell-requires");
|
||||
if (!cb || !cb.checked) return;
|
||||
await putCellSplit(sel, true, sel.value);
|
||||
}
|
||||
|
||||
async function onCellClear(btn: HTMLButtonElement): Promise<void> {
|
||||
// Explicit "back to inheritance" — DELETE the project / unit row.
|
||||
const url = cellEndpointURL(btn);
|
||||
if (!url) return;
|
||||
try {
|
||||
let resp: Response;
|
||||
if (value === "") {
|
||||
resp = await fetch(url, { method: "DELETE" });
|
||||
} else {
|
||||
resp = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ required_role: value }),
|
||||
});
|
||||
}
|
||||
const resp = await fetch(url, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
||||
|
||||
// Re-fetch the affected scope so attribution chips reflect the new state.
|
||||
if (scope === "unit") {
|
||||
const unitID = sel.dataset.unitId || "";
|
||||
unitPolicies[unitID] = await loadUnitPolicies(unitID);
|
||||
renderUnits();
|
||||
} else if (selectedProjectID) {
|
||||
const matrix = await loadMatrix(selectedProjectID);
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
}
|
||||
await refreshAfterCellMutation(btn);
|
||||
} catch (err) {
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function putCellSplit(el: HTMLElement, requires: boolean, minRole: string | null): Promise<void> {
|
||||
const url = cellEndpointURL(el);
|
||||
if (!url) return;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requires_approval: requires, min_role: minRole }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
||||
await refreshAfterCellMutation(el);
|
||||
} catch (err) {
|
||||
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterCellMutation(el: HTMLElement): Promise<void> {
|
||||
const scope = el.dataset.scope;
|
||||
if (scope === "unit") {
|
||||
const unitID = el.dataset.unitId || "";
|
||||
unitPolicies[unitID] = await loadUnitPolicies(unitID);
|
||||
renderUnits();
|
||||
} else if (selectedProjectID) {
|
||||
const matrix = await loadMatrix(selectedProjectID);
|
||||
const host = document.getElementById("ap-matrix-host");
|
||||
if (host) {
|
||||
host.innerHTML = renderProjectMatrix(matrix);
|
||||
bindCellChangeHandlers(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bulk-apply to descendants.
|
||||
// ============================================================================
|
||||
|
||||
@@ -13,6 +13,22 @@ interface Appointment {
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
created_by?: string;
|
||||
// t-paliad-138 + t-paliad-160 — pending-approval surface.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
pending_request_id?: string | null;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
status: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
@@ -25,6 +41,8 @@ interface Project {
|
||||
let appointment: Appointment | null = null;
|
||||
let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -89,6 +107,31 @@ async function loadAllProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingRequest mirrors deadlines-detail.ts (t-paliad-160 §C+E):
|
||||
// pull the in-flight approval_request when the entity is pending so the
|
||||
// badge tooltip + the Withdraw button can be wired correctly.
|
||||
async function loadPendingRequest(): Promise<void> {
|
||||
pendingRequest = null;
|
||||
if (!appointment || appointment.approval_status !== "pending" || !appointment.pending_request_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${appointment.pending_request_id}`);
|
||||
if (resp.ok) pendingRequest = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
@@ -133,6 +176,44 @@ function renderHeader() {
|
||||
} else {
|
||||
projectRow.style.display = "none";
|
||||
}
|
||||
|
||||
// t-paliad-160 §C+E — pending-approval badge + withdraw + freeze controls.
|
||||
const isPending = appointment.approval_status === "pending";
|
||||
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
||||
const apBadge = document.getElementById("appointment-pending-approval-badge") as HTMLElement | null;
|
||||
if (apBadge) {
|
||||
if (isPending) {
|
||||
apBadge.style.display = "";
|
||||
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
||||
apBadge.textContent = labelDe;
|
||||
if (pendingRequest) {
|
||||
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
||||
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
||||
const when = fmtDateTime(pendingRequest.requested_at);
|
||||
apBadge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
||||
} else {
|
||||
apBadge.title = labelDe;
|
||||
}
|
||||
} else {
|
||||
apBadge.style.display = "none";
|
||||
apBadge.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
const withdrawBtn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (withdrawBtn) {
|
||||
withdrawBtn.style.display = (isPending && isRequester) ? "" : "none";
|
||||
withdrawBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
}
|
||||
|
||||
function fillEditForm() {
|
||||
@@ -219,9 +300,9 @@ async function deleteAppointment() {
|
||||
if (resp.ok || resp.status === 204) {
|
||||
window.location.href = "/events?type=appointment";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.error || t("appointments.error.generic");
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
@@ -231,6 +312,40 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.message || data.error || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = (t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseAppointmentID();
|
||||
const loading = document.getElementById("appointment-loading")!;
|
||||
@@ -250,6 +365,8 @@ async function main() {
|
||||
await Promise.all([
|
||||
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
|
||||
loadAllProjects(),
|
||||
loadMe(),
|
||||
loadPendingRequest(),
|
||||
]);
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
@@ -259,6 +376,8 @@ async function main() {
|
||||
|
||||
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
|
||||
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
|
||||
const withdrawBtn = document.getElementById("appointment-withdraw-btn");
|
||||
if (withdrawBtn) withdrawBtn.addEventListener("click", () => void withdrawAppointmentRequest());
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
if (notes) {
|
||||
|
||||
@@ -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}`
|
||||
: "";
|
||||
|
||||
@@ -24,6 +24,20 @@ interface Deadline {
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
event_type_ids?: string[];
|
||||
// t-paliad-138 + t-paliad-160. approval_status='pending' means an
|
||||
// approval_request is in flight; pending_request_id resolves to it
|
||||
// and the controls flip to a withdraw affordance for the requester.
|
||||
approval_status?: "approved" | "pending" | "legacy";
|
||||
pending_request_id?: string | null;
|
||||
}
|
||||
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
status: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,6 +68,7 @@ let project: Project | null = null;
|
||||
let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -170,6 +185,23 @@ async function loadMe() {
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingRequest hydrates the in-flight approval_request when the
|
||||
// entity carries approval_status='pending'. Used to populate the badge
|
||||
// tooltip + decide whether to show the Withdraw button (only the
|
||||
// requester can withdraw).
|
||||
async function loadPendingRequest(): Promise<void> {
|
||||
pendingRequest = null;
|
||||
if (!deadline || deadline.approval_status !== "pending" || !deadline.pending_request_id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${deadline.pending_request_id}`);
|
||||
if (resp.ok) pendingRequest = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal — badge still renders without the tooltip details */
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!deadline) return;
|
||||
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
|
||||
@@ -249,19 +281,49 @@ function render() {
|
||||
|
||||
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
||||
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
||||
const withdrawBtn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement;
|
||||
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
|
||||
const badge = document.getElementById("deadline-pending-approval-badge") as HTMLElement | null;
|
||||
|
||||
// t-paliad-160 §C+E — approval_status='pending' freezes the action
|
||||
// controls and surfaces the badge + a Withdraw button (visible only to
|
||||
// the requester). Other authenticated viewers see only the badge.
|
||||
const isPending = deadline.approval_status === "pending";
|
||||
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
||||
|
||||
if (badge) {
|
||||
if (isPending) {
|
||||
badge.style.display = "";
|
||||
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
||||
badge.textContent = labelDe;
|
||||
// Tooltip carries requester + required_role + age (best-effort).
|
||||
if (pendingRequest) {
|
||||
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
||||
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
||||
const when = fmtDateTime(pendingRequest.requested_at);
|
||||
badge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
||||
} else {
|
||||
badge.title = labelDe;
|
||||
}
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
badge.title = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons.
|
||||
if (deadline.status === "completed") {
|
||||
completeBtn.style.display = "none";
|
||||
// Reopen is admin-gated server-side; the button is shown for global
|
||||
// admins/partners here as a client-side hint. Project leads who lack a
|
||||
// global admin/partner role won't see the inline button — they get a 403
|
||||
// only if they try, but the button itself stays hidden. They can still
|
||||
// PATCH the endpoint directly.
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
if (me && (me.global_role === "global_admin") && !isPending) {
|
||||
reopenBtn.style.display = "";
|
||||
reopenBtn.disabled = false;
|
||||
} else {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
} else if (isPending) {
|
||||
// Lifecycle frozen — server returns 409 to anyone who tries.
|
||||
completeBtn.style.display = "none";
|
||||
reopenBtn.style.display = "none";
|
||||
} else {
|
||||
completeBtn.style.display = "";
|
||||
completeBtn.disabled = false;
|
||||
@@ -269,8 +331,22 @@ function render() {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// Edit button: hidden during pending so users don't fight a 409.
|
||||
if (editBtn) editBtn.style.display = isPending ? "none" : "";
|
||||
|
||||
// Withdraw button: visible only when caller is the requester of the
|
||||
// in-flight request.
|
||||
if (withdrawBtn) {
|
||||
if (isPending && isRequester) {
|
||||
withdrawBtn.style.display = "";
|
||||
withdrawBtn.disabled = false;
|
||||
} else {
|
||||
withdrawBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
if (me && (me.global_role === "global_admin") && !isPending) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
@@ -377,6 +453,25 @@ function initComplete() {
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
deadline = await resp.json();
|
||||
// The complete may have created an approval_request rather than
|
||||
// completed the deadline outright (4-eye-required). Re-fetch the
|
||||
// entity + pending request to surface the right state.
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else if (resp.status === 409) {
|
||||
// The handler returns the t-paliad-160 §B body shape. Surface
|
||||
// the human message and refresh state — likely a concurrent
|
||||
// request was already in flight.
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && body.message) || t("approvals.error.awaiting_approval") || "Diese Anforderung wartet auf Genehmigung.";
|
||||
window.alert(msg);
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) {
|
||||
deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
}
|
||||
render();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -406,6 +501,48 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error)) || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
||||
window.alert(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
window.alert((t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDelete() {
|
||||
const btn = document.getElementById("deadline-delete-btn")!;
|
||||
const modal = document.getElementById("deadline-delete-modal")!;
|
||||
@@ -455,7 +592,7 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
@@ -485,6 +622,7 @@ async function main() {
|
||||
initEdit();
|
||||
initComplete();
|
||||
initReopen();
|
||||
initWithdraw();
|
||||
initDelete();
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
|
||||
@@ -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}`
|
||||
: "";
|
||||
|
||||
@@ -1654,6 +1654,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.approval_policies.source.project": "Projekt",
|
||||
"admin.approval_policies.source.ancestor": "Geerbt",
|
||||
"admin.approval_policies.source.unit_default": "Standard",
|
||||
"admin.approval_policies.source.no_approval": "keine Genehmigung",
|
||||
"admin.approval_policies.cell.requires": "Genehmigung erforderlich",
|
||||
"admin.approval_policies.cell.clear": "—",
|
||||
"admin.approval_policies.cell.clear.title": "Regel zurücksetzen (erben)",
|
||||
"admin.approval_policies.cell.saved_msg": "Gespeichert.",
|
||||
"admin.approval_policies.cell.error_msg": "Fehler",
|
||||
"admin.approval_policies.bulk.cta": "Auf Unterprojekte anwenden",
|
||||
@@ -1972,7 +1976,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
|
||||
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
|
||||
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
|
||||
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
|
||||
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
|
||||
"approvals.pending.badge": "Wartet auf Genehmigung",
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -3709,6 +3718,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.approval_policies.source.project": "Project",
|
||||
"admin.approval_policies.source.ancestor": "Inherited",
|
||||
"admin.approval_policies.source.unit_default": "Default",
|
||||
"admin.approval_policies.source.no_approval": "no approval",
|
||||
"admin.approval_policies.cell.requires": "Approval required",
|
||||
"admin.approval_policies.cell.clear": "—",
|
||||
"admin.approval_policies.cell.clear.title": "Reset to inheritance",
|
||||
"admin.approval_policies.cell.saved_msg": "Saved.",
|
||||
"admin.approval_policies.cell.error_msg": "Error",
|
||||
"admin.approval_policies.bulk.cta": "Apply to descendants",
|
||||
@@ -4027,7 +4040,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.error.not_authorized": "You don't have the required role.",
|
||||
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
|
||||
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
|
||||
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
|
||||
"approvals.error.request_not_pending": "This request is no longer open.",
|
||||
"approvals.pending.badge": "Awaiting approval",
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
|
||||
@@ -80,7 +80,14 @@ async function refresh() {
|
||||
let rows: ApprovalRequestView[] = [];
|
||||
try {
|
||||
const r = await fetch(path, { credentials: "include" });
|
||||
if (r.ok) rows = (await r.json()) as ApprovalRequestView[];
|
||||
if (r.ok) {
|
||||
// Defensive: a Go `nil` slice serialises as JSON `null`, not `[]`.
|
||||
// Coerce so `rows.length` never throws (t-paliad-160 §D regression
|
||||
// hardening). Server-side handler also forces `[]`, but keep the
|
||||
// client guard for older / cached deploys.
|
||||
const body = (await r.json()) as ApprovalRequestView[] | null;
|
||||
rows = body ?? [];
|
||||
}
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
<span id="deadline-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
|
||||
Wartet auf Genehmigung
|
||||
</span>
|
||||
<a id="deadline-project-link" className="entity-ref" href="#" />
|
||||
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
|
||||
</div>
|
||||
@@ -54,6 +57,9 @@ export function renderDeadlinesDetail(): string {
|
||||
<button id="deadline-reopen-btn" type="button" className="btn-primary btn-cta-lime btn-small" style="display:none" data-i18n="deadlines.detail.reopen">
|
||||
Wieder öffnen
|
||||
</button>
|
||||
<button id="deadline-withdraw-btn" type="button" className="btn-secondary btn-small" style="display:none" data-i18n="approvals.withdraw.cta">
|
||||
Genehmigungsanfrage zurückziehen
|
||||
</button>
|
||||
<button id="deadline-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="deadlines.detail.edit" title="Bearbeiten">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
|
||||
@@ -19,7 +19,10 @@ export type I18nKey =
|
||||
| "admin.approval_policies.bulk.modal.title"
|
||||
| "admin.approval_policies.bulk.modal.writes_label"
|
||||
| "admin.approval_policies.bulk.no_descendants"
|
||||
| "admin.approval_policies.cell.clear"
|
||||
| "admin.approval_policies.cell.clear.title"
|
||||
| "admin.approval_policies.cell.error_msg"
|
||||
| "admin.approval_policies.cell.requires"
|
||||
| "admin.approval_policies.cell.saved_msg"
|
||||
| "admin.approval_policies.entity.appointment"
|
||||
| "admin.approval_policies.entity.deadline"
|
||||
@@ -44,6 +47,7 @@ export type I18nKey =
|
||||
| "admin.approval_policies.section.units"
|
||||
| "admin.approval_policies.section.units.hint"
|
||||
| "admin.approval_policies.source.ancestor"
|
||||
| "admin.approval_policies.source.no_approval"
|
||||
| "admin.approval_policies.source.project"
|
||||
| "admin.approval_policies.source.unit_default"
|
||||
| "admin.approval_policies.subtitle"
|
||||
@@ -419,6 +423,7 @@ export type I18nKey =
|
||||
| "approvals.empty.pending_mine"
|
||||
| "approvals.entity.appointment"
|
||||
| "approvals.entity.deadline"
|
||||
| "approvals.error.awaiting_approval"
|
||||
| "approvals.error.concurrent_pending"
|
||||
| "approvals.error.no_qualified_approver"
|
||||
| "approvals.error.not_authorized"
|
||||
@@ -430,6 +435,7 @@ export type I18nKey =
|
||||
| "approvals.lifecycle.delete"
|
||||
| "approvals.lifecycle.update"
|
||||
| "approvals.note.placeholder"
|
||||
| "approvals.pending.badge"
|
||||
| "approvals.pending_complete.label"
|
||||
| "approvals.pending_create.label"
|
||||
| "approvals.pending_delete.label"
|
||||
@@ -457,6 +463,9 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.error"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
|
||||
@@ -6690,6 +6690,24 @@ input[type="range"]::-moz-range-thumb {
|
||||
.frist-due-chip.frist-urgency-later { background: var(--status-green-bg); color: var(--status-green-fg); }
|
||||
.frist-due-chip.frist-urgency-done { background: var(--status-neutral-bg); color: var(--status-neutral-fg); text-decoration: none; }
|
||||
|
||||
/* t-paliad-160 §C — pending-approval badge on the entity detail header
|
||||
* (and entity rows). Surfaces approval_status='pending' so the user
|
||||
* sees why the action buttons are hidden. Hover-tooltip carries
|
||||
* requested_at + required_role + requester. */
|
||||
.approval-pending-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--status-amber-bg);
|
||||
color: var(--status-amber-fg);
|
||||
border: 1px solid var(--status-amber-fg);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.entity-status-chip.entity-status-pending { background: var(--status-amber-bg); color: var(--status-amber-fg); }
|
||||
.entity-status-chip.entity-status-cancelled { background: var(--status-neutral-bg); color: var(--status-neutral-fg); }
|
||||
.entity-status-chip.entity-status-waived { background: var(--status-neutral-bg); color: var(--status-neutral-fg); }
|
||||
@@ -12085,6 +12103,65 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* t-paliad-160 §A — split-grammar 2-control cell. Checkbox + role select
|
||||
* + clear button. The checkbox is the gate (requires_approval); the role
|
||||
* select is the threshold (min_role) and disables when the checkbox is
|
||||
* off. The clear button explicitly drops the cell back to inheritance. */
|
||||
.ap-cell-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.ap-cell-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ap-cell-toggle-label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ap-cell-role {
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--color-input-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ap-cell-role[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ap-cell-clear {
|
||||
align-self: flex-end;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ap-cell-clear:hover:not([disabled]) {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ap-cell-clear[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ap-source-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
@@ -12152,6 +12229,9 @@ dialog.quick-add-sheet::backdrop {
|
||||
.ap-matrix-row .ap-cell-wrap {
|
||||
width: 50%;
|
||||
}
|
||||
.ap-matrix-row .ap-cell-controls {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bulk-apply modal — list of affected descendants. */
|
||||
|
||||
77
internal/db/migrations/066_approval_policy_split.down.sql
Normal file
77
internal/db/migrations/066_approval_policy_split.down.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Reverse t-paliad-160 M1: drop the new columns + restore the previous
|
||||
-- paliad.approval_policy_effective() shape from migration 062.
|
||||
--
|
||||
-- M1 is additive in code (dual-read), so this down migration restores the
|
||||
-- previous resolver semantics (project row wins outright, MAX(level) over
|
||||
-- ancestors+unit defaults). The required_role column was never dropped
|
||||
-- in M1 so the legacy values are still the source of truth.
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle;
|
||||
IF FOUND THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.required_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
paliad.approval_role_level(ap.required_role) AS lvl
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
)
|
||||
SELECT a.required_role, a.src, a.sid
|
||||
FROM (
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
) AS a
|
||||
ORDER BY a.lvl DESC, a.src ASC
|
||||
LIMIT 1;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_min_role_xor_required;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_min_role_check;
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP COLUMN IF EXISTS requires_approval,
|
||||
DROP COLUMN IF EXISTS min_role;
|
||||
237
internal/db/migrations/066_approval_policy_split.up.sql
Normal file
237
internal/db/migrations/066_approval_policy_split.up.sql
Normal file
@@ -0,0 +1,237 @@
|
||||
-- t-paliad-160 (M1, slice 1): split approval_policies.required_role into
|
||||
-- two columns — requires_approval (the gate) + min_role (the seniority
|
||||
-- threshold). The legacy required_role='none' sentinel conflated two
|
||||
-- concepts: "approval applies at all" vs "who can approve". This
|
||||
-- migration introduces the split and backfills.
|
||||
--
|
||||
-- M1 = additive + dual-read. New code paths read both old and new columns
|
||||
-- so a rollback to pre-deploy code keeps working. M2 (follow-up
|
||||
-- migration) will drop required_role once everything writes the new
|
||||
-- shape exclusively.
|
||||
--
|
||||
-- Resolver semantics also change with this split: when both a project-
|
||||
-- level row and a partner-unit-default row resolve for the same
|
||||
-- (entity_type, lifecycle_event), most-strict-wins now applies on BOTH
|
||||
-- axes:
|
||||
-- - requires_approval: OR (true if either side says true).
|
||||
-- - min_role: MAX along approval_role_level().
|
||||
-- That update lives in the paliad.approval_policy_effective() rewrite
|
||||
-- in §4 below.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER paliad.approval_policies ADD COLUMN requires_approval + min_role.
|
||||
-- 2. Backfill: required_role='none' → (false, NULL); else → (true, role).
|
||||
-- 3. Constraint: (requires_approval=false) OR (min_role IS NOT NULL).
|
||||
-- 4. Replace paliad.approval_policy_effective() with most-strict-wins
|
||||
-- across the new columns. Returns (requires_approval, min_role,
|
||||
-- source, source_id) — back-compat shim required_role column kept
|
||||
-- in result type so callers reading the old column don't break
|
||||
-- until they cut over.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. New columns. Both nullable in the schema; the constraint in §3
|
||||
-- enforces the relationship instead of a NOT NULL on requires_approval
|
||||
-- (we want Postgres to keep the row out cleanly when min_role is NULL
|
||||
-- and requires_approval = false).
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD COLUMN requires_approval boolean,
|
||||
ADD COLUMN min_role text;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Backfill from required_role.
|
||||
-- 'none' → (false, NULL)
|
||||
-- else (any of partner/of_counsel/associate/senior_pa/pa) → (true, role)
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET requires_approval = false,
|
||||
min_role = NULL
|
||||
WHERE required_role = 'none';
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET requires_approval = true,
|
||||
min_role = required_role
|
||||
WHERE required_role <> 'none';
|
||||
|
||||
-- After backfill every row has a non-NULL requires_approval. Tighten.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ALTER COLUMN requires_approval SET NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. The split-grammar invariant: a row that demands approval must name
|
||||
-- a min_role; a row that does not demand approval has min_role NULL.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_min_role_xor_required CHECK (
|
||||
(requires_approval = false AND min_role IS NULL)
|
||||
OR
|
||||
(requires_approval = true AND min_role IS NOT NULL)
|
||||
);
|
||||
|
||||
-- min_role values mirror the approval ladder. NULL is allowed (the
|
||||
-- requires_approval=false branch); any other value must be on the ladder.
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_min_role_check CHECK (
|
||||
min_role IS NULL OR min_role IN (
|
||||
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. paliad.approval_policy_effective — most-strict-wins resolver.
|
||||
--
|
||||
-- Returns at most one row, or zero rows when no policy applies. The result
|
||||
-- shape adds two columns (requires_approval, min_role) while keeping the
|
||||
-- legacy required_role column for back-compat dual-read. Old callers that
|
||||
-- still read required_role keep working; new callers branch on
|
||||
-- requires_approval/min_role.
|
||||
--
|
||||
-- Resolution:
|
||||
-- Step 1 — collect candidates for (project, entity, lifecycle):
|
||||
-- a) project-specific row (project_id = p_project_id).
|
||||
-- b) ancestor rows on the project's ltree path (excluding self).
|
||||
-- c) unit-default rows for partner units attached to this project.
|
||||
-- Step 2 — most-strict-wins over the union:
|
||||
-- requires_approval := bool_or(c.requires_approval) -- true if any says true
|
||||
-- min_role := role with MAX(approval_role_level) among the
|
||||
-- candidates whose requires_approval=true.
|
||||
-- NULL if no candidate demands approval.
|
||||
-- Step 3 — the project-specific row no longer wins outright. The 'none'
|
||||
-- sentinel is gone; suppression is now expressed as an explicit
|
||||
-- requires_approval=false at project level, which loses to any
|
||||
-- ancestor / unit_default with requires_approval=true under
|
||||
-- most-strict-wins. This is intentional: the user-locked semantics
|
||||
-- is "tighten only, never loosen by inheritance" and the project
|
||||
-- row that wants to relax inherited rules has to be authored at the
|
||||
-- ancestor / unit level instead. (See t-paliad-160 §A resolver lock.)
|
||||
--
|
||||
-- Returned columns:
|
||||
-- requires_approval boolean — the gate
|
||||
-- min_role text — the threshold (NULL when gate is off)
|
||||
-- required_role text — back-compat: NULL when gate is off,
|
||||
-- else equals min_role. Old callers that
|
||||
-- read required_role keep working until
|
||||
-- M2 drops the column.
|
||||
-- source text — 'project' | 'ancestor' | 'unit_default'
|
||||
-- (the source of the WINNING min_role; for
|
||||
-- a pure requires_approval=false result,
|
||||
-- the source of the highest-priority
|
||||
-- 'false' row in the order project >
|
||||
-- ancestor > unit_default).
|
||||
-- source_id uuid — project_id or partner_unit_id of source.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
requires_approval boolean,
|
||||
min_role text,
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
project_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'project'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
1 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
2 AS src_priority
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
3 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM project_rows
|
||||
UNION ALL
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
),
|
||||
-- Pick the strictest min_role: highest approval_role_level among the
|
||||
-- requires_approval=true candidates. Tie-break: project > ancestor >
|
||||
-- unit_default for stable attribution.
|
||||
strictest_role AS (
|
||||
SELECT c.min_role,
|
||||
c.src AS source,
|
||||
c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = true
|
||||
AND c.min_role IS NOT NULL
|
||||
ORDER BY paliad.approval_role_level(c.min_role) DESC,
|
||||
c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
-- If nothing demands approval, surface the project row's "no approval"
|
||||
-- if present, else any (false) row with stable tie-break, so attribution
|
||||
-- still works for the UI ("inherited from <unit>").
|
||||
no_approval_attribution AS (
|
||||
SELECT c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = false
|
||||
ORDER BY c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
summary AS (
|
||||
SELECT bool_or(c.requires_approval) AS req
|
||||
FROM candidates c
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.req, false) AS requires_approval,
|
||||
sr.min_role AS min_role,
|
||||
sr.min_role AS required_role,
|
||||
COALESCE(sr.source, na.source) AS source,
|
||||
COALESCE(sr.source_id, na.source_id) AS source_id
|
||||
FROM summary s
|
||||
LEFT JOIN strictest_role sr ON true
|
||||
LEFT JOIN no_approval_attribution na ON true
|
||||
WHERE EXISTS (SELECT 1 FROM candidates); -- zero rows when no policy applies
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
|
||||
'Effective approval policy resolver (t-paliad-160 most-strict-wins). '
|
||||
'Returns requires_approval (OR across candidates), min_role (MAX along '
|
||||
'the role ladder among requires_approval=true candidates), and the '
|
||||
'source attribution. required_role mirrors min_role for back-compat '
|
||||
'dual-read with code that hasn''t cut over yet. Zero rows when no '
|
||||
'policy candidates exist for the (project, entity_type, lifecycle).';
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Reverse t-paliad-160 M2: re-add the required_role column on
|
||||
-- paliad.approval_policies and re-introduce the dual-read function shape
|
||||
-- from migration 064.
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD COLUMN required_role text;
|
||||
|
||||
UPDATE paliad.approval_policies
|
||||
SET required_role = CASE
|
||||
WHEN requires_approval = false OR min_role IS NULL THEN 'none'
|
||||
ELSE min_role
|
||||
END;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ALTER COLUMN required_role SET NOT NULL;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
ADD CONSTRAINT approval_policies_required_role_check CHECK (
|
||||
required_role IN ('partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none')
|
||||
);
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
requires_approval boolean,
|
||||
min_role text,
|
||||
required_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
project_rows AS (
|
||||
SELECT ap.requires_approval, ap.min_role,
|
||||
'project'::text AS src, ap.project_id AS sid, 1 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.requires_approval, ap.min_role,
|
||||
'ancestor'::text AS src, ap.project_id AS sid, 2 AS src_priority
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.requires_approval, ap.min_role,
|
||||
'unit_default'::text AS src, ap.partner_unit_id AS sid, 3 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM project_rows UNION ALL
|
||||
SELECT * FROM ancestor_rows UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
),
|
||||
strictest_role AS (
|
||||
SELECT c.min_role, c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = true AND c.min_role IS NOT NULL
|
||||
ORDER BY paliad.approval_role_level(c.min_role) DESC, c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
no_approval_attribution AS (
|
||||
SELECT c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = false
|
||||
ORDER BY c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
summary AS (
|
||||
SELECT bool_or(c.requires_approval) AS req FROM candidates c
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.req, false) AS requires_approval,
|
||||
sr.min_role AS min_role,
|
||||
sr.min_role AS required_role,
|
||||
COALESCE(sr.source, na.source) AS source,
|
||||
COALESCE(sr.source_id, na.source_id) AS source_id
|
||||
FROM summary s
|
||||
LEFT JOIN strictest_role sr ON true
|
||||
LEFT JOIN no_approval_attribution na ON true
|
||||
WHERE EXISTS (SELECT 1 FROM candidates);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- t-paliad-160 (M2): drop the legacy `required_role` column from
|
||||
-- paliad.approval_policies. Migration 064 (M1) introduced the
|
||||
-- requires_approval + min_role split-grammar columns and kept
|
||||
-- required_role as a dual-read mirror so a rollback to pre-deploy
|
||||
-- code would still work. M2 retires the mirror once all writers have
|
||||
-- cut over.
|
||||
--
|
||||
-- Deploy ordering — IMPORTANT:
|
||||
--
|
||||
-- 1. Migration 064 must already be applied (introduces the new
|
||||
-- columns and rewrites approval_policy_effective() to the
|
||||
-- new shape). The Go service layer in slice 1+2 reads the new
|
||||
-- columns AND writes both old + new on every Upsert*. With
|
||||
-- this migration the writes drop the legacy column path.
|
||||
-- 2. After 065, NO code path may reference
|
||||
-- paliad.approval_policies.required_role.
|
||||
-- 3. paliad.approval_requests.required_role is a different column
|
||||
-- (the in-flight snapshot of the policy at submission time) and
|
||||
-- is intentionally untouched here.
|
||||
--
|
||||
-- The function paliad.approval_policy_effective() is also updated to
|
||||
-- stop returning the redundant required_role column.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Replace approval_policy_effective() with a 4-column return that no
|
||||
-- longer mirrors required_role.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
|
||||
|
||||
CREATE FUNCTION paliad.approval_policy_effective(
|
||||
p_project_id uuid,
|
||||
p_entity_type text,
|
||||
p_lifecycle text
|
||||
) RETURNS TABLE (
|
||||
requires_approval boolean,
|
||||
min_role text,
|
||||
source text,
|
||||
source_id uuid
|
||||
)
|
||||
LANGUAGE plpgsql STABLE AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH path AS (
|
||||
SELECT string_to_array(p.path, '.')::uuid[] AS ids
|
||||
FROM paliad.projects p WHERE p.id = p_project_id
|
||||
),
|
||||
project_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'project'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
1 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
WHERE ap.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
ancestor_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'ancestor'::text AS src,
|
||||
ap.project_id AS sid,
|
||||
2 AS src_priority
|
||||
FROM paliad.approval_policies ap, path
|
||||
WHERE ap.project_id = ANY(path.ids)
|
||||
AND ap.project_id <> p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
unit_rows AS (
|
||||
SELECT ap.requires_approval,
|
||||
ap.min_role,
|
||||
'unit_default'::text AS src,
|
||||
ap.partner_unit_id AS sid,
|
||||
3 AS src_priority
|
||||
FROM paliad.approval_policies ap
|
||||
JOIN paliad.project_partner_units ppu
|
||||
ON ppu.partner_unit_id = ap.partner_unit_id
|
||||
WHERE ppu.project_id = p_project_id
|
||||
AND ap.entity_type = p_entity_type
|
||||
AND ap.lifecycle_event = p_lifecycle
|
||||
),
|
||||
candidates AS (
|
||||
SELECT * FROM project_rows
|
||||
UNION ALL
|
||||
SELECT * FROM ancestor_rows
|
||||
UNION ALL
|
||||
SELECT * FROM unit_rows
|
||||
),
|
||||
strictest_role AS (
|
||||
SELECT c.min_role,
|
||||
c.src AS source,
|
||||
c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = true
|
||||
AND c.min_role IS NOT NULL
|
||||
ORDER BY paliad.approval_role_level(c.min_role) DESC,
|
||||
c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
no_approval_attribution AS (
|
||||
SELECT c.src AS source, c.sid AS source_id
|
||||
FROM candidates c
|
||||
WHERE c.requires_approval = false
|
||||
ORDER BY c.src_priority ASC
|
||||
LIMIT 1
|
||||
),
|
||||
summary AS (
|
||||
SELECT bool_or(c.requires_approval) AS req
|
||||
FROM candidates c
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.req, false) AS requires_approval,
|
||||
sr.min_role AS min_role,
|
||||
COALESCE(sr.source, na.source) AS source,
|
||||
COALESCE(sr.source_id, na.source_id) AS source_id
|
||||
FROM summary s
|
||||
LEFT JOIN strictest_role sr ON true
|
||||
LEFT JOIN no_approval_attribution na ON true
|
||||
WHERE EXISTS (SELECT 1 FROM candidates);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
|
||||
'Effective approval policy resolver (t-paliad-160 M2). Returns '
|
||||
'requires_approval (OR across candidates) + min_role (MAX along the '
|
||||
'role ladder among requires_approval=true candidates) + source '
|
||||
'attribution. Zero rows when no policy candidates exist.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Drop the legacy column.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
|
||||
|
||||
ALTER TABLE paliad.approval_policies
|
||||
DROP COLUMN required_role;
|
||||
@@ -62,7 +62,8 @@ func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Body: {"required_role": "associate"}
|
||||
// Body (split-grammar, t-paliad-160): {"requires_approval": bool, "min_role": "associate"|null}
|
||||
// Body (legacy, dual-read window): {"required_role": "associate"|"none"}
|
||||
//
|
||||
// Semantics: upsert. Replaces any existing row for the same
|
||||
// (project, entity_type, lifecycle) tuple.
|
||||
@@ -81,14 +82,17 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
entityType := r.PathValue("entity_type")
|
||||
lifecycle := r.PathValue("lifecycle")
|
||||
var body struct {
|
||||
RequiredRole string `json:"required_role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
body, err := decodePolicyBody(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
policy, err := dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.RequiredRole)
|
||||
var policy *models.ApprovalPolicy
|
||||
if body.useSplit {
|
||||
policy, err = dbSvc.approval.UpsertProjectPolicySplit(r.Context(), uid, projectID, entityType, lifecycle, body.requiresApproval, body.minRole)
|
||||
} else {
|
||||
policy, err = dbSvc.approval.UpsertProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle, body.requiredRole)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -96,6 +100,51 @@ func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
// policyUpsertBody is the parsed payload for the policy PUT endpoints,
|
||||
// supporting both the new split-grammar shape and the legacy single-string
|
||||
// shape during the M1 dual-read window. Exactly one path is taken:
|
||||
// - useSplit=true → call *Split with (requiresApproval, minRole).
|
||||
// - useSplit=false → call legacy with requiredRole.
|
||||
type policyUpsertBody struct {
|
||||
useSplit bool
|
||||
requiresApproval bool
|
||||
minRole *string
|
||||
requiredRole string
|
||||
}
|
||||
|
||||
// decodePolicyBody parses either split-grammar or legacy payload. The
|
||||
// presence of the "requires_approval" key wins — explicit absence falls
|
||||
// back to the legacy required_role path.
|
||||
func decodePolicyBody(r *http.Request) (policyUpsertBody, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
||||
return policyUpsertBody{}, errors.New("invalid JSON")
|
||||
}
|
||||
if v, ok := raw["requires_approval"]; ok {
|
||||
var b policyUpsertBody
|
||||
b.useSplit = true
|
||||
if err := json.Unmarshal(v, &b.requiresApproval); err != nil {
|
||||
return policyUpsertBody{}, errors.New("requires_approval must be boolean")
|
||||
}
|
||||
if mr, ok := raw["min_role"]; ok && string(mr) != "null" {
|
||||
var s string
|
||||
if err := json.Unmarshal(mr, &s); err != nil {
|
||||
return policyUpsertBody{}, errors.New("min_role must be string or null")
|
||||
}
|
||||
b.minRole = &s
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
if v, ok := raw["required_role"]; ok {
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return policyUpsertBody{}, errors.New("required_role must be string")
|
||||
}
|
||||
return policyUpsertBody{useSplit: false, requiredRole: s}, nil
|
||||
}
|
||||
return policyUpsertBody{}, errors.New("requires_approval or required_role required")
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Removes one policy row, reverting that lifecycle event back to the
|
||||
@@ -140,10 +189,18 @@ func handleListInboxPendingMine(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []services.ApprovalRequestView{} // ensure JSON [] not null
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/inbox/mine — requests I submitted.
|
||||
//
|
||||
// Returns ALL statuses by default (pending, approved, rejected, revoked,
|
||||
// superseded). Pass ?status=pending to narrow. Empty result is serialised
|
||||
// as [] not null so the frontend doesn't trip on a null body and crash
|
||||
// before rendering the empty state (t-paliad-160 §D regression hardening).
|
||||
func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -157,6 +214,9 @@ func handleListInboxMine(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []services.ApprovalRequestView{} // ensure JSON [] not null
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
@@ -178,11 +238,20 @@ func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// parseInboxFilter pulls common filter knobs off the query string.
|
||||
//
|
||||
// Status / EntityType pass through validation: an unrecognised value is
|
||||
// silently dropped so a stray ?status=foo from a stale frontend build
|
||||
// doesn't shadow every row out of the result set (t-paliad-160 §D
|
||||
// regression hardening — defence-in-depth against the "Meine Anfragen
|
||||
// is empty" report).
|
||||
func parseInboxFilter(r *http.Request) services.InboxFilter {
|
||||
q := r.URL.Query()
|
||||
f := services.InboxFilter{
|
||||
Status: q.Get("status"),
|
||||
EntityType: q.Get("entity_type"),
|
||||
f := services.InboxFilter{}
|
||||
if s := q.Get("status"); isValidInboxStatus(s) {
|
||||
f.Status = s
|
||||
}
|
||||
if e := q.Get("entity_type"); e == services.EntityTypeDeadline || e == services.EntityTypeAppointment {
|
||||
f.EntityType = e
|
||||
}
|
||||
if pid := q.Get("project_id"); pid != "" {
|
||||
if id, err := uuid.Parse(pid); err == nil {
|
||||
@@ -192,6 +261,21 @@ func parseInboxFilter(r *http.Request) services.InboxFilter {
|
||||
return f
|
||||
}
|
||||
|
||||
// isValidInboxStatus is the allowlist of accepted status filter values.
|
||||
// Empty string passes through as "no filter".
|
||||
func isValidInboxStatus(s string) bool {
|
||||
switch s {
|
||||
case "",
|
||||
services.RequestStatusPending,
|
||||
services.RequestStatusApproved,
|
||||
services.RequestStatusRejected,
|
||||
services.RequestStatusRevoked,
|
||||
services.RequestStatusSuperseded:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GET /api/approval-requests/{id} — one hydrated request.
|
||||
func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
@@ -322,7 +406,8 @@ func handleListUnitApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
|
||||
//
|
||||
// Body: {"required_role": "associate"}
|
||||
// Body (split-grammar): {"requires_approval": bool, "min_role": "associate"|null}
|
||||
// Body (legacy): {"required_role": "associate"|"none"}
|
||||
func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -338,14 +423,17 @@ func handlePutUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
entityType := r.PathValue("entity_type")
|
||||
lifecycle := r.PathValue("lifecycle")
|
||||
var body struct {
|
||||
RequiredRole string `json:"required_role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
body, err := decodePolicyBody(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
policy, err := dbSvc.approval.UpsertUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle, body.RequiredRole)
|
||||
var policy *models.ApprovalPolicy
|
||||
if body.useSplit {
|
||||
policy, err = dbSvc.approval.UpsertUnitPolicySplit(r.Context(), uid, unitID, entityType, lifecycle, body.requiresApproval, body.minRole)
|
||||
} else {
|
||||
policy, err = dbSvc.approval.UpsertUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle, body.requiredRole)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
@@ -491,20 +579,13 @@ func handleProjectEffectivePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// writeApprovalError maps approval-flow errors to HTTP status codes.
|
||||
// writeApprovalError maps approval-flow errors to HTTP status codes. The
|
||||
// code/message body shape is shared with mapApprovalError so the frontend
|
||||
// has a single switch on `code` regardless of which endpoint surfaced
|
||||
// the error (entity mutation vs explicit approve/reject/revoke decision).
|
||||
func writeApprovalError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrSelfApproval):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "self_approval_blocked"})
|
||||
case errors.Is(err, services.ErrNoQualifiedApprover):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "no_qualified_approver"})
|
||||
case errors.Is(err, services.ErrConcurrentPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "concurrent_pending"})
|
||||
case errors.Is(err, services.ErrNotApprover):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "not_authorized"})
|
||||
case errors.Is(err, services.ErrRequestNotPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "request_not_pending"})
|
||||
default:
|
||||
writeServiceError(w, err)
|
||||
if mapApprovalError(w, err) {
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
|
||||
113
internal/handlers/approvals_test.go
Normal file
113
internal/handlers/approvals_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// Pins t-paliad-160 §B: mapApprovalError must surface ErrConcurrentPending
|
||||
// as a 409 with code=awaiting_approval, and PendingApprovalError must
|
||||
// additionally carry the request_id + required_role so the UI can offer a
|
||||
// withdraw button.
|
||||
func TestMapApprovalError_ConcurrentPending409(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrConcurrentPending) {
|
||||
t.Fatal("mapApprovalError returned false for ErrConcurrentPending")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["code"] != "awaiting_approval" {
|
||||
t.Errorf("code = %q, want awaiting_approval", body["code"])
|
||||
}
|
||||
if _, ok := body["request_id"]; ok {
|
||||
t.Errorf("bare ErrConcurrentPending should not carry request_id, got %q", body["request_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApprovalError_PendingApprovalErrorCarriesRequestID(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
pe := services.NewPendingApprovalError("11111111-2222-3333-4444-555555555555", "associate")
|
||||
if !mapApprovalError(w, pe) {
|
||||
t.Fatal("mapApprovalError returned false for PendingApprovalError")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["code"] != "awaiting_approval" {
|
||||
t.Errorf("code = %q, want awaiting_approval", body["code"])
|
||||
}
|
||||
if body["request_id"] != "11111111-2222-3333-4444-555555555555" {
|
||||
t.Errorf("request_id = %q, want the wrapped uuid", body["request_id"])
|
||||
}
|
||||
if body["required_role"] != "associate" {
|
||||
t.Errorf("required_role = %q, want associate", body["required_role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApprovalError_NoQualifiedApprover409(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if !mapApprovalError(w, services.ErrNoQualifiedApprover) {
|
||||
t.Fatal("mapApprovalError returned false for ErrNoQualifiedApprover")
|
||||
}
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", w.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body["code"] != "no_qualified_approver" {
|
||||
t.Errorf("code = %q, want no_qualified_approver", body["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
if mapApprovalError(w, services.ErrInvalidInput) {
|
||||
t.Error("mapApprovalError matched ErrInvalidInput; that's writeServiceError's job")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (recorder default — nothing written)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
|
||||
// hardening: a stray ?status=foo from a stale frontend build (or an
|
||||
// attacker scoping us out of our own list) must NOT shadow rows out of
|
||||
// the result set. The handler silently drops anything not on the allowlist.
|
||||
func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"pending", "pending"},
|
||||
{"approved", "approved"},
|
||||
{"rejected", "rejected"},
|
||||
{"revoked", "revoked"},
|
||||
{"superseded", "superseded"},
|
||||
{"foo", ""}, // unknown — dropped
|
||||
{"DROP+TABLE", ""}, // hostile — dropped
|
||||
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/inbox/mine?status="+tc.raw, nil)
|
||||
f := parseInboxFilter(req)
|
||||
if f.Status != tc.want {
|
||||
t.Errorf("parseInboxFilter(%q).Status = %q, want %q", tc.raw, f.Status, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
|
||||
// writeServiceError maps a services error to an HTTP status.
|
||||
func writeServiceError(w http.ResponseWriter, err error) {
|
||||
if mapApprovalError(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, services.ErrNotVisible):
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
@@ -94,6 +97,72 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// mapApprovalError handles approval-flow errors that bubble through the
|
||||
// shared writeServiceError path (entity mutation handlers — deadlines,
|
||||
// appointments — go through writeServiceError, not writeApprovalError).
|
||||
//
|
||||
// Returns true iff err matched an approval-flow case and the response
|
||||
// has been written. False = caller should keep walking the switch.
|
||||
//
|
||||
// Response shape (t-paliad-160 §B):
|
||||
//
|
||||
// {
|
||||
// code: "awaiting_approval" | "no_qualified_approver" | ...,
|
||||
// message: "<localizable German hint>",
|
||||
// request_id?: "<uuid>", // present when known
|
||||
// required_role?: "<role>", // present when known
|
||||
// }
|
||||
func mapApprovalError(w http.ResponseWriter, err error) bool {
|
||||
var pendingErr *services.PendingApprovalError
|
||||
if errors.As(err, &pendingErr) {
|
||||
body := map[string]string{
|
||||
"code": "awaiting_approval",
|
||||
"message": "Diese Anforderung wartet auf Genehmigung.",
|
||||
}
|
||||
if pendingErr.RequestID != "" {
|
||||
body["request_id"] = pendingErr.RequestID
|
||||
}
|
||||
if pendingErr.RequiredRole != "" {
|
||||
body["required_role"] = pendingErr.RequiredRole
|
||||
}
|
||||
writeJSON(w, http.StatusConflict, body)
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, services.ErrConcurrentPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"code": "awaiting_approval",
|
||||
"message": "Diese Anforderung wartet auf Genehmigung.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrNoQualifiedApprover):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"code": "no_qualified_approver",
|
||||
"message": "Es gibt keinen anderen Benutzer, der diese Anfrage genehmigen kann.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrSelfApproval):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"code": "self_approval_blocked",
|
||||
"message": "Selbst-Genehmigung ist nicht erlaubt.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrNotApprover):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"code": "not_authorized",
|
||||
"message": "Sie sind für diese Genehmigung nicht berechtigt.",
|
||||
})
|
||||
return true
|
||||
case errors.Is(err, services.ErrRequestNotPending):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"code": "request_not_pending",
|
||||
"message": "Die Anfrage ist nicht mehr offen.",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GET /api/projects — list visible projects.
|
||||
// Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo
|
||||
func handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -541,10 +541,16 @@ type ApprovalPolicy struct {
|
||||
PartnerUnitID *uuid.UUID `db:"partner_unit_id" json:"partner_unit_id,omitempty"`
|
||||
EntityType string `db:"entity_type" json:"entity_type"`
|
||||
LifecycleEvent string `db:"lifecycle_event" json:"lifecycle_event"`
|
||||
RequiredRole string `db:"required_role" json:"required_role"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
// RequiresApproval is the gate (t-paliad-160). False = lifecycle event
|
||||
// auto-passes, no approval_request inserted.
|
||||
RequiresApproval bool `db:"requires_approval" json:"requires_approval"`
|
||||
// MinRole is the minimum profession tier qualified to approve. NULL
|
||||
// (nil) when RequiresApproval=false. Constraint: the two columns are
|
||||
// XOR-locked — either (false, NULL) or (true, role).
|
||||
MinRole *string `db:"min_role" json:"min_role,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// EffectivePolicy is the resolved policy for one (project, entity_type,
|
||||
@@ -554,17 +560,23 @@ type ApprovalPolicy struct {
|
||||
// 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.
|
||||
// RequiresApproval is the gate (true iff any candidate demands approval).
|
||||
// MinRole is the seniority threshold among requires_approval=true
|
||||
// candidates (nil when the gate is off). Source ∈ {"project", "ancestor",
|
||||
// "unit_default"} attributes which row supplied the winning value.
|
||||
// SourceID is the project_id for project / ancestor; 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"`
|
||||
EntityType string `json:"entity_type"`
|
||||
LifecycleEvent string `json:"lifecycle_event"`
|
||||
// RequiresApproval is the gate (t-paliad-160 split-grammar). True iff
|
||||
// the resolver yielded a policy that demands approval.
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
// MinRole is the seniority threshold (NULL when gate is off).
|
||||
MinRole *string `json:"min_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
|
||||
|
||||
@@ -39,6 +39,20 @@ func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
|
||||
// request id + required role for a 409 hint. Falls back to the bare
|
||||
// ErrConcurrentPending when approvals is unwired or the lookup fails.
|
||||
func (s *AppointmentService) pendingApprovalErr(ctx context.Context, appointmentID uuid.UUID) error {
|
||||
if s.approvals == nil {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeAppointment, appointmentID)
|
||||
if err != nil || rid == "" {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
return NewPendingApprovalError(rid, role)
|
||||
}
|
||||
|
||||
// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
|
||||
// AppointmentService can push individual appointment changes without importing the
|
||||
// caldav package directly.
|
||||
@@ -384,7 +398,7 @@ func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID u
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
return nil, s.pendingApprovalErr(ctx, appointmentID)
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
@@ -587,7 +601,7 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID u
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
return s.pendingApprovalErr(ctx, appointmentID)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
|
||||
@@ -165,3 +165,32 @@ var (
|
||||
ErrRequestNotPending = errors.New("request is not pending")
|
||||
ErrUnknownEntityType = errors.New("unknown entity type")
|
||||
)
|
||||
|
||||
// PendingApprovalError wraps ErrConcurrentPending with the in-flight
|
||||
// request's id + required role, so handlers can render a 409 body that
|
||||
// tells the user which request is blocking them and lets the UI offer
|
||||
// a "withdraw" affordance pointing at that request.
|
||||
//
|
||||
// Construct via NewPendingApprovalError(requestID, requiredRole). Unwraps
|
||||
// to ErrConcurrentPending so existing errors.Is() checks still work.
|
||||
type PendingApprovalError struct {
|
||||
RequestID string
|
||||
RequiredRole string
|
||||
}
|
||||
|
||||
func (e *PendingApprovalError) Error() string {
|
||||
if e.RequestID == "" {
|
||||
return ErrConcurrentPending.Error()
|
||||
}
|
||||
return ErrConcurrentPending.Error() + ": request_id=" + e.RequestID
|
||||
}
|
||||
|
||||
func (e *PendingApprovalError) Unwrap() error { return ErrConcurrentPending }
|
||||
|
||||
// NewPendingApprovalError builds a PendingApprovalError for an entity row
|
||||
// whose pending_request_id is non-nil. requestID may be the empty string
|
||||
// when the entity row's pending_request_id is unexpectedly NULL — the
|
||||
// error still works as a generic ErrConcurrentPending in that case.
|
||||
func NewPendingApprovalError(requestID, requiredRole string) *PendingApprovalError {
|
||||
return &PendingApprovalError{RequestID: requestID, RequiredRole: requiredRole}
|
||||
}
|
||||
|
||||
@@ -68,26 +68,26 @@ func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
|
||||
// or nil if none applies. 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.
|
||||
// Resolution (t-paliad-160): delegates to paliad.approval_policy_effective(),
|
||||
// which returns at most one row after the most-strict-wins fold over the
|
||||
// project-row / ancestor-row / unit-default candidates. The split-grammar
|
||||
// columns are:
|
||||
//
|
||||
// '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.
|
||||
// - requires_approval — the gate (OR across candidates).
|
||||
// - min_role — the seniority threshold (MAX along the role
|
||||
// ladder among the requires_approval=true
|
||||
// candidates). NULL when the gate is off.
|
||||
//
|
||||
// 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.
|
||||
// When the gate is off (requires_approval=false OR no candidates), this
|
||||
// returns nil and the caller skips creating an approval_request entirely.
|
||||
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
|
||||
var row struct {
|
||||
RequiredRole string `db:"required_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
RequiresApproval bool `db:"requires_approval"`
|
||||
MinRole sql.NullString `db:"min_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
}
|
||||
q := `SELECT required_role, source, source_id
|
||||
q := `SELECT requires_approval, min_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) {
|
||||
@@ -95,15 +95,16 @@ func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, project
|
||||
}
|
||||
return nil, fmt.Errorf("lookup approval policy: %w", err)
|
||||
}
|
||||
if row.RequiredRole == "none" {
|
||||
return nil, nil // explicit suppression at project-row level
|
||||
if !row.RequiresApproval || !row.MinRole.Valid {
|
||||
return nil, nil // gate off — no approval request needed
|
||||
}
|
||||
pid := projectID
|
||||
return &models.ApprovalPolicy{
|
||||
ProjectID: &pid,
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycleEvent,
|
||||
RequiredRole: row.RequiredRole,
|
||||
ProjectID: &pid,
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycleEvent,
|
||||
RequiresApproval: true,
|
||||
MinRole: &row.MinRole.String,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -211,12 +212,15 @@ func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, en
|
||||
|
||||
// Deadlock check: somebody other than the requester must be qualified
|
||||
// to approve, either via project team membership or as global_admin.
|
||||
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, policy.RequiredRole)
|
||||
// LookupPolicy guarantees MinRole is non-nil whenever a non-nil policy
|
||||
// is returned (gate on + threshold set).
|
||||
requiredRole := *policy.MinRole
|
||||
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, requiredRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, policy.RequiredRole)
|
||||
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, requiredRole)
|
||||
}
|
||||
|
||||
// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
|
||||
@@ -248,7 +252,7 @@ func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, en
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`
|
||||
if _, err := tx.ExecContext(ctx, insertReqSQL,
|
||||
requestID, projectID, entityType, entityID, lifecycle,
|
||||
preImageJSON, payloadJSON, requesterID, policy.RequiredRole); err != nil {
|
||||
preImageJSON, payloadJSON, requesterID, requiredRole); err != nil {
|
||||
return nil, fmt.Errorf("insert approval_request: %w", err)
|
||||
}
|
||||
|
||||
@@ -270,11 +274,11 @@ func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, en
|
||||
|
||||
// Audit emit.
|
||||
eventType := approvalEventType(entityType, "requested")
|
||||
descPtr := approvalDescription("requested", policy.RequiredRole, lifecycle)
|
||||
descPtr := approvalDescription("requested", requiredRole, lifecycle)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": requestID.String(),
|
||||
"lifecycle_event": lifecycle,
|
||||
"required_role": policy.RequiredRole,
|
||||
"required_role": requiredRole,
|
||||
entityType + "_id": entityID.String(),
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
|
||||
@@ -659,6 +663,31 @@ func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx,
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// PendingRequestForEntity returns the request_id + required_role of the
|
||||
// in-flight approval_request for an entity in approval_status='pending'.
|
||||
// Returns ("", "", nil) when no pending request is associated. Used by
|
||||
// the entity services to enrich ErrConcurrentPending into a
|
||||
// PendingApprovalError that handlers can render as a 409 with structured
|
||||
// payload.
|
||||
func (s *ApprovalService) PendingRequestForEntity(ctx context.Context, entityType string, entityID uuid.UUID) (string, string, error) {
|
||||
q := `SELECT id::text, required_role
|
||||
FROM paliad.approval_requests
|
||||
WHERE entity_type = $1 AND entity_id = $2 AND status = 'pending'
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT 1`
|
||||
var row struct {
|
||||
ID string `db:"id"`
|
||||
RequiredRole string `db:"required_role"`
|
||||
}
|
||||
if err := s.db.GetContext(ctx, &row, q, entityType, entityID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("lookup pending request: %w", err)
|
||||
}
|
||||
return row.ID, row.RequiredRole, nil
|
||||
}
|
||||
|
||||
// entityTableName resolves the SQL table name for a given entity_type.
|
||||
// Internal helper — entityType comes from server-side constants, not user
|
||||
// input, so a panic on an unknown value is a programming error.
|
||||
@@ -951,7 +980,8 @@ func IsValidPolicyRole(role string) bool {
|
||||
// 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
|
||||
requires_approval, min_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE project_id = $1
|
||||
ORDER BY entity_type, lifecycle_event`
|
||||
@@ -966,7 +996,8 @@ func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uui
|
||||
// partner unit (up to 8).
|
||||
func (s *ApprovalService) ListUnitPolicies(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
|
||||
requires_approval, min_role,
|
||||
created_at, updated_at, created_by
|
||||
FROM paliad.approval_policies
|
||||
WHERE partner_unit_id = $1
|
||||
ORDER BY entity_type, lifecycle_event`
|
||||
@@ -994,14 +1025,79 @@ func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePolicySplit validates the split-grammar tuple (requires_approval,
|
||||
// min_role). When requires_approval=true, min_role must be one of the
|
||||
// strict-ladder professions; when false, min_role must be nil.
|
||||
func validatePolicySplit(entityType, lifecycle string, requiresApproval bool, minRole *string) error {
|
||||
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
|
||||
return fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
|
||||
}
|
||||
switch lifecycle {
|
||||
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
|
||||
default:
|
||||
return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
|
||||
}
|
||||
if requiresApproval {
|
||||
if minRole == nil || !IsValidRequiredRole(*minRole) {
|
||||
role := ""
|
||||
if minRole != nil {
|
||||
role = *minRole
|
||||
}
|
||||
return fmt.Errorf("%w: min_role %q (required when requires_approval=true)", ErrInvalidInput, role)
|
||||
}
|
||||
} else if minRole != nil {
|
||||
return fmt.Errorf("%w: min_role must be NULL when requires_approval=false", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitFromLegacy maps the legacy required_role grammar into the
|
||||
// split-grammar pair. 'none' → (false, nil); else → (true, &role). Used by
|
||||
// the back-compat Upsert*Policy shims that still take required_role.
|
||||
func splitFromLegacy(requiredRole string) (bool, *string) {
|
||||
if requiredRole == "none" {
|
||||
return false, nil
|
||||
}
|
||||
r := requiredRole
|
||||
return true, &r
|
||||
}
|
||||
|
||||
// legacyFromSplit is the inverse: produce the audit-row required_role
|
||||
// string. Used so the policy_audit_log keeps the human-readable role
|
||||
// (or 'none') under the old grammar even after callers cut over to the
|
||||
// split-grammar API.
|
||||
func legacyFromSplit(requiresApproval bool, minRole *string) string {
|
||||
if !requiresApproval || minRole == nil {
|
||||
return "none"
|
||||
}
|
||||
return *minRole
|
||||
}
|
||||
|
||||
// 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.
|
||||
// row using the legacy required_role grammar ('none' → no approval; else
|
||||
// the strict-ladder role). Thin shim around UpsertProjectPolicySplit kept
|
||||
// for callers (and tests) that haven't cut over yet.
|
||||
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
|
||||
}
|
||||
requiresApproval, minRole := splitFromLegacy(requiredRole)
|
||||
return s.UpsertProjectPolicySplit(ctx, callerID, projectID, entityType, lifecycle, requiresApproval, minRole)
|
||||
}
|
||||
|
||||
// UpsertProjectPolicySplit creates or replaces a single project-scoped
|
||||
// policy row using the split-grammar (requires_approval, min_role) shape
|
||||
// (t-paliad-160). Caller must be global_admin (gate enforced at the
|
||||
// handler layer). Audit row written via writePolicyAudit using the
|
||||
// legacy required_role string for compatibility with the existing
|
||||
// policy_audit_log shape.
|
||||
func (s *ApprovalService) UpsertProjectPolicySplit(
|
||||
ctx context.Context, callerID, projectID uuid.UUID,
|
||||
entityType, lifecycle string, requiresApproval bool, minRole *string,
|
||||
) (*models.ApprovalPolicy, error) {
|
||||
if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -1009,26 +1105,45 @@ func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, pro
|
||||
}
|
||||
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
|
||||
// Snapshot pre-existing (requires_approval, min_role) for the audit
|
||||
// row. The audit log still uses the legacy string format
|
||||
// (partner|of_counsel|...|none) so we project through legacyFromSplit.
|
||||
var preReq sql.NullBool
|
||||
var preMin sql.NullString
|
||||
if err := tx.QueryRowxContext(ctx,
|
||||
`SELECT requires_approval, min_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) {
|
||||
projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
|
||||
}
|
||||
var oldRole *string
|
||||
if preReq.Valid {
|
||||
var pm *string
|
||||
if preMin.Valid {
|
||||
s := preMin.String
|
||||
pm = &s
|
||||
}
|
||||
legacy := legacyFromSplit(preReq.Bool, pm)
|
||||
oldRole = &legacy
|
||||
}
|
||||
|
||||
requiredRole := legacyFromSplit(requiresApproval, minRole)
|
||||
q := `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)
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
requires_approval, min_role, created_by)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6)
|
||||
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 requires_approval = EXCLUDED.requires_approval,
|
||||
min_role = EXCLUDED.min_role,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by`
|
||||
requires_approval, min_role,
|
||||
created_at, updated_at, created_by`
|
||||
var p models.ApprovalPolicy
|
||||
if err := tx.GetContext(ctx, &p, q, projectID, entityType, lifecycle, requiredRole, callerID); err != nil {
|
||||
if err := tx.GetContext(ctx, &p, q,
|
||||
projectID, entityType, lifecycle,
|
||||
requiresApproval, minRole, callerID); err != nil {
|
||||
return nil, fmt.Errorf("upsert project policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -1043,7 +1158,7 @@ func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, pro
|
||||
|
||||
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
|
||||
"project", &projectID, nil, scopeName, entityType, lifecycle,
|
||||
nullToPtr(oldRole), &requiredRole); err != nil {
|
||||
oldRole, &requiredRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1062,17 +1177,23 @@ func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, pro
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var oldRole sql.NullString
|
||||
if err := tx.GetContext(ctx, &oldRole,
|
||||
`SELECT required_role FROM paliad.approval_policies
|
||||
var preReq sql.NullBool
|
||||
var preMin sql.NullString
|
||||
if err := tx.QueryRowxContext(ctx,
|
||||
`SELECT requires_approval, min_role FROM paliad.approval_policies
|
||||
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
|
||||
projectID, entityType, lifecycle); err != nil {
|
||||
projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Nothing to delete — exit cleanly without auditing a no-op.
|
||||
return nil
|
||||
return nil // nothing to delete — no audit needed
|
||||
}
|
||||
return fmt.Errorf("delete project policy: read pre-image: %w", err)
|
||||
}
|
||||
var pm *string
|
||||
if preMin.Valid {
|
||||
s := preMin.String
|
||||
pm = &s
|
||||
}
|
||||
oldRoleStr := legacyFromSplit(preReq.Bool, pm)
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.approval_policies
|
||||
@@ -1089,17 +1210,32 @@ func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, pro
|
||||
|
||||
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
|
||||
"project", &projectID, nil, scopeName, entityType, lifecycle,
|
||||
nullToPtr(oldRole), nil); err != nil {
|
||||
&oldRoleStr, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpsertUnitPolicy creates or replaces a single unit-default policy row.
|
||||
// UpsertUnitPolicy creates or replaces a single unit-default policy row
|
||||
// using the legacy required_role grammar. Thin shim around
|
||||
// UpsertUnitPolicySplit kept for callers / tests that haven't cut over.
|
||||
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
|
||||
}
|
||||
requiresApproval, minRole := splitFromLegacy(requiredRole)
|
||||
return s.UpsertUnitPolicySplit(ctx, callerID, unitID, entityType, lifecycle, requiresApproval, minRole)
|
||||
}
|
||||
|
||||
// UpsertUnitPolicySplit creates or replaces a single unit-default policy
|
||||
// row using the split-grammar (requires_approval, min_role) shape.
|
||||
func (s *ApprovalService) UpsertUnitPolicySplit(
|
||||
ctx context.Context, callerID, unitID uuid.UUID,
|
||||
entityType, lifecycle string, requiresApproval bool, minRole *string,
|
||||
) (*models.ApprovalPolicy, error) {
|
||||
if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -1107,25 +1243,42 @@ func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var oldRole sql.NullString
|
||||
if err := tx.GetContext(ctx, &oldRole,
|
||||
`SELECT required_role FROM paliad.approval_policies
|
||||
var preReq sql.NullBool
|
||||
var preMin sql.NullString
|
||||
if err := tx.QueryRowxContext(ctx,
|
||||
`SELECT requires_approval, min_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) {
|
||||
unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
|
||||
}
|
||||
var oldRole *string
|
||||
if preReq.Valid {
|
||||
var pm *string
|
||||
if preMin.Valid {
|
||||
s := preMin.String
|
||||
pm = &s
|
||||
}
|
||||
legacy := legacyFromSplit(preReq.Bool, pm)
|
||||
oldRole = &legacy
|
||||
}
|
||||
|
||||
requiredRole := legacyFromSplit(requiresApproval, minRole)
|
||||
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)
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
requires_approval, min_role, created_by)
|
||||
VALUES (NULL, $1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
|
||||
WHERE partner_unit_id IS NOT NULL
|
||||
DO UPDATE SET required_role = EXCLUDED.required_role,
|
||||
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
|
||||
min_role = EXCLUDED.min_role,
|
||||
updated_at = now()
|
||||
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
required_role, created_at, updated_at, created_by`
|
||||
requires_approval, min_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 {
|
||||
if err := tx.GetContext(ctx, &p, q,
|
||||
unitID, entityType, lifecycle,
|
||||
requiresApproval, minRole, callerID); err != nil {
|
||||
return nil, fmt.Errorf("upsert unit policy: %w", err)
|
||||
}
|
||||
|
||||
@@ -1137,7 +1290,7 @@ func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID
|
||||
|
||||
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
|
||||
"unit", nil, &unitID, scopeName, entityType, lifecycle,
|
||||
nullToPtr(oldRole), &requiredRole); err != nil {
|
||||
oldRole, &requiredRole); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1155,16 +1308,23 @@ func (s *ApprovalService) DeleteUnitPolicy(ctx context.Context, callerID, unitID
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var oldRole sql.NullString
|
||||
if err := tx.GetContext(ctx, &oldRole,
|
||||
`SELECT required_role FROM paliad.approval_policies
|
||||
var preReq sql.NullBool
|
||||
var preMin sql.NullString
|
||||
if err := tx.QueryRowxContext(ctx,
|
||||
`SELECT requires_approval, min_role FROM paliad.approval_policies
|
||||
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
|
||||
unitID, entityType, lifecycle); err != nil {
|
||||
unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete unit policy: read pre-image: %w", err)
|
||||
}
|
||||
var pm *string
|
||||
if preMin.Valid {
|
||||
s := preMin.String
|
||||
pm = &s
|
||||
}
|
||||
oldRoleStr := legacyFromSplit(preReq.Bool, pm)
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.approval_policies
|
||||
@@ -1181,7 +1341,7 @@ func (s *ApprovalService) DeleteUnitPolicy(ctx context.Context, callerID, unitID
|
||||
|
||||
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
|
||||
"unit", nil, &unitID, scopeName, entityType, lifecycle,
|
||||
nullToPtr(oldRole), nil); err != nil {
|
||||
&oldRoleStr, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
@@ -1206,13 +1366,14 @@ var allLifecycleEvents = []struct {
|
||||
|
||||
// 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).
|
||||
// the resolved (requires_approval, min_role) pair + 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)".
|
||||
// requires_approval=false with a non-nil source means the cell has been
|
||||
// explicitly authored as "no approval needed" at that scope; cells with
|
||||
// no candidates at all return Source=nil so the admin UI can distinguish
|
||||
// "inherited off" from "never authored".
|
||||
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projectID uuid.UUID) ([]models.EffectivePolicy, error) {
|
||||
out := make([]models.EffectivePolicy, 0, len(allLifecycleEvents))
|
||||
for _, c := range allLifecycleEvents {
|
||||
@@ -1227,13 +1388,17 @@ func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projec
|
||||
|
||||
// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
|
||||
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
|
||||
//
|
||||
// Carries the split-grammar fields: RequiresApproval is the gate, MinRole
|
||||
// the seniority threshold (NULL when gate off).
|
||||
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"`
|
||||
RequiresApproval bool `db:"requires_approval"`
|
||||
MinRole sql.NullString `db:"min_role"`
|
||||
Source sql.NullString `db:"source"`
|
||||
SourceID *uuid.UUID `db:"source_id"`
|
||||
}
|
||||
q := `SELECT required_role, source, source_id
|
||||
q := `SELECT requires_approval, min_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) {
|
||||
@@ -1246,12 +1411,13 @@ func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID u
|
||||
}
|
||||
|
||||
res := &models.EffectivePolicy{
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycle,
|
||||
EntityType: entityType,
|
||||
LifecycleEvent: lifecycle,
|
||||
RequiresApproval: row.RequiresApproval,
|
||||
}
|
||||
if row.RequiredRole.Valid {
|
||||
rr := row.RequiredRole.String
|
||||
res.RequiredRole = &rr
|
||||
if row.MinRole.Valid {
|
||||
mr := row.MinRole.String
|
||||
res.MinRole = &mr
|
||||
}
|
||||
if row.Source.Valid {
|
||||
src := row.Source.String
|
||||
@@ -1259,8 +1425,6 @@ func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID u
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1353,16 +1517,23 @@ func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID
|
||||
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.
|
||||
// Apply source's effective values as project-scoped rows. Skip
|
||||
// cells where the source has no policy at all (no candidates) —
|
||||
// the target is left to inherit from its own ancestors / unit
|
||||
// defaults rather than getting a synthetic project row written.
|
||||
for _, cell := range matrix {
|
||||
if cell.RequiredRole == nil {
|
||||
continue
|
||||
if cell.Source == nil {
|
||||
continue // no candidates for this cell at the source
|
||||
}
|
||||
requiresApproval := cell.RequiresApproval
|
||||
minRole := cell.MinRole
|
||||
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 {
|
||||
(project_id, partner_unit_id, entity_type, lifecycle_event,
|
||||
requires_approval, min_role, created_by)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6)`,
|
||||
target, cell.EntityType, cell.LifecycleEvent,
|
||||
requiresApproval, minRole, callerID); err != nil {
|
||||
return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
|
||||
target, cell.EntityType, cell.LifecycleEvent, err)
|
||||
}
|
||||
@@ -1434,7 +1605,8 @@ func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx,
|
||||
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
|
||||
requires_approval, min_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)
|
||||
@@ -1475,15 +1647,6 @@ func (s *ApprovalService) writePolicyAuditRaw(
|
||||
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 }
|
||||
|
||||
|
||||
@@ -708,8 +708,14 @@ func TestApprovalService_PolicyCRUD(t *testing.T) {
|
||||
}
|
||||
got, _ = env.approvals.ListProjectPolicies(ctx, env.projectID)
|
||||
for _, p := range got {
|
||||
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate && p.RequiredRole != "partner" {
|
||||
t.Errorf("after re-upsert: required_role=%q, want partner", p.RequiredRole)
|
||||
if p.EntityType == EntityTypeDeadline && p.LifecycleEvent == LifecycleCreate {
|
||||
gotRole := ""
|
||||
if p.MinRole != nil {
|
||||
gotRole = *p.MinRole
|
||||
}
|
||||
if gotRole != "partner" {
|
||||
t.Errorf("after re-upsert: min_role=%q, want partner", gotRole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,3 +738,77 @@ func TestApprovalService_PolicyCRUD(t *testing.T) {
|
||||
t.Errorf("after delete: %d rows, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalService_ListSubmittedByUser_PendingVisible pins t-paliad-160
|
||||
// §D: a user with one pending approval_request must see it on /api/inbox/mine
|
||||
// — neither the service nor the handler may filter pending rows out of the
|
||||
// "Meine Anfragen" view. The only legitimate filter is the explicit
|
||||
// ?status=... query parameter, which the handler validates against an
|
||||
// allowlist (everything else is ignored).
|
||||
func TestApprovalService_ListSubmittedByUser_PendingVisible(t *testing.T) {
|
||||
env := setupApprovalTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
env.seedPolicy(EntityTypeDeadline, LifecycleCreate, "associate")
|
||||
deadlineID := env.seedDeadline(time.Now().AddDate(0, 0, 14))
|
||||
|
||||
tx, err := env.pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
reqID, err := env.approvals.SubmitCreate(ctx, tx, env.projectID, deadlineID, env.requester, EntityTypeDeadline, nil)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("SubmitCreate: %v", err)
|
||||
}
|
||||
if reqID == nil {
|
||||
tx.Rollback()
|
||||
t.Fatal("SubmitCreate returned nil request id")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// No filter — must include the pending row authored by env.requester.
|
||||
rows, err := env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubmittedByUser: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("len(rows) = %d, want 1 — pending row must surface on Meine Anfragen", len(rows))
|
||||
}
|
||||
if rows[0].ID != *reqID {
|
||||
t.Errorf("rows[0].ID = %s, want %s", rows[0].ID, *reqID)
|
||||
}
|
||||
if rows[0].Status != RequestStatusPending {
|
||||
t.Errorf("rows[0].Status = %q, want pending", rows[0].Status)
|
||||
}
|
||||
|
||||
// Explicit ?status=pending filter — same row.
|
||||
rows, err = env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{Status: RequestStatusPending})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubmittedByUser status=pending: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Errorf("status=pending filter: len(rows) = %d, want 1", len(rows))
|
||||
}
|
||||
|
||||
// Explicit ?status=approved filter — empty (the row is pending).
|
||||
rows, err = env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{Status: RequestStatusApproved})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubmittedByUser status=approved: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("status=approved filter: len(rows) = %d, want 0", len(rows))
|
||||
}
|
||||
|
||||
// Different user — empty (this is "MY submissions", scoped by requested_by).
|
||||
rows, err = env.approvals.ListSubmittedByUser(ctx, env.approver, InboxFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubmittedByUser other user: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("other user: len(rows) = %d, want 0 — must scope by requested_by", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,23 @@ func (s *DeadlineService) SetApprovalService(a *ApprovalService) {
|
||||
s.approvals = a
|
||||
}
|
||||
|
||||
// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
|
||||
// approval_request id + required_role for an entity, so handlers can
|
||||
// render a 409 body that points the UI at the blocking request. Falls
|
||||
// back to the bare ErrConcurrentPending if approvals isn't wired or the
|
||||
// lookup fails — the user still gets a 409, just without the structured
|
||||
// hint.
|
||||
func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uuid.UUID) error {
|
||||
if s.approvals == nil {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeDeadline, deadlineID)
|
||||
if err != nil || rid == "" {
|
||||
return ErrConcurrentPending
|
||||
}
|
||||
return NewPendingApprovalError(rid, role)
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
@@ -420,7 +437,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return nil, err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
return nil, s.pendingApprovalErr(ctx, deadlineID)
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
@@ -594,7 +611,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, deadlineID uuid.
|
||||
return current, nil
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return nil, ErrConcurrentPending
|
||||
return nil, s.pendingApprovalErr(ctx, deadlineID)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
@@ -741,7 +758,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, deadlineID uuid.UU
|
||||
return err
|
||||
}
|
||||
if current.ApprovalStatus == ApprovalStatusPending {
|
||||
return ErrConcurrentPending
|
||||
return s.pendingApprovalErr(ctx, deadlineID)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
|
||||
Reference in New Issue
Block a user