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.
705 lines
22 KiB
Go
705 lines
22 KiB
Go
package handlers
|
|
|
|
// Approval workflow HTTP endpoints (t-paliad-138).
|
|
//
|
|
// Three groups of routes:
|
|
//
|
|
// 1. Policy CRUD (admin-only, gated at the route layer):
|
|
// GET /api/projects/{id}/approval-policies
|
|
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
|
// DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
|
//
|
|
// 2. Inbox (any authenticated user — the service-layer query gates by
|
|
// project visibility + approver eligibility):
|
|
// GET /api/inbox/pending-mine — requests I can approve
|
|
// GET /api/inbox/mine — requests I submitted
|
|
// GET /api/inbox/count — bell badge count
|
|
// GET /api/approval-requests/{id} — one request hydrated
|
|
//
|
|
// 3. Decisions (any authenticated user — service layer gates the action):
|
|
// POST /api/approval-requests/{id}/approve
|
|
// POST /api/approval-requests/{id}/reject
|
|
// POST /api/approval-requests/{id}/revoke
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Policy CRUD (admin only — gated by RequireAdminFunc at registration).
|
|
// ============================================================================
|
|
|
|
// GET /api/projects/{id}/approval-policies
|
|
func handleListApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.approval.ListProjectPolicies(r.Context(), projectID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []models.ApprovalPolicy{} // ensure JSON [] not null
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}
|
|
//
|
|
// 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.
|
|
func handlePutApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
|
return
|
|
}
|
|
entityType := r.PathValue("entity_type")
|
|
lifecycle := r.PathValue("lifecycle")
|
|
body, err := decodePolicyBody(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
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
|
|
}
|
|
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
|
|
// no-approval-needed default.
|
|
func handleDeleteApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
|
return
|
|
}
|
|
entityType := r.PathValue("entity_type")
|
|
lifecycle := r.PathValue("lifecycle")
|
|
if err := dbSvc.approval.DeleteProjectPolicy(r.Context(), uid, projectID, entityType, lifecycle); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Inbox.
|
|
// ============================================================================
|
|
|
|
// GET /api/inbox/pending-mine — requests I'm qualified to approve.
|
|
func handleListInboxPendingMine(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := dbSvc.approval.ListPendingForApprover(r.Context(), uid, parseInboxFilter(r))
|
|
if err != nil {
|
|
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
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := dbSvc.approval.ListSubmittedByUser(r.Context(), uid, parseInboxFilter(r))
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []services.ApprovalRequestView{} // ensure JSON [] not null
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// GET /api/inbox/count — bell badge count for the sidebar.
|
|
func handleInboxCount(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
n, err := dbSvc.approval.PendingCountForUser(r.Context(), uid)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"count": n})
|
|
}
|
|
|
|
// 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{}
|
|
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 {
|
|
f.ProjectID = &id
|
|
}
|
|
}
|
|
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,
|
|
services.RequestStatusChangesRequested:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GET /api/approval-requests/{id} — one hydrated request.
|
|
func handleGetApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
requestID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
|
return
|
|
}
|
|
row, err := dbSvc.approval.GetRequest(r.Context(), uid, requestID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Decisions.
|
|
// ============================================================================
|
|
|
|
type approvalDecisionBody struct {
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// POST /api/approval-requests/{id}/approve
|
|
func handleApproveApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
|
handleApprovalDecision(w, r, "approve")
|
|
}
|
|
|
|
// POST /api/approval-requests/{id}/reject
|
|
func handleRejectApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
|
handleApprovalDecision(w, r, "reject")
|
|
}
|
|
|
|
// POST /api/approval-requests/{id}/revoke
|
|
func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
|
handleApprovalDecision(w, r, "revoke")
|
|
}
|
|
|
|
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
|
|
//
|
|
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
|
|
// pending create) without withdrawing the request. The non-destructive
|
|
// sibling of /revoke that m asked for after noticing that withdraw silently
|
|
// deletes the underlying event.
|
|
//
|
|
// Body: {"fields": {<entity-shape>}}
|
|
// 200: {"status": "ok"}
|
|
//
|
|
// Status mapping (mapApprovalError):
|
|
//
|
|
// 400 suggestion_requires_change — payload has no allowlisted fields
|
|
// 403 not_authorized — caller isn't the requested_by
|
|
// 404 — request not found / not visible
|
|
// 409 request_not_pending — request already decided / revoked
|
|
type editPendingEntityBody struct {
|
|
Fields map[string]any `json:"fields"`
|
|
}
|
|
|
|
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
requestID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
|
return
|
|
}
|
|
var body editPendingEntityBody
|
|
if r.Body != nil && r.ContentLength > 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"code": "invalid_body",
|
|
"message": "Ungültiger Body.",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
|
|
writeApprovalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
|
// counter_payload is an entity-shaped jsonb of the approver's edited
|
|
// values (allowlist enforced server-side); note is the optional free-text
|
|
// explanation. The service rejects the call with
|
|
// ErrSuggestionRequiresChange when both are no-ops (counter is identical
|
|
// to the old row's payload AND note is empty).
|
|
type suggestChangesBody struct {
|
|
CounterPayload map[string]any `json:"counter_payload"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// POST /api/approval-requests/{id}/suggest-changes — t-paliad-216.
|
|
//
|
|
// In one transaction: close the pending request as 'changes_requested'
|
|
// (with the caller's note + counter_payload on the row), revert the entity
|
|
// from pre_image, then spawn a NEW pending approval_request authored by
|
|
// the caller carrying the counter_payload. Returns the new request id.
|
|
//
|
|
// Status mapping (see writeApprovalError → mapApprovalError):
|
|
//
|
|
// 400 suggestion_requires_change — counter == old payload AND no note
|
|
// 400 suggestion_lifecycle_invalid — old row's lifecycle ∉ (update, complete)
|
|
// 403 self_approval_blocked — caller == old row's requested_by
|
|
// 403 not_authorized — caller doesn't satisfy canApprove
|
|
// 404 — request not found / not visible
|
|
// 409 request_not_pending — old row already decided
|
|
// 409 no_qualified_approver — deadlock on the new row
|
|
func handleSuggestChangesApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
requestID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
|
return
|
|
}
|
|
var body suggestChangesBody
|
|
if r.Body != nil && r.ContentLength > 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"code": "invalid_body",
|
|
"message": "Ungültiger Body.",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
newID, err := dbSvc.approval.SuggestChanges(r.Context(), requestID, uid, body.CounterPayload, body.Note)
|
|
if err != nil {
|
|
writeApprovalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"status": "ok",
|
|
"new_request_id": newID.String(),
|
|
})
|
|
}
|
|
|
|
func handleApprovalDecision(w http.ResponseWriter, r *http.Request, action string) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
requestID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
|
return
|
|
}
|
|
var body approvalDecisionBody
|
|
if r.Body != nil && r.ContentLength > 0 {
|
|
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
|
|
}
|
|
|
|
switch action {
|
|
case "approve":
|
|
err = dbSvc.approval.Approve(r.Context(), requestID, uid, body.Note)
|
|
case "reject":
|
|
err = dbSvc.approval.Reject(r.Context(), requestID, uid, body.Note)
|
|
case "revoke":
|
|
err = dbSvc.approval.Revoke(r.Context(), requestID, uid)
|
|
}
|
|
if err != nil {
|
|
writeApprovalError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// GET /inbox — server-static page shell. Hydration is purely client-side
|
|
// (the bundle calls /api/inbox/pending-mine on load).
|
|
func handleInboxPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/inbox.html")
|
|
}
|
|
|
|
// ============================================================================
|
|
// t-paliad-154 — admin approval-policy authoring page + APIs.
|
|
//
|
|
// Most endpoints below register under /api/admin and are admin-gated by the
|
|
// outer adminGate(users, ...) wrapper at handlers.go. The form-time hint
|
|
// endpoint at /api/projects/{id}/approval-policies/effective is the one
|
|
// exception — it's reachable by every authenticated user authoring a
|
|
// deadline/appointment so the form can render the "this needs 4-eye"
|
|
// banner before they save.
|
|
// ============================================================================
|
|
|
|
// GET /admin/approval-policies — server-static page shell.
|
|
func handleAdminApprovalPoliciesPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "dist/admin-approval-policies.html")
|
|
}
|
|
|
|
// GET /api/admin/partner-units/{unit_id}/approval-policies — list one
|
|
// partner unit's default policy rows.
|
|
func handleListUnitApprovalPolicies(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
unitID, err := uuid.Parse(r.PathValue("unit_id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.approval.ListUnitPolicies(r.Context(), unitID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []models.ApprovalPolicy{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
|
|
//
|
|
// 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
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
unitID, err := uuid.Parse(r.PathValue("unit_id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
|
|
return
|
|
}
|
|
entityType := r.PathValue("entity_type")
|
|
lifecycle := r.PathValue("lifecycle")
|
|
body, err := decodePolicyBody(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusOK, policy)
|
|
}
|
|
|
|
// DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}
|
|
func handleDeleteUnitApprovalPolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
unitID, err := uuid.Parse(r.PathValue("unit_id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid unit id"})
|
|
return
|
|
}
|
|
entityType := r.PathValue("entity_type")
|
|
lifecycle := r.PathValue("lifecycle")
|
|
if err := dbSvc.approval.DeleteUnitPolicy(r.Context(), uid, unitID, entityType, lifecycle); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GET /api/admin/approval-policies/seeded — has any policy been authored
|
|
// firm-wide? Used by /inbox to gate the admin "configure policies" nudge.
|
|
func handleApprovalPoliciesSeeded(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
any, err := dbSvc.approval.PoliciesExist(r.Context())
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"any": any})
|
|
}
|
|
|
|
// GET /api/admin/approval-policies/matrix?project_id=... — 8 effective
|
|
// policy rows for one project, with attribution chips.
|
|
func handleApprovalPoliciesMatrix(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
pidStr := r.URL.Query().Get("project_id")
|
|
if pidStr == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id required"})
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(pidStr)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.approval.GetEffectivePoliciesMatrix(r.Context(), projectID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// POST /api/admin/approval-policies/apply-to-descendants
|
|
//
|
|
// Body: {"source_project_id": uuid, "target_project_ids": [uuid, ...]}
|
|
//
|
|
// Copies the source's effective matrix down to every target as
|
|
// project-scoped rows. Targets must be actual descendants of source.
|
|
func handleApplyMatrixToDescendants(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
SourceProjectID uuid.UUID `json:"source_project_id"`
|
|
TargetProjectIDs []uuid.UUID `json:"target_project_ids"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
if body.SourceProjectID == uuid.Nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "source_project_id required"})
|
|
return
|
|
}
|
|
writes, err := dbSvc.approval.ApplyMatrixToDescendants(r.Context(), uid, body.SourceProjectID, body.TargetProjectIDs)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"writes": writes,
|
|
"targets": len(body.TargetProjectIDs),
|
|
})
|
|
}
|
|
|
|
// GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=
|
|
//
|
|
// Single-cell effective policy lookup. Used by the deadline + appointment
|
|
// new/edit forms to render the form-time 4-eye hint above the Speichern
|
|
// button (Q13 of the locked design). Reachable by every authenticated user
|
|
// (NOT admin-gated) — they need to know their save will trigger an
|
|
// approval request.
|
|
func handleProjectEffectivePolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
if _, ok := requireUser(w, r); !ok {
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
entityType := q.Get("entity_type")
|
|
lifecycle := q.Get("lifecycle")
|
|
if entityType == "" || lifecycle == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "entity_type and lifecycle required"})
|
|
return
|
|
}
|
|
row, err := dbSvc.approval.GetEffectivePolicyOne(r.Context(), projectID, entityType, lifecycle)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, row)
|
|
}
|
|
|
|
// 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) {
|
|
if mapApprovalError(w, err) {
|
|
return
|
|
}
|
|
writeServiceError(w, err)
|
|
}
|