diff --git a/frontend/src/appointments-detail.tsx b/frontend/src/appointments-detail.tsx
index ded0b33..bdd320d 100644
--- a/frontend/src/appointments-detail.tsx
+++ b/frontend/src/appointments-detail.tsx
@@ -35,6 +35,9 @@ export function renderAppointmentsDetail(): string {
+
+ Wartet auf Genehmigung
+
@@ -94,6 +97,7 @@ export function renderAppointmentsDetail(): string {
+ Genehmigungsanfrage zurückziehen
Termin löschen
Änderungen speichern
diff --git a/frontend/src/client/admin-approval-policies.ts b/frontend/src/client/admin-approval-policies.ts
index ae48a5e..8df0224 100644
--- a/frontend/src/client/admin-approval-policies.ts
+++ b/frontend/src/client/admin-approval-policies.ts
@@ -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(`
${esc(t("admin.approval_policies.role.no_rule") || "— keine Regel —")} `);
- for (const r of ROLE_OPTIONS) {
- opts.push(`
${esc(roleLabel(r))} `);
- }
- return `
${opts.join("")} `;
+// 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) =>
+ `
${esc(roleLabel(r))} `
+ ).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 `
+
+
+ ${reqLabel}
+
+
${opts}
+
${clearLabel}
+ `;
}
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 `
${renderCellControls(authored, attrs)}
`;
+ };
+
let cells = "";
for (const e of ENTITY_TYPES) {
cells += `
${esc(entityLabel(e))} `;
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 += `${renderRoleSelect(v, attrs)} `;
+ cells += `${buildCell(e, l)} `;
}
cells += ``;
}
@@ -180,12 +226,9 @@ function renderUnitMatrix(unit: PartnerUnit): string {
for (const e of ENTITY_TYPES) {
stacked += `
${esc(entityLabel(e))} `;
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 += `
${esc(lifecycleLabel(l))}
- ${renderRoleSelect(v, attrs)}
+ ${buildCell(e, l)}
`;
}
stacked += `
`;
@@ -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 = `
${esc(label)}${name} `;
+ const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
+ chip = `
${esc(label)}${name}${role} `;
+ } 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 = `
${esc(label)}${name} · ${offLabel} `;
} else if (own) {
chip = `
${esc(t("admin.approval_policies.source.project") || "Projekt")} `;
}
- return `
${renderRoleSelect(own ? v : null, attrs)}${chip}
`;
+ return `
${renderCellControls(authored, attrs)}
${chip}
`;
};
const byCell = new Map
();
@@ -347,64 +408,117 @@ function renderProjectResults(filter: string): void {
// ============================================================================
function bindCellChangeHandlers(scope: HTMLElement): void {
- scope.querySelectorAll(".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(".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(".ap-cell-role");
+ if (sel) sel.disabled = !cb.checked;
+ void onCellChangeFromCheckbox(cb);
+ });
+ });
+ scope.querySelectorAll(".ap-cell-role").forEach((sel) => {
+ sel.addEventListener("change", () => void onCellChangeFromRole(sel));
+ });
+ scope.querySelectorAll(".ap-cell-clear").forEach((btn) => {
+ btn.addEventListener("click", () => void onCellClear(btn));
});
}
-async function onCellChange(sel: HTMLSelectElement): Promise {
- 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 {
+ // Checkbox flipped — write the cell as (requires_approval, min_role).
+ const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
+ const sel = wrap?.querySelector(".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 {
+ // 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(".ap-cell-requires");
+ if (!cb || !cb.checked) return;
+ await putCellSplit(sel, true, sel.value);
+}
+
+async function onCellClear(btn: HTMLButtonElement): Promise {
+ // 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 {
+ 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 {
+ 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.
// ============================================================================
diff --git a/frontend/src/client/appointments-detail.ts b/frontend/src/client/appointments-detail.ts
index d4545f9..d30982a 100644
--- a/frontend/src/client/appointments-detail.ts
+++ b/frontend/src/client/appointments-detail.ts
@@ -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 {
+ 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("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) {
diff --git a/frontend/src/client/appointments-new.ts b/frontend/src/client/appointments-new.ts
index f719c76..7cc330c 100644
--- a/frontend/src/client/appointments-new.ts
+++ b/frontend/src/client/appointments-new.ts
@@ -126,16 +126,23 @@ async function refreshApprovalHint(): Promise {
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}`
: "";
diff --git a/frontend/src/client/deadlines-detail.ts b/frontend/src/client/deadlines-detail.ts
index adcf15b..aa633cd 100644
--- a/frontend/src/client/deadlines-detail.ts
+++ b/frontend/src/client/deadlines-detail.ts
@@ -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 {
+ 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");
diff --git a/frontend/src/client/deadlines-new.ts b/frontend/src/client/deadlines-new.ts
index 1235ba5..3df1551 100644
--- a/frontend/src/client/deadlines-new.ts
+++ b/frontend/src/client/deadlines-new.ts
@@ -193,16 +193,21 @@ async function refreshApprovalHint(): Promise {
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}`
: "";
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index 1dd9de2..5e0c060 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -1654,6 +1654,10 @@ const translations: Record> = {
"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> = {
"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> = {
"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> = {
"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)",
diff --git a/frontend/src/client/inbox.ts b/frontend/src/client/inbox.ts
index 65a8724..09db983 100644
--- a/frontend/src/client/inbox.ts
+++ b/frontend/src/client/inbox.ts
@@ -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.
}
diff --git a/frontend/src/deadlines-detail.tsx b/frontend/src/deadlines-detail.tsx
index 78a2db9..6f22403 100644
--- a/frontend/src/deadlines-detail.tsx
+++ b/frontend/src/deadlines-detail.tsx
@@ -43,6 +43,9 @@ export function renderDeadlinesDetail(): string {
+
+ Wartet auf Genehmigung
+
@@ -54,6 +57,9 @@ export function renderDeadlinesDetail(): string {
Wieder öffnen
+
+ Genehmigungsanfrage zurückziehen
+
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts
index 95e575b..a15df11 100644
--- a/frontend/src/i18n-keys.ts
+++ b/frontend/src/i18n-keys.ts
@@ -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"
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index c8fa1b1..0d18730 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -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. */
diff --git a/internal/db/migrations/066_approval_policy_split.down.sql b/internal/db/migrations/066_approval_policy_split.down.sql
new file mode 100644
index 0000000..96a4c2a
--- /dev/null
+++ b/internal/db/migrations/066_approval_policy_split.down.sql
@@ -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;
diff --git a/internal/db/migrations/066_approval_policy_split.up.sql b/internal/db/migrations/066_approval_policy_split.up.sql
new file mode 100644
index 0000000..4083801
--- /dev/null
+++ b/internal/db/migrations/066_approval_policy_split.up.sql
@@ -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 ").
+ 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).';
diff --git a/internal/db/migrations/067_approval_policy_drop_required_role.down.sql b/internal/db/migrations/067_approval_policy_drop_required_role.down.sql
new file mode 100644
index 0000000..fcf6e5b
--- /dev/null
+++ b/internal/db/migrations/067_approval_policy_drop_required_role.down.sql
@@ -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;
+$$;
diff --git a/internal/db/migrations/067_approval_policy_drop_required_role.up.sql b/internal/db/migrations/067_approval_policy_drop_required_role.up.sql
new file mode 100644
index 0000000..d7bbc8b
--- /dev/null
+++ b/internal/db/migrations/067_approval_policy_drop_required_role.up.sql
@@ -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;
diff --git a/internal/handlers/approvals.go b/internal/handlers/approvals.go
index 03bac9f..e617257 100644
--- a/internal/handlers/approvals.go
+++ b/internal/handlers/approvals.go
@@ -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)
}
diff --git a/internal/handlers/approvals_test.go b/internal/handlers/approvals_test.go
new file mode 100644
index 0000000..92407bb
--- /dev/null
+++ b/internal/handlers/approvals_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go
index fbc1d86..6f980a7 100644
--- a/internal/handlers/projects.go
+++ b/internal/handlers/projects.go
@@ -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: "",
+// request_id?: "", // present when known
+// required_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=&parent_null=1&search=foo
func handleListProjects(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/models/models.go b/internal/models/models.go
index 6053f39..b292a5a 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -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
diff --git a/internal/services/appointment_service.go b/internal/services/appointment_service.go
index 3650066..ec52d71 100644
--- a/internal/services/appointment_service.go
+++ b/internal/services/appointment_service.go
@@ -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)
diff --git a/internal/services/approval_levels.go b/internal/services/approval_levels.go
index 379bf49..cc6aace 100644
--- a/internal/services/approval_levels.go
+++ b/internal/services/approval_levels.go
@@ -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}
+}
diff --git a/internal/services/approval_service.go b/internal/services/approval_service.go
index 24f1661..b7384ed 100644
--- a/internal/services/approval_service.go
+++ b/internal/services/approval_service.go
@@ -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 }
diff --git a/internal/services/approval_service_test.go b/internal/services/approval_service_test.go
index e3b861f..6ec89ac 100644
--- a/internal/services/approval_service_test.go
+++ b/internal/services/approval_service_test.go
@@ -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))
+ }
+}
diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go
index d8230b6..09a5a81 100644
--- a/internal/services/deadline_service.go
+++ b/internal/services/deadline_service.go
@@ -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)