Merge: t-paliad-160 — Approval system rework (shannon, 3 slices + renumber:

- Slice 1+2 (3a41aa9): schema split required_role → requires_approval boolean + min_role text via mig 066 with two-step dual-read; resolver most-strict-wins via approval_role_level(); ErrAlreadyPendingApproval / ErrNoQualifiedApprover / ErrInvalidInput → 409 Conflict with structured body via mapApprovalError helper.
- Slice 2 (073af97): /admin/approval-policies UI flip — 2-control checkbox + role dropdown replacing the 'none' sentinel option; pending-approval badge on deadline + appointment detail pages; 'Genehmigungsanfrage zurückziehen' button wired to existing Revoke endpoint; /approvals 'Meine Anfragen' visibility hardening (filter regression + inbox count badge sync).
- Slice 3 / M2 (aec6cf6): drop required_role column once writers cut over via mig 067.
- 3368aa5: renumber 064/065 → 066/067 to avoid collision with feynman's t-157 migrations 064 (users.forum_pref) + 065 (event_categories.forums) that landed first.
- 9350cd0: merge origin/main into branch to absorb feynman's slices.

Closes m's open dogfood gap from t-138 (cronus) + t-154 (hilbert): the 500 on already-pending now becomes a friendly 409, the entity detail page surfaces the pending state, the user can withdraw their own request explicitly, and /approvals lists their pending-mine entries. m's locked redesign (2026-05-08 16:40) of split + most-strict-wins shipped end-to-end. NOT cronus.)
This commit is contained in:
m
2026-05-08 17:17:25 +02:00
24 changed files with 1869 additions and 229 deletions

View File

