Compare commits

...

4 Commits

Author SHA1 Message Date
mAi
aa82434af9 fix(t-paliad-202): grey out inbox actions instead of erroring on illegal click
m's UX bug (2026-05-17, paliad.de prod): clicking Genehmigen/Ablehnen/
Zurückziehen on a row the viewer can't act on alerted ("Eigengenehmigung
nicht zulässig.", "Sie haben nicht die erforderliche Rolle.") after the
POST round-trip. m's ask: "approval that i cannot grant should have the
'Genehmigen' button greyed out... that would be better than showing an
error when I try."

Backend (internal/services/approval_service.go):
- ApprovalRequestView gains viewer_can_approve + viewer_is_requester
  booleans. Resolved server-side per caller — false on self-authored rows
  (caller == requester), true when the eligibility predicate matches.
- Extract the eligibility EXISTS-block into approvalEligibilitySQL const
  and reuse it in ListPendingForApprover (WHERE), PendingCountForUser
  (WHERE), and the new viewer_can_approve SELECT expression. Single
  source of truth for the gate, identical to canApprove.
- ListPendingForApprover, ListSubmittedByUser, and GetRequest all bind
  $1 = callerID so the SELECT computes the flags inline (one query, no
  N+1). GetRequest's signature grows a callerID arg; the handler passes
  the authenticated user.

Frontend (frontend/src/client/views/shape-list.ts):
- ApprovalDetail picks up the two booleans (optional — falsy is safe:
  it disables, never falsely enables).
- approvalActionBtn renders the button as before but flips
  btn.disabled + sets a tooltip via disabledReasonFor: approve/reject
  share the viewer_can_approve gate (self → self_approval tooltip;
  unauthorized → not_authorized); revoke needs viewer_is_requester.
- All three buttons still render on every pending row so users see
  what's possible — the disabled+tooltip combo explains what's not.

i18n + CSS:
- 3 new keys × DE/EN: approvals.disabled.{self_approval,
  not_authorized,revoke_not_requester}.
- .inbox-row-action:disabled neutralises the .btn-primary/danger/
  secondary variant via opacity + not-allowed + muted tokens.

Tests:
- internal/services/approval_service_test.go::TestApprovalService_ViewerFlags
  is a 4-case table-driven live-DB test (skips without TEST_DATABASE_URL):
  self-authored (false/true), eligible peer (true/false), non-eligible
  viewer (false/false), global_admin (true/false). Also asserts the flags
  on ListPendingForApprover + ListSubmittedByUser rows.

Defence-in-depth preserved: server still rejects illegal POSTs with the
same error contract, and the alert path stays in inbox.ts for the race
where state changes between render and click.
2026-05-17 12:44:29 +02:00
mAi
4f66feffce Merge: fix(projects) — unbreak Create + 6-digit CM constraint 2026-05-17 12:30:58 +02:00
mAi
bdd4999213 fix(projects): unbreak Create — drop $1::text reuse + tighten CM CHECK to 6 digits
Two issues m hit and reported in one breath while adding a project:

1. **Internal error on POST /projects** (prod-only, surfaced at 10:23). Both
   ProjectService.Create and CreateCounterclaim re-referenced the uuid
   parameter `$1` as `$1::text` to fill the path placeholder. Postgres'
   planner deduced conflicting types for `$1` (uuid in the id column,
   text in the cast) and rejected the prepared statement with 42P08
   "inconsistent types deduced for parameter". The path placeholder
   value is irrelevant — paliad.projects_sync_path() (BEFORE INSERT
   trigger from mig 018/021) always overwrites it from id and parent
   path. Fix: replace `$1::text` with a literal '' in both INSERTs,
   keeping the parameter list decoupled from the id column's type.
   Same comment now anchors the rationale on both call sites.

2. **CM number length — 6 digits, not 7.** m's correction; mig 018's
   `^[0-9]{7}$` CHECK on paliad.projects.client_number and
   matter_number was wrong. Mig 094 snapshots affected rows to
   paliad.projects_pre_094, NULL-s the 3 surviving 7-digit test
   values (2 client_numbers, 1 matter_number), then swaps the legacy
   `projekte_*_check` constraints from {7} to {6}. Frontend pattern,
   maxLength, placeholder, labels, and i18n hint flipped from 7 → 6
   on both DE and EN sides; format hint reads CCCCCC.MMMMMM now.

