feat(t-paliad-154) commit 3/5: HTTP handlers — admin APIs + form-hint endpoint + audit-log union
8 new endpoints under /api/admin/* (admin-gated) and /api/projects (gated
on per-user authentication for the form-time hint):
Admin APIs (gated by adminGate):
- GET /admin/approval-policies — page shell
- GET /api/admin/partner-units/{unit_id}/approval-policies — list unit defaults
- PUT /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — upsert unit default
- DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity}/{lifecycle} — clear unit default
- GET /api/admin/approval-policies/seeded — exists check (gates inbox nudge)
- GET /api/admin/approval-policies/matrix?project_id=... — 8 effective rows w/ attribution
- POST /api/admin/approval-policies/apply-to-descendants — bulk fanout
Form-time hint (NOT admin-gated — every user authoring a deadline /
appointment needs to know whether their save will trigger 4-eye):
- GET /api/projects/{id}/approval-policies/effective?entity_type=&lifecycle=
AuditService extension:
- New AuditSourcePolicyAuditLog source string.
- Fifth UNION ALL branch in auditUnionSQL queries paliad.policy_audit_log,
packs description as 'entity/lifecycle: old → new'. project_id forwarded
for project-scoped rows so /admin/audit-log filters work — but
policy_audit_log is NOT a /verlauf source (the verlauf SELECT in
ProjectService.ListProjectEvents reads project_events directly), so
Q8's no-leak constraint is preserved.
Build + go vet clean. The new handler functions register with the existing
adminGate / gateOnboarded patterns; no new middleware.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user