Commit Graph

12 Commits

Author SHA1 Message Date
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
f0b08e9d06 feat(approvals): t-paliad-217 Slice B — counter_payload allowlist expansion
m's t-paliad-217 Q1 lock-in (2026-05-20): the suggest-changes modal lets
the approver edit EVERY field on the underlying deadline / appointment,
not just the date allowlist that triggers approval. Server-side support
for the wider counter shape:

  - buildCounterSetClauses (new) — the counter-allowlist:
      deadline:    title, due_date, original_due_date, warning_date,
                   description, notes, rule_code (event_type_ids handled
                   separately via junction-table rewrite).
      appointment: title, start_at, end_at, description, location,
                   appointment_type.
  - buildRevertSetClauses (existing) stays narrow — Reject only restores
    what pre_image actually contains (defence-in-depth: a hostile UPDATE
    on the request row must not let arbitrary fields be reverted, and
    pre_image is server-written so what's in there is what we trust).
  - rewriteDeadlineEventTypes — junction-table DELETE+INSERT for the
    deadline_event_types m-to-m when counter_payload carries
    event_type_ids. Runs in the same tx as the entity UPDATE.
  - applyEntityUpdate — switched from buildRevertSetClauses to
    buildCounterSetClauses; gained the event_type_ids branch for
    deadlines.
  - SuggestChanges no-op validator — now uses buildCounterSetClauses
    so the wider field set counts as "differs".
  - title is treated as NOT NULL — whitespace-only counter title
    surfaces ErrSuggestionRequiresChange (defence-in-depth against the
    column's own NOT NULL CHECK).

Tests:
  - TestApprovalService_SuggestChanges_TitleOnlyCounter — title diff
    succeeds; entity title updates.
  - TestApprovalService_SuggestChanges_NotesOnlyCounter — notes diff
    succeeds; entity notes column populates.
  - TestApprovalService_SuggestChanges_EmptyTitleRejected — whitespace-
    only title rejected with ErrSuggestionRequiresChange.

No DB migration needed (counter_payload jsonb already accepts arbitrary
shape; the change is in the column-allowlist switch on read).
2026-05-20 13:05:59 +02:00
mAi
0263a0e932 feat(approvals): t-paliad-216 — server-side hydration for back-link
Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:

  - ApprovalRequestView gains NextRequestID. Hydrated via correlated
    subquery on previous_request_id; mig 103's partial index makes the
    lookup O(1) per row.
  - view_service.go approvalRowSubtitle picks up the changes_requested
    case ("Abgelehnt mit Vorschlag von <decider>").
  - filter_spec.go validRequestStatuses includes "changes_requested" so
    user-views can filter on it.
  - handlers/approvals.go isValidInboxStatus accepts "changes_requested"
    on the /api/inbox/{mine,pending-mine}?status= query. Test case added
    to TestParseInboxFilter_DropsUnknownStatus.
2026-05-20 10:02:36 +02:00
mAi
705e1a2e79 feat(approvals): t-paliad-216 SuggestChanges service method
ApprovalService.SuggestChanges is the fourth approval action — in one
transaction:

  1. Validates the OLD pending row (caller satisfies canApprove,
     lifecycle in update/complete only, counter differs from old.payload
     OR note is non-empty).
  2. Closes the OLD row as 'changes_requested' with decision_note +
     counter_payload + decided_by + decided_at + decision_kind.
  3. Reverts the entity from old.pre_image (reuses applyRevert — same
     code path Reject runs).
  4. Runs the deadlock check for the NEW row (excluding the suggesting
     caller; original requester is no longer excluded).
  5. Re-applies the counter_payload to the entity row (via
     applyEntityUpdate, mirroring the write-then-approve write).
  6. INSERTs a NEW pending approval_requests row authored by the caller
     with previous_request_id pointing back at the OLD row.
  7. Marks the entity pending + pending_request_id → new row.
  8. Emits two project_events: *_approval_changes_suggested + a fresh
     *_approval_requested for the new row.

4-Augen still holds: the suggesting caller is the new row's
requested_by, so self-approval on the new row is blocked by the standard
3-layer guard. The ORIGINAL requester is no longer the requested_by of
the new row — if their profession satisfies the required_role they can
now approve the counter themselves.

Adds:
  - const RequestStatusChangesRequested = "changes_requested"
  - var  ErrSuggestionRequiresChange   = "suggestion requires counter diff or note"
  - var  ErrSuggestionLifecycleInvalid = "suggest is only valid for update/complete"
  - models.ApprovalRequest.CounterPayload + PreviousRequestID
  - Per-row read paths (getRequestForUpdate, approvalRequestViewColumns)
    populate the new columns.
2026-05-20 09:50:07 +02:00
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
m
a3052eb085 feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with  added in Slice E).

  POST /api/paliadin/suggest/deadline
  POST /api/paliadin/suggest/appointment

Wiring:

- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
  always creates an approval_request (bypassing policy lookup) and
  stamps requester_kind='agent' + agent_turn_id. Required-role defaults
  to 'associate' so the deadlock check has a non-NULL threshold; m's
  lock-in for Q11 (every agent suggestion needs the user's eye) means
  bypassing the policy gate is correct here, not a regression.

- The shared `submit` kernel takes an optional agent_turn_id pointer.
  All four lifecycle entry points (SubmitCreate / SubmitUpdate /
  SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
  the turn id. INSERT to approval_requests now writes both
  requester_kind + agent_turn_id atomically (xor-check on the schema
  enforces consistency).

- models.ApprovalRequest grows the two columns + their JSON tags so
  the inbox view + Verlauf renderer can read provenance without an
  extra fetch.

- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
  to the SQL projection; both surfaces (ListPendingForApprover,
  ListSubmittedByUser, GetRequest) inherit the new fields free.

- CreateDeadlineInput + CreateAppointmentInput each get an optional
  AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
  SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
  behaviour is unchanged for every existing caller.

- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
  requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
  RFC3339 + ISO-date validation, 409 + a useful message on
  ErrNoQualifiedApprover.

- Project-event audit metadata gains requester_kind + agent_turn_id so
  the project's Verlauf can render "Paliadin hat eine Frist
  vorgeschlagen " without joining approval_requests (Slice E reads
  this).

SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.

go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the  on existing eye-pill surfaces.

Refs: docs/design-paliadin-inline-2026-05-08.md §7.
2026-05-08 19:59:44 +02:00
m
aec6cf6104 refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.

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

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

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

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

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

Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
2026-05-08 17:15:05 +02:00
m
3a41aa9209 feat(approvals/t-paliad-160 slice1+2): split policy + 409 handler
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".

M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
  - requires_approval := bool_or across project + ancestor + unit_default
  - min_role          := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.

Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.

Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.

Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.

Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.

Defers per task spec to follow-up slices:
  - A3 (admin UI 2-control flip)
  - C+E (badge + withdraw button on detail pages)
  - D   (/inbox Meine Anfragen visibility fix)
  - M2  (drop required_role column)
2026-05-08 16:54:45 +02:00
m
e6067c74db feat(t-paliad-154) commit 2/5: ApprovalService rewire — resolver delegation + scope-split CRUD + audit emission
Service-layer changes implementing the locked design (Q5/Q6/Q8):

LookupPolicy (existing, called by SubmitCreate/Update/Complete/Delete)
delegates to paliad.approval_policy_effective() resolver. Returns nil
for the 'none' sentinel — explicit project-level suppression of inherited
defaults. Synthesizes a *models.ApprovalPolicy carrying the actual
project_id so the existing submit chain branches don't change.

Policy CRUD split into project + unit scope methods:
- ListProjectPolicies / ListUnitPolicies — read-only per scope.
- UpsertProjectPolicy / DeleteProjectPolicy — project-scoped writes,
  audit-emitting (writes paliad.policy_audit_log inside the same tx).
- UpsertUnitPolicy / DeleteUnitPolicy — unit-default writes, same shape.
- All four use validatePolicyTuple for entity_type/lifecycle/required_role
  ranges. IsValidPolicyRole accepts the 'none' sentinel; the existing
  IsValidRequiredRole keeps rejecting 'none' (gate-only contract).

Effective-policy reads:
- GetEffectivePolicyOne(projectID, entity, lifecycle) — single-cell,
  used by the form-time hint endpoint above /projects/{id}/deadlines/new.
- GetEffectivePoliciesMatrix(projectID) — 8 cells in stable display order
  (Fristen/Termine × create/update/complete/delete), each w/ attribution.
- lookupSourceName resolves source_id to projects.title or partner_units.name.

ApplyMatrixToDescendants — bulk-apply (Q10): copies source project's
effective matrix down to listed descendants as project-specific rows,
inside one tx. Validates targetIDs are actual descendants via path-prefix
NOT LIKE check. Idempotent fanout: deletes target's project rows first
then writes the source's effective values. Self-target skipped. Audit
row per affected target.

PoliciesExist() — bool, used by /inbox empty-state nudge.

Models:
- ApprovalPolicy.ProjectID is now *uuid.UUID (was uuid.UUID); new
  *uuid.UUID PartnerUnitID. Existing handler code only reads RequiredRole
  so no upstream breakage.
- New EffectivePolicy struct (resolved cell + source attribution).
- New PolicyAuditEntry struct (paliad.policy_audit_log row).

Handlers:
- handleListApprovalPolicies → ListProjectPolicies (renamed).
- handlePutApprovalPolicy → UpsertProjectPolicy (caller-id reordering).
- handleDeleteApprovalPolicy → DeleteProjectPolicy (now needs uid for
  audit; took the existing requireUser path).

Tests:
- Existing TestApprovalService_PolicyCRUD updated for new method names
  + post-148 enum (partner, not lead) + new 'none' sentinel acceptance.
- New TestIsValidPolicyRole pins the helper that gates writes.
- TestIsValidRequiredRole extended with 'none' rejection (gate-only).

Build + go vet + role-tests clean.

Q8: audit emission writes to paliad.policy_audit_log only — never to
project_events — so /admin/audit-log surfaces the change while /verlauf
stays focused on entity-level lifecycle.
2026-05-08 02:20:15 +02:00
m
6506864730 feat(t-paliad-148) commit 2/6: ApprovalService + DerivationService — tuple-with-gate ladder
Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.

approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.

CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.

derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.

Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
2026-05-07 21:44:14 +02:00
m
a61c1490e3 feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.

Schema (migration 055 update)
-----------------------------
  - paliad.approval_requests.decision_kind CHECK extended to accept
    'derived_peer'. Down migration restores the t-138 two-value CHECK.
    Live SQL dry-run confirmed the new value is accepted.

Service layer
-------------
  - approval_levels.go: new constant DecisionKindDerivedPeer.
  - approval_service.go (4 sites widened with the derivation EXISTS branch):
      1. canApprove — third resolution step after global_admin + direct/
         ancestor team membership: matches partner-unit-derived members
         on path with derive_grants_authority=true and a unit_role whose
         approval_role_from_unit_role mapping meets the threshold.
         Returns DecisionKindDerivedPeer when this branch is the one that
         passed.
      2. hasQualifiedApprover (the deadlock-check at submit time) —
         widened so a project with no direct approvers but an authority-
         granting unit attachment is still submittable.
      3. ListPendingForApprover (the /inbox query) — third UNION ALL
         branch so derived authority sees their queue.
      4. PendingCountForUser (the bell-badge query) — same widening so
         derived authority sees the count tick.
  All four queries reuse paliad.approval_role_from_unit_role(text) added
  by Phase 2 of migration 055.

Frontend
--------
  - 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
    "Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
    derived member (Partner Unit)". Verlauf rendering of the third
    decision_kind value works through the existing translateEvent /
    decision_kind switch with no other change. 1606 keys total.

Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.

Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
2026-05-06 16:45:19 +02:00
m
4ebbf2c1af feat(t-paliad-138): ApprovalService core + tests
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.

Public surface:

- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
  AppointmentService inside their existing tx. Looks up policy,
  runs the deadlock check, inserts paliad.approval_requests, marks
  the entity pending, emits the *_approval_requested project_events
  audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
  finalises the lifecycle (clears pending markers + sets approved_by
  for non-delete; hard-deletes for delete). Reject / Revoke revert
  the entity from pre_image (delete a pending-create, restore date
  fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
  PendingCountForUser — read paths the inbox + bell will hit in
  commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
  authoring page in commit 4.

Self-approval is blocked at three layers:
  1. canApprove() returns ErrSelfApproval when caller == requester.
  2. The DB CHECK constraint approval_requests_no_self_approval.
  3. The deadlock check excludes the requester from the pool.

Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).

Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
  approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
  reject-create deletes; reject-update restores pre_image;
  no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
  Skipped when TEST_DATABASE_URL is unset, mirroring the existing
  audit_service_test pattern.

No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.
2026-05-06 15:21:47 +02:00