diff --git a/internal/handlers/approvals.go b/internal/handlers/approvals.go index f2a8b51..03bac9f 100644 --- a/internal/handlers/approvals.go +++ b/internal/handlers/approvals.go @@ -279,6 +279,218 @@ 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: {"required_role": "associate"} +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") + var body struct { + RequiredRole string `json:"required_role"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + 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. func writeApprovalError(w http.ResponseWriter, err error) { switch { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 213b4c1..b2d2bc1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -424,6 +424,24 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc adminGate(users, handlePutApprovalPolicy)) protected.HandleFunc("DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}", adminGate(users, handleDeleteApprovalPolicy)) + + // t-paliad-154 — approval-policy authoring page + admin APIs for + // per-partner-unit defaults, matrix view, bulk-apply, and the + // existence-check used by /inbox. + protected.HandleFunc("GET /admin/approval-policies", + adminGate(users, gateOnboarded(handleAdminApprovalPoliciesPage))) + protected.HandleFunc("GET /api/admin/partner-units/{unit_id}/approval-policies", + adminGate(users, handleListUnitApprovalPolicies)) + protected.HandleFunc("PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}", + adminGate(users, handlePutUnitApprovalPolicy)) + protected.HandleFunc("DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}", + adminGate(users, handleDeleteUnitApprovalPolicy)) + protected.HandleFunc("GET /api/admin/approval-policies/seeded", + adminGate(users, handleApprovalPoliciesSeeded)) + protected.HandleFunc("GET /api/admin/approval-policies/matrix", + adminGate(users, handleApprovalPoliciesMatrix)) + protected.HandleFunc("POST /api/admin/approval-policies/apply-to-descendants", + adminGate(users, handleApplyMatrixToDescendants)) } // t-paliad-138 — approval inbox + decision endpoints (any authenticated @@ -437,6 +455,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest) protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest) protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest) + + // t-paliad-154 — form-time effective policy lookup. Reachable by + // every authenticated user (NOT admin-gated) so deadline + + // appointment forms can render the 4-eye hint. + protected.HandleFunc("GET /api/projects/{id}/approval-policies/effective", + gateOnboarded(handleProjectEffectivePolicy)) } // t-paliad-144 Phase A1+A2 — Custom Views (substrate + user_views CRUD diff --git a/internal/services/audit_service.go b/internal/services/audit_service.go index 69afee3..8b89411 100644 --- a/internal/services/audit_service.go +++ b/internal/services/audit_service.go @@ -2,12 +2,13 @@ package services // AuditService produces a unified, paginated, filterable timeline across // every audit source we keep in the paliad schema. There is no single -// audit_log table — instead we union four existing sources: +// audit_log table — instead we union five sources: // // - paliad.project_events — per-project audit (creates, updates, etc.) // - paliad.caldav_sync_log — CalDAV push/pull outcomes per user // - paliad.reminder_log — bundled-digest reminder sends // - paliad.partner_unit_events — partner-unit CRUD + membership changes +// - paliad.policy_audit_log — approval-policy CRUD (t-paliad-154) // // The union happens in SQL (one round-trip, server-side ordering) and is // keyset-paginated on (timestamp, id) DESC so the cursor stays stable across @@ -35,6 +36,7 @@ const ( AuditSourceCalDAVLog = "caldav_sync_log" AuditSourceReminderLog = "reminder_log" AuditSourcePartnerUnitEvents = "partner_unit_events" + AuditSourcePolicyAuditLog = "policy_audit_log" ) // MaxAuditPageLimit caps a single ListEntries page. @@ -187,6 +189,33 @@ WITH unioned AS ( WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'partner_unit_events') AND ($2::timestamptz IS NULL OR pue.created_at >= $2) AND ($3::timestamptz IS NULL OR pue.created_at <= $3) + + UNION ALL + + -- t-paliad-154 — approval-policy CRUD audit. Project-scoped rows carry + -- project_id (so the timeline filter by project still works on the + -- /verlauf SELECT — but project_events is the source for that surface, + -- not policy_audit_log, so no leakage). Description packs the field + -- transition (entity_type/lifecycle: old → new). + SELECT + 'policy_audit_log'::text AS source, + pal.id AS id, + pal.created_at AS ts, + pal.event_type AS event_type, + COALESCE(au.email, pal.actor_id::text) AS actor, + pal.scope_name AS subject, + pal.project_id AS project_id, + NULL::text AS title, + format('%s/%s: %s → %s', + pal.entity_type, + pal.lifecycle_event, + COALESCE(pal.old_required_role, '—'), + COALESCE(pal.new_required_role, '—')) AS description + FROM paliad.policy_audit_log pal + LEFT JOIN paliad.users au ON au.id = pal.actor_id + WHERE ($1::text IS NULL OR $1 = '' OR $1 = 'policy_audit_log') + AND ($2::timestamptz IS NULL OR pal.created_at >= $2) + AND ($3::timestamptz IS NULL OR pal.created_at <= $3) ) SELECT source, id, ts, event_type, actor, subject, project_id, title, description FROM unioned