@@ -35,6 +35,9 @@ export function renderAppointmentsDetail(): string {
<div id="appointment-body" style="display:none">
<div className="tool-header">
<span className="termin-type-badge" id="appointment-type-badge" />
<span id="appointment-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
Wartet auf Genehmigung
</span>
<h1 id="appointment-title-display" />
<p className="tool-subtitle" id="appointment-time-display" />
</div>
@@ -94,6 +97,7 @@ export function renderAppointmentsDetail(): string {
<p className="form-msg" id="appointment-edit-msg" />
<div className="form-actions">
<button type="button" id="appointment-withdraw-btn" className="btn-secondary" style="display:none" data-i18n="approvals.withdraw.cta">Genehmigungsanfrage zur&uuml;ckziehen</button>
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin l&ouml;schen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">&Auml;nderungen speichern</button>
</div>

View File

@@ -20,13 +20,16 @@ interface UnitPolicy {
project_id: string | null;
entity_type: string;
lifecycle_event: string;
required_role: string;
// t-paliad-160 split-grammar.
requires_approval: boolean;
min_role?: string | null;
}
interface EffectivePolicy {
entity_type: string;
lifecycle_event: string;
required_role?: string | null;
requires_approval: boolean;
min_role?: string | null;
source?: string | null;
source_id?: string | null;
source_name?: string | null;
@@ -43,13 +46,15 @@ interface ProjectNode {
const ENTITY_TYPES = ["deadline", "appointment"] as const;
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
// Strict-ladder roles only. The legacy "none" sentinel is gone — its job
// (suppress the gate) is now done by the requires_approval=false checkbox
// (t-paliad-160 §A).
const ROLE_OPTIONS = [
"partner",
"of_counsel",
"associate",
"senior_pa",
"pa",
"none",
];
let partnerUnits: PartnerUnit[] = [];
@@ -150,27 +155,68 @@ function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): U
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
}
function renderRoleSelect(currentValue: string | null, dataAttrs: string): string {
const opts: string[] = [];
// "Keine Regel" sentinel — distinct from 'none' (which is the explicit
// suppression value). Empty string maps to DELETE.
opts.push(`<option value=""${currentValue === null ? " selected" : ""}>${esc(t("admin.approval_policies.role.no_rule") || "— keine Regel —")}</option>`);
for (const r of ROLE_OPTIONS) {
opts.push(`<option value="${esc(r)}"${currentValue === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`);
}
return `<select class="ap-cell-select" ${dataAttrs}>${opts.join("")}</select>`;
// Cell control state, t-paliad-160 §A.
// none → no project-specific rule authored (the cell inherits).
// off → requires_approval=false explicitly authored.
// on(role) → requires_approval=true with the given min_role.
//
// rendered as: [✓] requires approval [role select]
// - checkbox unchecked → role select disabled (greyed).
// - checkbox checked → role select enabled, min_role required.
// - "no rule" — surfaced as a third button next to the controls so the
// admin can explicitly clear an authored cell back to inheritance.
type CellAuthored =
| { kind: "none" }
| { kind: "off" }
| { kind: "on"; role: string };
function authoredFromUnitPolicy(p: UnitPolicy | undefined): CellAuthored {
if (!p) return { kind: "none" };
if (!p.requires_approval) return { kind: "off" };
return { kind: "on", role: p.min_role || "associate" };
}
function authoredFromEffective(r: EffectivePolicy): CellAuthored {
if (r.source !== "project") return { kind: "none" };
if (!r.requires_approval) return { kind: "off" };
return { kind: "on", role: r.min_role || "associate" };
}
function renderCellControls(authored: CellAuthored, dataAttrs: string): string {
const checked = authored.kind === "on";
const disabled = authored.kind !== "on";
const role = authored.kind === "on" ? authored.role : "associate";
const opts = ROLE_OPTIONS.map((r) =>
`<option value="${esc(r)}"${role === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`
).join("");
const reqLabel = esc(t("admin.approval_policies.cell.requires") || "Genehmigung");
const clearLabel = esc(t("admin.approval_policies.cell.clear") || "—");
const clearTitle = esc(t("admin.approval_policies.cell.clear.title") || "Regel zurücksetzen (erben)");
const cleared = authored.kind === "none";
return `
<label class="ap-cell-toggle">
<input type="checkbox" class="ap-cell-requires" ${dataAttrs}${checked ? " checked" : ""} aria-label="${reqLabel}" />
<span class="ap-cell-toggle-label">${reqLabel}</span>
</label>
<select class="ap-cell-role" ${dataAttrs}${disabled ? " disabled" : ""}>${opts}</select>
<button type="button" class="ap-cell-clear" ${dataAttrs} title="${clearTitle}"${cleared ? " disabled" : ""}>${clearLabel}</button>
`;
}
function renderUnitMatrix(unit: PartnerUnit): string {
const rows = unitPolicies[unit.id] || [];
const buildCell = (e: string, l: string): string => {
const p = policyForCell(rows, e, l);
const authored = authoredFromUnitPolicy(p);
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
return `<div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>`;
};
let cells = "";
for (const e of ENTITY_TYPES) {
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
for (const l of LIFECYCLES) {
const p = policyForCell(rows, e, l);
const v = p ? p.required_role : null;
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
cells += `<td class="ap-matrix-cell">${renderRoleSelect(v, attrs)}</td>`;
cells += `<td class="ap-matrix-cell">${buildCell(e, l)}</td>`;
}
cells += `</tr>`;
}
@@ -180,12 +226,9 @@ function renderUnitMatrix(unit: PartnerUnit): string {
for (const e of ENTITY_TYPES) {
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
for (const l of LIFECYCLES) {
const p = policyForCell(rows, e, l);
const v = p ? p.required_role : null;
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
stacked += `<div class="ap-matrix-row">
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
${renderRoleSelect(v, attrs)}
${buildCell(e, l)}
</div>`;
}
stacked += `</div>`;
@@ -239,21 +282,39 @@ function renderUnits(): void {
function renderProjectMatrix(rows: EffectivePolicy[]): string {
const cell = (r: EffectivePolicy): string => {
const v = r.required_role || null;
const own = r.source === "project";
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
// The controls show the AUTHORED state — the project row's own values
// when there is one, else the inherited state is rendered via the
// attribution chip and the controls sit unset (kind="none"). Most-
// strict-wins inheritance from ancestors / unit defaults is purely
// informational on this row; flipping the controls writes a new
// project-specific row.
const authored = authoredFromEffective(r);
let chip = "";
if (r.source && !own && r.required_role) {
if (r.source && !own && r.requires_approval) {
// Inherited from ancestor or unit default. Surface attribution +
// the inherited min_role so the admin sees what the cell is
// resolving to before they author an override.
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
"admin.approval_policies.source.project";
const label = tDyn(sourceKey) || r.source;
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}</span>`;
const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}${role}</span>`;
} else if (r.source && !own && !r.requires_approval) {
// Inherited "no approval needed" — distinct from "no rule at all".
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
"admin.approval_policies.source.unit_default";
const label = tDyn(sourceKey) || r.source;
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
const offLabel = esc(t("admin.approval_policies.source.no_approval") || "keine Genehmigung");
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name} · ${offLabel}</span>`;
} else if (own) {
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
}
return `<div class="ap-cell-wrap">${renderRoleSelect(own ? v : null, attrs)}${chip}</div>`;
return `<div class="ap-cell-wrap"><div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>${chip}</div>`;
};
const byCell = new Map<string, EffectivePolicy>();
@@ -347,64 +408,117 @@ function renderProjectResults(filter: string): void {
// ============================================================================
function bindCellChangeHandlers(scope: HTMLElement): void {
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-select").forEach((sel) => {
sel.addEventListener("change", () => void onCellChange(sel));
// Each cell now has THREE controls — the requires-approval checkbox,
// the role select, and the explicit "clear / inherit" button. They all
// share data-* attrs so onCellChange can derive the URL + intended
// post-state from any of them.
scope.querySelectorAll<HTMLInputElement>(".ap-cell-requires").forEach((cb) => {
cb.addEventListener("change", () => {
// Toggle the sibling role select disabled state immediately for
// visual feedback — the server PUT might lag.
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
if (sel) sel.disabled = !cb.checked;
void onCellChangeFromCheckbox(cb);
});
});
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-role").forEach((sel) => {
sel.addEventListener("change", () => void onCellChangeFromRole(sel));
});
scope.querySelectorAll<HTMLButtonElement>(".ap-cell-clear").forEach((btn) => {
btn.addEventListener("click", () => void onCellClear(btn));
});
}
async function onCellChange(sel: HTMLSelectElement): Promise<void> {
const scope = sel.dataset.scope;
const entity = sel.dataset.entity || "";
const lifecycle = sel.dataset.lifecycle || "";
const value = sel.value;
let url = "";
function cellEndpointURL(el: HTMLElement): string | null {
const scope = el.dataset.scope;
const entity = el.dataset.entity || "";
const lifecycle = el.dataset.lifecycle || "";
if (scope === "unit") {
const unitID = sel.dataset.unitId || "";
url = `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
} else if (scope === "project") {
const projectID = sel.dataset.projectId || "";
url = `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
} else {
return;
const unitID = el.dataset.unitId || "";
return `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
}
if (scope === "project") {
const projectID = el.dataset.projectId || "";
return `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
}
return null;
}
async function onCellChangeFromCheckbox(cb: HTMLInputElement): Promise<void> {
// Checkbox flipped — write the cell as (requires_approval, min_role).
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
const requires = cb.checked;
const minRole = requires ? (sel?.value || "associate") : null;
await putCellSplit(cb, requires, minRole);
}
async function onCellChangeFromRole(sel: HTMLSelectElement): Promise<void> {
// Role select changed — only meaningful when the checkbox is on (the
// disabled state would block this on a real interaction, but pin it
// for safety).
const wrap = sel.closest(".ap-cell-controls") as HTMLElement | null;
const cb = wrap?.querySelector<HTMLInputElement>(".ap-cell-requires");
if (!cb || !cb.checked) return;
await putCellSplit(sel, true, sel.value);
}
async function onCellClear(btn: HTMLButtonElement): Promise<void> {
// Explicit "back to inheritance" — DELETE the project / unit row.
const url = cellEndpointURL(btn);
if (!url) return;
try {
let resp: Response;
if (value === "") {
resp = await fetch(url, { method: "DELETE" });
} else {
resp = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ required_role: value }),
});
}
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok) {
const errBody = await resp.text();
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
return;
}
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
// Re-fetch the affected scope so attribution chips reflect the new state.
if (scope === "unit") {
const unitID = sel.dataset.unitId || "";
unitPolicies[unitID] = await loadUnitPolicies(unitID);
renderUnits();
} else if (selectedProjectID) {
const matrix = await loadMatrix(selectedProjectID);
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
}
await refreshAfterCellMutation(btn);
} catch (err) {
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
}
}
async function putCellSplit(el: HTMLElement, requires: boolean, minRole: string | null): Promise<void> {
const url = cellEndpointURL(el);
if (!url) return;
try {
const resp = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requires_approval: requires, min_role: minRole }),
});
if (!resp.ok) {
const errBody = await resp.text();
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
return;
}
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
await refreshAfterCellMutation(el);
} catch (err) {
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
}
}
async function refreshAfterCellMutation(el: HTMLElement): Promise<void> {
const scope = el.dataset.scope;
if (scope === "unit") {
const unitID = el.dataset.unitId || "";
unitPolicies[unitID] = await loadUnitPolicies(unitID);
renderUnits();
} else if (selectedProjectID) {
const matrix = await loadMatrix(selectedProjectID);
const host = document.getElementById("ap-matrix-host");
if (host) {
host.innerHTML = renderProjectMatrix(matrix);
bindCellChangeHandlers(host);
}
}
}
// ============================================================================
// Bulk-apply to descendants.
// ============================================================================

View File

@@ -13,6 +13,22 @@ interface Appointment {
location?: string;
appointment_type?: string;
created_by?: string;
// t-paliad-138 + t-paliad-160 — pending-approval surface.
approval_status?: "approved" | "pending" | "legacy";
pending_request_id?: string | null;
}
interface PendingApprovalRequest {
id: string;
status: string;
requested_by: string;
requested_at: string;
required_role: string;
requester_name?: string;
}
interface Me {
id: string;
}
interface Project {
@@ -25,6 +41,8 @@ interface Project {
let appointment: Appointment | null = null;
let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -89,6 +107,31 @@ async function loadAllProjects() {
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
// loadPendingRequest mirrors deadlines-detail.ts (t-paliad-160 §C+E):
// pull the in-flight approval_request when the entity is pending so the
// badge tooltip + the Withdraw button can be wired correctly.
async function loadPendingRequest(): Promise<void> {
pendingRequest = null;
if (!appointment || appointment.approval_status !== "pending" || !appointment.pending_request_id) {
return;
}
try {
const resp = await fetch(`/api/approval-requests/${appointment.pending_request_id}`);
if (resp.ok) pendingRequest = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjectPicker() {
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
if (!sel) return;
@@ -133,6 +176,44 @@ function renderHeader() {
} else {
projectRow.style.display = "none";
}
// t-paliad-160 §C+E — pending-approval badge + withdraw + freeze controls.
const isPending = appointment.approval_status === "pending";
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
const apBadge = document.getElementById("appointment-pending-approval-badge") as HTMLElement | null;
if (apBadge) {
if (isPending) {
apBadge.style.display = "";
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
apBadge.textContent = labelDe;
if (pendingRequest) {
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
const when = fmtDateTime(pendingRequest.requested_at);
apBadge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
} else {
apBadge.title = labelDe;
}
} else {
apBadge.style.display = "none";
apBadge.title = "";
}
}
const withdrawBtn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (withdrawBtn) {
withdrawBtn.style.display = (isPending && isRequester) ? "" : "none";
withdrawBtn.disabled = false;
}
// Freeze the edit form + delete button while a request is in flight.
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
}
function fillEditForm() {
@@ -219,9 +300,9 @@ async function deleteAppointment() {
if (resp.ok || resp.status === 204) {
window.location.href = "/events?type=appointment";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.error || t("appointments.error.generic");
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
@@ -231,6 +312,40 @@ async function deleteAppointment() {
}
}
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.message || data.error || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
msg.className = "form-msg form-msg-error";
if (btn) btn.disabled = false;
}
} catch (e) {
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = (t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e;
msg.className = "form-msg form-msg-error";
if (btn) btn.disabled = false;
}
}
async function main() {
const id = parseAppointmentID();
const loading = document.getElementById("appointment-loading")!;
@@ -250,6 +365,8 @@ async function main() {
await Promise.all([
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
loadAllProjects(),
loadMe(),
loadPendingRequest(),
]);
loading.style.display = "none";
body.style.display = "";
@@ -259,6 +376,8 @@ async function main() {
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
const withdrawBtn = document.getElementById("appointment-withdraw-btn");
if (withdrawBtn) withdrawBtn.addEventListener("click", () => void withdrawAppointmentRequest());
const notes = document.getElementById("notes-container");
if (notes) {

View File

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

View File

@@ -24,6 +24,20 @@ interface Deadline {
created_at: string;
completed_at?: string;
event_type_ids?: string[];
// t-paliad-138 + t-paliad-160. approval_status='pending' means an
// approval_request is in flight; pending_request_id resolves to it
// and the controls flip to a withdraw affordance for the requester.
approval_status?: "approved" | "pending" | "legacy";
pending_request_id?: string | null;
}
interface PendingApprovalRequest {
id: string;
status: string;
requested_by: string;
requested_at: string;
required_role: string;
requester_name?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -54,6 +68,7 @@ let project: Project | null = null;
let rule: DeadlineRule | null = null;
let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -170,6 +185,23 @@ async function loadMe() {
}
}
// loadPendingRequest hydrates the in-flight approval_request when the
// entity carries approval_status='pending'. Used to populate the badge
// tooltip + decide whether to show the Withdraw button (only the
// requester can withdraw).
async function loadPendingRequest(): Promise<void> {
pendingRequest = null;
if (!deadline || deadline.approval_status !== "pending" || !deadline.pending_request_id) {
return;
}
try {
const resp = await fetch(`/api/approval-requests/${deadline.pending_request_id}`);
if (resp.ok) pendingRequest = await resp.json();
} catch {
/* non-fatal — badge still renders without the tooltip details */
}
}
function render() {
if (!deadline) return;
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
@@ -249,19 +281,49 @@ function render() {
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
const withdrawBtn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement;
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
const badge = document.getElementById("deadline-pending-approval-badge") as HTMLElement | null;
// t-paliad-160 §C+E — approval_status='pending' freezes the action
// controls and surfaces the badge + a Withdraw button (visible only to
// the requester). Other authenticated viewers see only the badge.
const isPending = deadline.approval_status === "pending";
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
if (badge) {
if (isPending) {
badge.style.display = "";
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
badge.textContent = labelDe;
// Tooltip carries requester + required_role + age (best-effort).
if (pendingRequest) {
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
const when = fmtDateTime(pendingRequest.requested_at);
badge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
} else {
badge.title = labelDe;
}
} else {
badge.style.display = "none";
badge.title = "";
}
}
// Buttons.
if (deadline.status === "completed") {
completeBtn.style.display = "none";
// Reopen is admin-gated server-side; the button is shown for global
// admins/partners here as a client-side hint. Project leads who lack a
// global admin/partner role won't see the inline button — they get a 403
// only if they try, but the button itself stays hidden. They can still
// PATCH the endpoint directly.
if (me && (me.global_role === "global_admin")) {
if (me && (me.global_role === "global_admin") && !isPending) {
reopenBtn.style.display = "";
reopenBtn.disabled = false;
} else {
reopenBtn.style.display = "none";
}
} else if (isPending) {
// Lifecycle frozen — server returns 409 to anyone who tries.
completeBtn.style.display = "none";
reopenBtn.style.display = "none";
} else {
completeBtn.style.display = "";
completeBtn.disabled = false;
@@ -269,8 +331,22 @@ function render() {
reopenBtn.style.display = "none";
}
// Edit button: hidden during pending so users don't fight a 409.
if (editBtn) editBtn.style.display = isPending ? "none" : "";
// Withdraw button: visible only when caller is the requester of the
// in-flight request.
if (withdrawBtn) {
if (isPending && isRequester) {
withdrawBtn.style.display = "";
withdrawBtn.disabled = false;
} else {
withdrawBtn.style.display = "none";
}
}
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
if (me && (me.global_role === "global_admin")) {
if (me && (me.global_role === "global_admin") && !isPending) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
@@ -377,6 +453,25 @@ function initComplete() {
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
if (resp.ok) {
deadline = await resp.json();
// The complete may have created an approval_request rather than
// completed the deadline outright (4-eye-required). Re-fetch the
// entity + pending request to surface the right state.
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else if (resp.status === 409) {
// The handler returns the t-paliad-160 §B body shape. Surface
// the human message and refresh state — likely a concurrent
// request was already in flight.
const body = await resp.json().catch(() => null);
const msg = (body && body.message) || t("approvals.error.awaiting_approval") || "Diese Anforderung wartet auf Genehmigung.";
window.alert(msg);
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) {
deadline = await fresh.json();
await loadPendingRequest();
}
render();
} else {
btn.disabled = false;
@@ -406,6 +501,48 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
}
} else {
btn.disabled = false;
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error)) || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
window.alert(msg);
}
} catch (e) {
btn.disabled = false;
window.alert((t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e);
}
});
}
function initDelete() {
const btn = document.getElementById("deadline-delete-btn")!;
const modal = document.getElementById("deadline-delete-modal")!;
@@ -455,7 +592,7 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
@@ -485,6 +622,7 @@ async function main() {
initEdit();
initComplete();
initReopen();
initWithdraw();
initDelete();
const notes = document.getElementById("notes-container");

View File

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

View File

@@ -1654,6 +1654,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.approval_policies.source.project": "Projekt",
"admin.approval_policies.source.ancestor": "Geerbt",
"admin.approval_policies.source.unit_default": "Standard",
"admin.approval_policies.source.no_approval": "keine Genehmigung",
"admin.approval_policies.cell.requires": "Genehmigung erforderlich",
"admin.approval_policies.cell.clear": "—",
"admin.approval_policies.cell.clear.title": "Regel zurücksetzen (erben)",
"admin.approval_policies.cell.saved_msg": "Gespeichert.",
"admin.approval_policies.cell.error_msg": "Fehler",
"admin.approval_policies.bulk.cta": "Auf Unterprojekte anwenden",
@@ -1972,7 +1976,12 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.",
"approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.",
"approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.",
"approvals.error.awaiting_approval": "Diese Anforderung wartet auf Genehmigung.",
"approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.",
"approvals.pending.badge": "Wartet auf Genehmigung",
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
"approvals.withdraw.error": "Fehler beim Zurückziehen",
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
@@ -3709,6 +3718,10 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.approval_policies.source.project": "Project",
"admin.approval_policies.source.ancestor": "Inherited",
"admin.approval_policies.source.unit_default": "Default",
"admin.approval_policies.source.no_approval": "no approval",
"admin.approval_policies.cell.requires": "Approval required",
"admin.approval_policies.cell.clear": "—",
"admin.approval_policies.cell.clear.title": "Reset to inheritance",
"admin.approval_policies.cell.saved_msg": "Saved.",
"admin.approval_policies.cell.error_msg": "Error",
"admin.approval_policies.bulk.cta": "Apply to descendants",
@@ -4027,7 +4040,12 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.error.not_authorized": "You don't have the required role.",
"approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.",
"approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.",
"approvals.error.awaiting_approval": "This entity is awaiting approval.",
"approvals.error.request_not_pending": "This request is no longer open.",
"approvals.pending.badge": "Awaiting approval",
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
"approvals.withdraw.error": "Failed to withdraw",
"approvals.pending_create.label": "Awaits approval (creation)",
"approvals.pending_update.label": "Awaits approval (change)",
"approvals.pending_complete.label": "Awaits approval (completion)",

View File

@@ -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.
}

View File

@@ -43,6 +43,9 @@ export function renderDeadlinesDetail(): string {
<div className="entity-detail-meta">
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />
<span id="deadline-pending-approval-badge" className="approval-pending-badge" style="display:none" data-i18n="approvals.pending.badge" title="">
Wartet auf Genehmigung
</span>
<a id="deadline-project-link" className="entity-ref" href="#" />
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
</div>
@@ -54,6 +57,9 @@ export function renderDeadlinesDetail(): string {
<button id="deadline-reopen-btn" type="button" className="btn-primary btn-cta-lime btn-small" style="display:none" data-i18n="deadlines.detail.reopen">
Wieder &ouml;ffnen
</button>
<button id="deadline-withdraw-btn" type="button" className="btn-secondary btn-small" style="display:none" data-i18n="approvals.withdraw.cta">
Genehmigungsanfrage zur&uuml;ckziehen
</button>
<button id="deadline-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="deadlines.detail.edit" title="Bearbeiten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />

View File

@@ -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"

View File

@@ -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. */

View File

@@ -0,0 +1,77 @@
-- Reverse t-paliad-160 M1: drop the new columns + restore the previous
-- paliad.approval_policy_effective() shape from migration 062.
--
-- M1 is additive in code (dual-read), so this down migration restores the
-- previous resolver semantics (project row wins outright, MAX(level) over
-- ancestors+unit defaults). The required_role column was never dropped
-- in M1 so the legacy values are still the source of truth.
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
required_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
RETURN QUERY
SELECT ap.required_role, 'project'::text AS source, ap.project_id AS source_id
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle;
IF FOUND THEN
RETURN;
END IF;
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
ancestor_rows AS (
SELECT ap.required_role,
'ancestor'::text AS src,
ap.project_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.required_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
paliad.approval_role_level(ap.required_role) AS lvl
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
)
SELECT a.required_role, a.src, a.sid
FROM (
SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows
) AS a
ORDER BY a.lvl DESC, a.src ASC
LIMIT 1;
END;
$$;
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_min_role_xor_required;
ALTER TABLE paliad.approval_policies
DROP CONSTRAINT IF EXISTS approval_policies_min_role_check;
ALTER TABLE paliad.approval_policies
DROP COLUMN IF EXISTS requires_approval,
DROP COLUMN IF EXISTS min_role;

View File

@@ -0,0 +1,237 @@
-- t-paliad-160 (M1, slice 1): split approval_policies.required_role into
-- two columns — requires_approval (the gate) + min_role (the seniority
-- threshold). The legacy required_role='none' sentinel conflated two
-- concepts: "approval applies at all" vs "who can approve". This
-- migration introduces the split and backfills.
--
-- M1 = additive + dual-read. New code paths read both old and new columns
-- so a rollback to pre-deploy code keeps working. M2 (follow-up
-- migration) will drop required_role once everything writes the new
-- shape exclusively.
--
-- Resolver semantics also change with this split: when both a project-
-- level row and a partner-unit-default row resolve for the same
-- (entity_type, lifecycle_event), most-strict-wins now applies on BOTH
-- axes:
-- - requires_approval: OR (true if either side says true).
-- - min_role: MAX along approval_role_level().
-- That update lives in the paliad.approval_policy_effective() rewrite
-- in §4 below.
--
-- Sections:
-- 1. ALTER paliad.approval_policies ADD COLUMN requires_approval + min_role.
-- 2. Backfill: required_role='none' → (false, NULL); else → (true, role).
-- 3. Constraint: (requires_approval=false) OR (min_role IS NOT NULL).
-- 4. Replace paliad.approval_policy_effective() with most-strict-wins
-- across the new columns. Returns (requires_approval, min_role,
-- source, source_id) — back-compat shim required_role column kept
-- in result type so callers reading the old column don't break
-- until they cut over.
-- ============================================================================
-- 1. New columns. Both nullable in the schema; the constraint in §3
-- enforces the relationship instead of a NOT NULL on requires_approval
-- (we want Postgres to keep the row out cleanly when min_role is NULL
-- and requires_approval = false).
-- ============================================================================
ALTER TABLE paliad.approval_policies
ADD COLUMN requires_approval boolean,
ADD COLUMN min_role text;
-- ============================================================================
-- 2. Backfill from required_role.
-- 'none' → (false, NULL)
-- else (any of partner/of_counsel/associate/senior_pa/pa) → (true, role)
-- ============================================================================
UPDATE paliad.approval_policies
SET requires_approval = false,
min_role = NULL
WHERE required_role = 'none';
UPDATE paliad.approval_policies
SET requires_approval = true,
min_role = required_role
WHERE required_role <> 'none';
-- After backfill every row has a non-NULL requires_approval. Tighten.
ALTER TABLE paliad.approval_policies
ALTER COLUMN requires_approval SET NOT NULL;
-- ============================================================================
-- 3. The split-grammar invariant: a row that demands approval must name
-- a min_role; a row that does not demand approval has min_role NULL.
-- ============================================================================
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_min_role_xor_required CHECK (
(requires_approval = false AND min_role IS NULL)
OR
(requires_approval = true AND min_role IS NOT NULL)
);
-- min_role values mirror the approval ladder. NULL is allowed (the
-- requires_approval=false branch); any other value must be on the ladder.
ALTER TABLE paliad.approval_policies
ADD CONSTRAINT approval_policies_min_role_check CHECK (
min_role IS NULL OR min_role IN (
'partner', 'of_counsel', 'associate', 'senior_pa', 'pa'
)
);
-- ============================================================================
-- 4. paliad.approval_policy_effective — most-strict-wins resolver.
--
-- Returns at most one row, or zero rows when no policy applies. The result
-- shape adds two columns (requires_approval, min_role) while keeping the
-- legacy required_role column for back-compat dual-read. Old callers that
-- still read required_role keep working; new callers branch on
-- requires_approval/min_role.
--
-- Resolution:
-- Step 1 — collect candidates for (project, entity, lifecycle):
-- a) project-specific row (project_id = p_project_id).
-- b) ancestor rows on the project's ltree path (excluding self).
-- c) unit-default rows for partner units attached to this project.
-- Step 2 — most-strict-wins over the union:
-- requires_approval := bool_or(c.requires_approval) -- true if any says true
-- min_role := role with MAX(approval_role_level) among the
-- candidates whose requires_approval=true.
-- NULL if no candidate demands approval.
-- Step 3 — the project-specific row no longer wins outright. The 'none'
-- sentinel is gone; suppression is now expressed as an explicit
-- requires_approval=false at project level, which loses to any
-- ancestor / unit_default with requires_approval=true under
-- most-strict-wins. This is intentional: the user-locked semantics
-- is "tighten only, never loosen by inheritance" and the project
-- row that wants to relax inherited rules has to be authored at the
-- ancestor / unit level instead. (See t-paliad-160 §A resolver lock.)
--
-- Returned columns:
-- requires_approval boolean — the gate
-- min_role text — the threshold (NULL when gate is off)
-- required_role text — back-compat: NULL when gate is off,
-- else equals min_role. Old callers that
-- read required_role keep working until
-- M2 drops the column.
-- source text — 'project' | 'ancestor' | 'unit_default'
-- (the source of the WINNING min_role; for
-- a pure requires_approval=false result,
-- the source of the highest-priority
-- 'false' row in the order project >
-- ancestor > unit_default).
-- source_id uuid — project_id or partner_unit_id of source.
-- ============================================================================
DROP FUNCTION IF EXISTS paliad.approval_policy_effective(uuid, text, text);
CREATE FUNCTION paliad.approval_policy_effective(
p_project_id uuid,
p_entity_type text,
p_lifecycle text
) RETURNS TABLE (
requires_approval boolean,
min_role text,
required_role text,
source text,
source_id uuid
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
RETURN QUERY
WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = p_project_id
),
project_rows AS (
SELECT ap.requires_approval,
ap.min_role,
'project'::text AS src,
ap.project_id AS sid,
1 AS src_priority
FROM paliad.approval_policies ap
WHERE ap.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
ancestor_rows AS (
SELECT ap.requires_approval,
ap.min_role,
'ancestor'::text AS src,
ap.project_id AS sid,
2 AS src_priority
FROM paliad.approval_policies ap, path
WHERE ap.project_id = ANY(path.ids)
AND ap.project_id <> p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
unit_rows AS (
SELECT ap.requires_approval,
ap.min_role,
'unit_default'::text AS src,
ap.partner_unit_id AS sid,
3 AS src_priority
FROM paliad.approval_policies ap
JOIN paliad.project_partner_units ppu
ON ppu.partner_unit_id = ap.partner_unit_id
WHERE ppu.project_id = p_project_id
AND ap.entity_type = p_entity_type
AND ap.lifecycle_event = p_lifecycle
),
candidates AS (
SELECT * FROM project_rows
UNION ALL
SELECT * FROM ancestor_rows
UNION ALL
SELECT * FROM unit_rows
),
-- Pick the strictest min_role: highest approval_role_level among the
-- requires_approval=true candidates. Tie-break: project > ancestor >
-- unit_default for stable attribution.
strictest_role AS (
SELECT c.min_role,
c.src AS source,
c.sid AS source_id
FROM candidates c
WHERE c.requires_approval = true
AND c.min_role IS NOT NULL
ORDER BY paliad.approval_role_level(c.min_role) DESC,
c.src_priority ASC
LIMIT 1
),
-- If nothing demands approval, surface the project row's "no approval"
-- if present, else any (false) row with stable tie-break, so attribution
-- still works for the UI ("inherited from <unit>").
no_approval_attribution AS (
SELECT c.src AS source, c.sid AS source_id
FROM candidates c
WHERE c.requires_approval = false
ORDER BY c.src_priority ASC
LIMIT 1
),
summary AS (
SELECT bool_or(c.requires_approval) AS req
FROM candidates c
)
SELECT
COALESCE(s.req, false) AS requires_approval,
sr.min_role AS min_role,
sr.min_role AS required_role,
COALESCE(sr.source, na.source) AS source,
COALESCE(sr.source_id, na.source_id) AS source_id
FROM summary s
LEFT JOIN strictest_role sr ON true
LEFT JOIN no_approval_attribution na ON true
WHERE EXISTS (SELECT 1 FROM candidates); -- zero rows when no policy applies
END;
$$;
COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
'Effective approval policy resolver (t-paliad-160 most-strict-wins). '
'Returns requires_approval (OR across candidates), min_role (MAX along '
'the role ladder among requires_approval=true candidates), and the '
'source attribution. required_role mirrors min_role for back-compat '
'dual-read with code that hasn''t cut over yet. Zero rows when no '
'policy candidates exist for the (project, entity_type, lifecycle).';

View File

@@ -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;
$$;

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -0,0 +1,113 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"mgit.msbls.de/m/paliad/internal/services"
)
// Pins t-paliad-160 §B: mapApprovalError must surface ErrConcurrentPending
// as a 409 with code=awaiting_approval, and PendingApprovalError must
// additionally carry the request_id + required_role so the UI can offer a
// withdraw button.
func TestMapApprovalError_ConcurrentPending409(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrConcurrentPending) {
t.Fatal("mapApprovalError returned false for ErrConcurrentPending")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["code"] != "awaiting_approval" {
t.Errorf("code = %q, want awaiting_approval", body["code"])
}
if _, ok := body["request_id"]; ok {
t.Errorf("bare ErrConcurrentPending should not carry request_id, got %q", body["request_id"])
}
}
func TestMapApprovalError_PendingApprovalErrorCarriesRequestID(t *testing.T) {
w := httptest.NewRecorder()
pe := services.NewPendingApprovalError("11111111-2222-3333-4444-555555555555", "associate")
if !mapApprovalError(w, pe) {
t.Fatal("mapApprovalError returned false for PendingApprovalError")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["code"] != "awaiting_approval" {
t.Errorf("code = %q, want awaiting_approval", body["code"])
}
if body["request_id"] != "11111111-2222-3333-4444-555555555555" {
t.Errorf("request_id = %q, want the wrapped uuid", body["request_id"])
}
if body["required_role"] != "associate" {
t.Errorf("required_role = %q, want associate", body["required_role"])
}
}
func TestMapApprovalError_NoQualifiedApprover409(t *testing.T) {
w := httptest.NewRecorder()
if !mapApprovalError(w, services.ErrNoQualifiedApprover) {
t.Fatal("mapApprovalError returned false for ErrNoQualifiedApprover")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", w.Code)
}
var body map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body["code"] != "no_qualified_approver" {
t.Errorf("code = %q, want no_qualified_approver", body["code"])
}
}
func TestMapApprovalError_MissReturnsFalse(t *testing.T) {
w := httptest.NewRecorder()
if mapApprovalError(w, services.ErrInvalidInput) {
t.Error("mapApprovalError matched ErrInvalidInput; that's writeServiceError's job")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (recorder default — nothing written)", w.Code)
}
}
// TestParseInboxFilter_DropsUnknownStatus pins t-paliad-160 §D regression
// hardening: a stray ?status=foo from a stale frontend build (or an
// attacker scoping us out of our own list) must NOT shadow rows out of
// the result set. The handler silently drops anything not on the allowlist.
func TestParseInboxFilter_DropsUnknownStatus(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"", ""},
{"pending", "pending"},
{"approved", "approved"},
{"rejected", "rejected"},
{"revoked", "revoked"},
{"superseded", "superseded"},
{"foo", ""}, // unknown — dropped
{"DROP+TABLE", ""}, // hostile — dropped
{"PENDING", ""}, // case mismatch — dropped (we don't normalise)
}
for _, tc := range cases {
t.Run(tc.raw, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/inbox/mine?status="+tc.raw, nil)
f := parseInboxFilter(req)
if f.Status != tc.want {
t.Errorf("parseInboxFilter(%q).Status = %q, want %q", tc.raw, f.Status, tc.want)
}
})
}
}

View File

@@ -79,6 +79,9 @@ func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
// writeServiceError maps a services error to an HTTP status.
func writeServiceError(w http.ResponseWriter, err error) {
if mapApprovalError(w, err) {
return
}
switch {
case errors.Is(err, services.ErrNotVisible):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
@@ -94,6 +97,72 @@ func writeServiceError(w http.ResponseWriter, err error) {
}
}
// mapApprovalError handles approval-flow errors that bubble through the
// shared writeServiceError path (entity mutation handlers — deadlines,
// appointments — go through writeServiceError, not writeApprovalError).
//
// Returns true iff err matched an approval-flow case and the response
// has been written. False = caller should keep walking the switch.
//
// Response shape (t-paliad-160 §B):
//
// {
// code: "awaiting_approval" | "no_qualified_approver" | ...,
// message: "<localizable German hint>",
// request_id?: "<uuid>", // present when known
// required_role?: "<role>", // present when known
// }
func mapApprovalError(w http.ResponseWriter, err error) bool {
var pendingErr *services.PendingApprovalError
if errors.As(err, &pendingErr) {
body := map[string]string{
"code": "awaiting_approval",
"message": "Diese Anforderung wartet auf Genehmigung.",
}
if pendingErr.RequestID != "" {
body["request_id"] = pendingErr.RequestID
}
if pendingErr.RequiredRole != "" {
body["required_role"] = pendingErr.RequiredRole
}
writeJSON(w, http.StatusConflict, body)
return true
}
switch {
case errors.Is(err, services.ErrConcurrentPending):
writeJSON(w, http.StatusConflict, map[string]string{
"code": "awaiting_approval",
"message": "Diese Anforderung wartet auf Genehmigung.",
})
return true
case errors.Is(err, services.ErrNoQualifiedApprover):
writeJSON(w, http.StatusConflict, map[string]string{
"code": "no_qualified_approver",
"message": "Es gibt keinen anderen Benutzer, der diese Anfrage genehmigen kann.",
})
return true
case errors.Is(err, services.ErrSelfApproval):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "self_approval_blocked",
"message": "Selbst-Genehmigung ist nicht erlaubt.",
})
return true
case errors.Is(err, services.ErrNotApprover):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "not_authorized",
"message": "Sie sind für diese Genehmigung nicht berechtigt.",
})
return true
case errors.Is(err, services.ErrRequestNotPending):
writeJSON(w, http.StatusConflict, map[string]string{
"code": "request_not_pending",
"message": "Die Anfrage ist nicht mehr offen.",
})
return true
}
return false
}
// GET /api/projects — list visible projects.
// Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo
func handleListProjects(w http.ResponseWriter, r *http.Request) {

View File

@@ -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

View File

@@ -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)

View File

@@ -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}
}

View File

@@ -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 }

View File

@@ -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))
}
}

View File

@@ -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)