Dry-run against live DB (BEGIN..ROLLBACK):
- Fixed Create SQL: trigger populates path = id::text (36 chars). ✓
- Mig 094: 2 rows snapshotted, 0 clients/matters remain after clear,
  0 rows violate the new 6-digit CHECK. ✓

go build, go test ./internal/..., bun run build all clean.
2026-05-17 12:30:53 +02:00
mAi
cbcc67bae7 Merge: t-paliad-200 — Slice 9 follow-up B (archive 40 Pipeline-A litigation rules, drop 7 litigation proceeding_types — Phase 3 closeout complete) 2026-05-16 01:30:03 +02:00
11 changed files with 433 additions and 89 deletions

View File

@@ -1149,9 +1149,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.title.placeholder": "z.B. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projects.field.reference": "Interne Referenz (optional)",
"projects.field.reference.placeholder": `z.B. ${FIRM}-2026-0042`,
"projects.field.client_number": "Client-Nr. (7 Ziffern)",
"projects.field.matter_number": "Matter-Nr. (7 Ziffern)",
"projects.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projects.field.client_number": "Client-Nr. (6 Ziffern)",
"projects.field.matter_number": "Matter-Nr. (6 Ziffern)",
"projects.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projects.field.billing_reference": "Billing-Referenz (optional)",
"projects.field.netdocuments_url": "netDocuments-URL (optional)",
"projects.field.industry": "Branche",
@@ -2201,6 +2201,9 @@ const translations: Record<Lang, Record<string, string>> = {
"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.disabled.self_approval": "Du kannst eigene Anträge nicht genehmigen",
"approvals.disabled.not_authorized": "Du hast keine Genehmigungsberechtigung für diesen Antrag",
"approvals.disabled.revoke_not_requester": "Nur der Antragsteller kann zurückziehen",
"approvals.pending.badge": "Wartet auf Genehmigung",
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
@@ -3698,9 +3701,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.field.title.placeholder": "e.g. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projects.field.reference": "Internal reference (optional)",
"projects.field.reference.placeholder": `e.g. ${FIRM}-2026-0042`,
"projects.field.client_number": "Client no. (7 digits)",
"projects.field.matter_number": "Matter no. (7 digits)",
"projects.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCCC.MMMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projects.field.client_number": "Client no. (6 digits)",
"projects.field.matter_number": "Matter no. (6 digits)",
"projects.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCC.MMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projects.field.billing_reference": "Billing reference (optional)",
"projects.field.netdocuments_url": "netDocuments URL (optional)",
"projects.field.industry": "Industry",
@@ -4746,6 +4749,9 @@ const translations: Record<Lang, Record<string, string>> = {
"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.disabled.self_approval": "You cannot approve your own requests",
"approvals.disabled.not_authorized": "You are not authorized to approve this request",
"approvals.disabled.revoke_not_requester": "Only the requester can withdraw",
"approvals.pending.badge": "Awaiting approval",
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",

View File

@@ -196,6 +196,12 @@ interface ApprovalDetail {
requester_kind?: "user" | "agent";
decider_name?: string;
decision_note?: string;
// Per-viewer eligibility flags resolved server-side against the caller
// (t-paliad-202). Used to grey out actions the server would reject.
// Optional so an older payload still renders — falsy means "treat as
// disabled" for the safety side (no false enables).
viewer_can_approve?: boolean;
viewer_is_requester?: boolean;
}
function renderApprovalList(rows: ViewRow[]): HTMLElement {
@@ -256,13 +262,15 @@ function renderApprovalList(rows: ViewRow[]): HTMLElement {
actions.className = "inbox-row-actions";
if (detail.status === "pending") {
// The bar's approval_viewer_role distinguishes which actions are
// appropriate. The surface inspects the active role and decides
// which buttons to keep — but for default rendering we stamp all
// three with role-class hints and let the surface filter.
actions.appendChild(actionBtn("approve"));
actions.appendChild(actionBtn("reject"));
actions.appendChild(actionBtn("revoke"));
// All three actions are stamped on every pending row; the per-viewer
// viewer_can_approve / viewer_is_requester flags (resolved server-side)
// decide which are enabled vs. greyed out with a tooltip. m's ask
// (2026-05-17): show what's possible but disable what isn't, rather
// than alert-after-click. The server still enforces — disabled buttons
// are a UI hint, not a security gate.
actions.appendChild(approvalActionBtn("approve", detail));
actions.appendChild(approvalActionBtn("reject", detail));
actions.appendChild(approvalActionBtn("revoke", detail));
} else if (detail.status) {
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
@@ -312,16 +320,39 @@ function renderDiff(detail: ApprovalDetail): HTMLElement | null {
return wrap;
}
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
function approvalActionBtn(
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
// approve / reject share the eligibility gate; revoke is requester-only.
const reason = disabledReasonFor(action, detail);
if (reason) {
btn.disabled = true;
btn.title = t(reason);
}
return btn;
}
function disabledReasonFor(
action: "approve" | "reject" | "revoke",
detail: ApprovalDetail,
): I18nKey | null {
if (action === "revoke") {
return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester";
}
// approve + reject — same gate as the server's canApprove.
if (detail.viewer_can_approve) return null;
if (detail.viewer_is_requester) return "approvals.disabled.self_approval";
return "approvals.disabled.not_authorized";
}
function formatRelativeTime(iso: string): string {
const t0 = Date.parse(iso);
if (isNaN(t0)) return iso;

View File

@@ -64,28 +64,28 @@ export function ProjectFormFields(): string {
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (7 Ziffern)</label>
<label htmlFor="project-client-number" data-i18n="projects.field.client_number">Client-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-client-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0001234"
pattern="[0-9]{6}"
maxLength={6}
placeholder="001234"
/>
</div>
<div className="form-field">
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (7 Ziffern)</label>
<label htmlFor="project-matter-number" data-i18n="projects.field.matter_number">Matter-Nr. (6 Ziffern)</label>
<input
type="text"
id="project-matter-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0000567"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000567"
/>
</div>
</div>
<p className="form-hint" data-i18n="projects.field.clientmatter.hint">
{`${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
{`${FIRM}-Billing-Nummern. Format CCCCCC.MMMMMM. Client-Nr. wird an Unterprojekte vererbt
(überschreibbar).`}
</p>

View File

@@ -591,6 +591,9 @@ export type I18nKey =
| "approvals.decision_kind.peer"
| "approvals.diff.after"
| "approvals.diff.before"
| "approvals.disabled.not_authorized"
| "approvals.disabled.revoke_not_requester"
| "approvals.disabled.self_approval"
| "approvals.empty.mine"
| "approvals.empty.pending_mine"
| "approvals.entity.appointment"

View File

@@ -11456,6 +11456,24 @@ dialog.quick-add-sheet::backdrop {
font-size: 13px;
}
/* Greyed-out variant for actions the current viewer can't grant —
* approve/reject without authority, revoke when not the requester
* (t-paliad-202). Click is suppressed by the disabled attribute; the
* tooltip on `title` explains why. The neutral background/colour pair
* overrides .btn-primary/.btn-danger/.btn-secondary so all three
* variants look the same when disabled. */
.inbox-row-action:disabled {
cursor: not-allowed;
opacity: 0.55;
background: var(--color-surface-2);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.inbox-row-action:disabled:hover {
background: var(--color-surface-2);
border-color: var(--color-border);
}
.inbox-row-decided {
color: var(--fg-muted);
font-size: 12px;

View File

@@ -0,0 +1,32 @@
-- mig 094 DOWN — restore the 7-digit CHECK and the snapshotted
-- pre-clear client_number / matter_number values from
-- paliad.projects_pre_094. Symmetric to the up migration.
SELECT set_config(
'paliad.audit_reason',
'mig 094 DOWN: restore 7-digit CHECK and pre-094 client_number/matter_number values from snapshot',
true);
-- 1. Drop the 6-digit CHECKs.
ALTER TABLE paliad.projects
DROP CONSTRAINT projekte_client_number_check,
DROP CONSTRAINT projekte_matter_number_check;
-- 2. Restore the original values from the snapshot. Only rows that
-- existed at snapshot time are touched; rows added since stay as
-- they were.
UPDATE paliad.projects p
SET client_number = s.client_number,
matter_number = s.matter_number
FROM paliad.projects_pre_094 s
WHERE p.id = s.id;
-- 3. Re-add the legacy 7-digit CHECKs.
ALTER TABLE paliad.projects
ADD CONSTRAINT projekte_client_number_check
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{7}$'),
ADD CONSTRAINT projekte_matter_number_check
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{7}$');
-- 4. Drop the snapshot. The down migration is the only consumer.
DROP TABLE IF EXISTS paliad.projects_pre_094;

View File

@@ -0,0 +1,97 @@
-- mig 094 — tighten paliad.projects.client_number + matter_number CHECK
-- from 7-digit to 6-digit. The "7-Ziffern" rule in mig 018 was wrong;
-- HLC's real Client/Matter format is 6 digits each (m's correction,
-- 2026-05-17). The constraints carry the legacy 'projekte_*_check'
-- name from before the table was renamed (mig 021), so the ALTER
-- TABLE DROP / ADD has to use those names verbatim.
--
-- Existing rows: only test data (2 client_numbers, 1 matter_number),
-- all 7-digit. They violate the new pattern, so we NULL them out
-- before tightening — preserving the project rows themselves, just
-- clearing the wrong-shaped billing identifiers. The rows are
-- snapshotted in projects_pre_094 first so the down migration can
-- restore them byte-identically.
--
-- audit_reason wrapper at top: the trigger on paliad.projects logs
-- every row-level UPDATE; the message persists in the audit table as
-- the permanent record of why those test values were cleared.
SELECT set_config(
'paliad.audit_reason',
'mig 094: clear test 7-digit client_number/matter_number values before tightening CHECK to 6-digit (HLC real format correction, 2026-05-17)',
true);
-- =============================================================================
-- 1. Backup snapshot. Full row copy of every paliad.projects row that
-- has either field populated. Idempotent via CREATE TABLE IF NOT
-- EXISTS — re-running the migration after an aborted run re-uses
-- the existing snapshot.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.projects_pre_094 AS
SELECT *, now() AS snapshotted_at
FROM paliad.projects
WHERE client_number IS NOT NULL OR matter_number IS NOT NULL;
COMMENT ON TABLE paliad.projects_pre_094 IS
'Snapshot of paliad.projects rows that had a client_number or '
'matter_number set before mig 094 tightened the CHECK from '
'7-digit to 6-digit. The 094 UPDATE NULL-ed those values out '
'because they were leftover 7-digit test data. Persists as the '
'permanent audit anchor; the down migration restores from it.';
-- =============================================================================
-- 2. Clear the 7-digit test values. Only rows that already violate
-- the new pattern are touched — anything that happens to already
-- be 6 digits (none today, but the WHERE keeps the migration
-- re-runnable after future inserts) is left alone.
-- =============================================================================
UPDATE paliad.projects
SET client_number = NULL
WHERE client_number IS NOT NULL
AND client_number !~ '^[0-9]{6}$';
UPDATE paliad.projects
SET matter_number = NULL
WHERE matter_number IS NOT NULL
AND matter_number !~ '^[0-9]{6}$';
-- =============================================================================
-- 3. Replace the legacy 7-digit CHECKs with 6-digit ones. The
-- constraint names carry the pre-rename `projekte_*` prefix from
-- mig 018; keep them stable so external audit tools that scan
-- pg_constraint by name don't drift.
-- =============================================================================
ALTER TABLE paliad.projects
DROP CONSTRAINT projekte_client_number_check,
DROP CONSTRAINT projekte_matter_number_check;
ALTER TABLE paliad.projects
ADD CONSTRAINT projekte_client_number_check
CHECK (client_number IS NULL OR client_number ~ '^[0-9]{6}$'),
ADD CONSTRAINT projekte_matter_number_check
CHECK (matter_number IS NULL OR matter_number ~ '^[0-9]{6}$');
-- =============================================================================
-- 4. Hard assertions. Any row that survived the UPDATE+ALTER must
-- satisfy the new pattern; the count of cleared test rows must
-- match the snapshot.
-- =============================================================================
DO $$
DECLARE
n_violations int;
BEGIN
SELECT count(*) INTO n_violations
FROM paliad.projects
WHERE (client_number IS NOT NULL AND client_number !~ '^[0-9]{6}$')
OR (matter_number IS NOT NULL AND matter_number !~ '^[0-9]{6}$');
IF n_violations > 0 THEN
RAISE EXCEPTION 'mig 094: % rows still violate the 6-digit pattern after UPDATE — should be 0', n_violations;
END IF;
RAISE NOTICE 'mig 094: 6-digit CHECKs in place, all rows compliant';
END $$;

View File

@@ -281,7 +281,8 @@ func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
@@ -289,7 +290,7 @@ func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
row, err := dbSvc.approval.GetRequest(r.Context(), requestID)
row, err := dbSvc.approval.GetRequest(r.Context(), uid, requestID)
if err != nil {
writeServiceError(w, err)
return

View File

@@ -809,16 +809,67 @@ func marshalJSONOrNull(m map[string]any) ([]byte, error) {
// ApprovalRequestView is the inbox-friendly projection of an approval
// request: the bare ApprovalRequest plus the contextual labels the inbox
// needs to render a row without further fetches.
//
// ViewerCanApprove + ViewerIsRequester are per-viewer eligibility flags
// computed against the $1 callerID bound at query time (t-paliad-202).
// The frontend uses them to grey out the action buttons it knows the
// server would reject, replacing the previous click-then-alert UX.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"`
ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"`
}
// approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean
// expression that returns true iff the user bound to $1 is qualified to
// approve the approval_requests row aliased `ar` on the project aliased
// `p` (i.e. the SELECT must include `paliad.approval_requests ar JOIN
// paliad.projects p ON p.id = ar.project_id`). The three eligibility
// branches mirror canApprove (line 484):
//
// - $1 is global_admin, OR
// - $1 has direct/ancestor project_teams membership with responsibility
// ∈ {lead, member} AND a profession at or above the threshold
// (t-paliad-148 tuple-with-gate), OR
// - $1 has partner-unit-derived authority (t-paliad-139).
//
// Self-authorship is NOT subtracted here — callers add the
// `ar.requested_by <> $1` predicate when they want the strict
// "can approve" semantics (the inbox WHERE) or fold it into the
// SELECT (viewer_can_approve column). Keeping the two predicates
// separate lets the same fragment serve both ListPendingForApprover's
// filter and the per-row viewer flag without duplicating SQL.
const approvalEligibilitySQL = `(
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
)
)`
// approvalRequestViewColumns binds $1 = callerID via the two viewer_*
// flags. Every caller must pass the caller's UUID as the first arg.
const approvalRequestViewColumns = `
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
@@ -832,7 +883,9 @@ const approvalRequestViewColumns = `
COALESCE(ru.display_name, ru.email) AS requester_name,
ru.email AS requester_email,
du.display_name AS decider_name,
du.email AS decider_email`
du.email AS decider_email,
(ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve,
(ar.requested_by = $1) AS viewer_is_requester`
const approvalRequestViewJoins = `
paliad.approval_requests ar
@@ -860,34 +913,10 @@ func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID u
conds := []string{
"ar.status = 'pending'",
"ar.requested_by <> $1",
// Eligibility (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams membership with
// responsibility ∈ {lead, member} AND profession at or above
// the threshold (t-paliad-148 tuple-with-gate), OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// profession at or above the threshold (t-paliad-139).
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`,
// Eligibility predicate (the three branches mirror canApprove and
// the viewer_can_approve SELECT expression — same fragment, single
// source of truth).
approvalEligibilitySQL,
}
args := []any{callerID}
if filter.ProjectID != nil {
@@ -946,13 +975,15 @@ func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid
}
// GetRequest returns one approval request hydrated for the inbox detail
// view. Visibility is gated upstream by the handler (anyone with project
// access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
// view, with viewer_can_approve / viewer_is_requester resolved for
// callerID. Visibility is gated upstream by the handler (anyone with
// project access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, callerID, requestID uuid.UUID) (*ApprovalRequestView, error) {
// $1 = callerID (binds the viewer_* flags); $2 = requestID.
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $2`,
approvalRequestViewColumns, approvalRequestViewJoins)
var v ApprovalRequestView
if err := s.db.GetContext(ctx, &v, q, requestID); err != nil {
if err := s.db.GetContext(ctx, &v, q, callerID, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -974,26 +1005,7 @@ func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`
AND ` + approvalEligibilitySQL
var n int
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
return 0, fmt.Errorf("pending count: %w", err)

View File

@@ -812,3 +812,137 @@ func TestApprovalService_ListSubmittedByUser_PendingVisible(t *testing.T) {
t.Errorf("other user: len(rows) = %d, want 0 — must scope by requested_by", len(rows))
}
}
// TestApprovalService_ViewerFlags pins the per-viewer eligibility flags on
// ApprovalRequestView (t-paliad-202). Drives /inbox grey-out of
// Genehmigen/Ablehnen/Zurückziehen instead of click-then-error.
//
// Matrix (one pending request, four viewers):
//
// viewer viewer_can_approve viewer_is_requester
// requester (self) false true → only Zurückziehen
// approver (peer) true false → Genehmigen + Ablehnen
// other (no team) false false → all three disabled
// global_admin true false → Genehmigen + Ablehnen
func TestApprovalService_ViewerFlags(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
// Profession + global_role tuning: the live-DB seed gives every user
// global_role='standard' + profession=NULL, which means nobody is
// eligible by default. Promote requester→associate (matches threshold)
// and approver→partner (above threshold), and create a fourth user
// with global_role='global_admin' (the override branch).
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.users SET profession = 'associate' WHERE id = $1`, env.requester); err != nil {
t.Fatalf("set requester profession: %v", err)
}
if _, err := env.pool.ExecContext(ctx,
`UPDATE paliad.users SET profession = 'partner' WHERE id = $1`, env.approver); err != nil {
t.Fatalf("set approver profession: %v", err)
}
adminID := uuid.New()
if _, err := env.pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, adminID); err != nil {
t.Logf("skip auth.users seed for admin: %v (continuing)", err)
}
if _, err := env.pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'Admin', 'munich', 'global_admin')
ON CONFLICT (id) DO UPDATE SET global_role = 'global_admin'`, adminID); err != nil {
t.Fatalf("seed admin: %v", err)
}
defer func() {
ctx := context.Background()
env.pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID)
env.pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID)
}()
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)
}
cases := []struct {
name string
viewer uuid.UUID
wantCanApprove bool
wantIsRequester bool
}{
{"self_authored", env.requester, false, true},
{"eligible_approver", env.approver, true, false},
{"non_eligible_viewer", env.other, false, false},
{"global_admin", adminID, true, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
row, err := env.approvals.GetRequest(ctx, c.viewer, *reqID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if row == nil {
t.Fatal("GetRequest returned nil — request should exist")
}
if row.ViewerCanApprove != c.wantCanApprove {
t.Errorf("viewer_can_approve = %v, want %v",
row.ViewerCanApprove, c.wantCanApprove)
}
if row.ViewerIsRequester != c.wantIsRequester {
t.Errorf("viewer_is_requester = %v, want %v",
row.ViewerIsRequester, c.wantIsRequester)
}
})
}
// ListPendingForApprover stamps the same flags. The approver runs the
// query; they should see one row with viewer_can_approve=true,
// viewer_is_requester=false.
pending, err := env.approvals.ListPendingForApprover(ctx, env.approver, InboxFilter{})
if err != nil {
t.Fatalf("ListPendingForApprover: %v", err)
}
if len(pending) != 1 {
t.Fatalf("len(pending) = %d, want 1", len(pending))
}
if !pending[0].ViewerCanApprove {
t.Error("ListPendingForApprover: viewer_can_approve = false, want true")
}
if pending[0].ViewerIsRequester {
t.Error("ListPendingForApprover: viewer_is_requester = true, want false")
}
// ListSubmittedByUser carries them too. Requester runs the query; the
// one row must have viewer_can_approve=false (self-approval blocked)
// and viewer_is_requester=true.
mine, err := env.approvals.ListSubmittedByUser(ctx, env.requester, InboxFilter{})
if err != nil {
t.Fatalf("ListSubmittedByUser: %v", err)
}
if len(mine) != 1 {
t.Fatalf("len(mine) = %d, want 1", len(mine))
}
if mine[0].ViewerCanApprove {
t.Error("ListSubmittedByUser: viewer_can_approve = true on self-authored row, want false")
}
if !mine[0].ViewerIsRequester {
t.Error("ListSubmittedByUser: viewer_is_requester = false on self-authored row, want true")
}
}

View File

@@ -849,8 +849,15 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
id := uuid.New()
now := time.Now().UTC()
// path is NOT NULL but the trigger populates it; supply a placeholder
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
// path is NOT NULL but paliad.projects_sync_path() (BEFORE INSERT
// trigger from mig 018/021) overwrites it from id and parent path,
// so any non-null value satisfies the constraint. Use a literal
// placeholder rather than re-referencing $1 — reusing a parameter
// across columns with different SQL types (id is uuid, path is text)
// makes Postgres's planner reject the statement with 42P08
// "inconsistent types deduced for parameter" once the driver hands
// $1 across as an inferred type. The literal keeps the param list
// decoupled from the id column's type.
if input.OurSide != nil {
if err := validateOurSide(*input.OurSide); err != nil {
return nil, err
@@ -868,7 +875,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
instance_level, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
VALUES ($1, $2, $3, '', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, '{}'::jsonb, $24, $24)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
@@ -1281,12 +1288,15 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
id := uuid.New()
now := time.Now().UTC()
// path placeholder is overwritten by paliad.projects_sync_path();
// same rationale as ProjectService.Create — see comment there for
// why we use a literal '' instead of re-referencing $1.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, 'case', $2, $1::text, $3, 'active', $4,
VALUES ($1, 'case', $2, '', $3, 'active', $4,
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
id, childParentID, title, userID,
parent.Court, opts.CaseNumber, procTypeID,