Merge: Slice B.6 — /admin/rules → /admin/procedural-events URL rename + 301 redirects + .tsx i18n rebind. #93 slice train concludes (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 20:13:17 +02:00
9 changed files with 137 additions and 48 deletions

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// 37-column rule row plus a side panel with the preview widget and the
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
// rule's current state (draft/published/archived). Every write goes
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.edit.title">Regel bearbeiten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
<div className="tool-header admin-rules-edit-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">&larr; Regeln verwalten</a>
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
<input type="text" id="f-primary-party" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
// admin can hand-bind each legacy deadline to one of the candidate
// rule_ids. Both surfaces share the same page shell to keep navigation
@@ -21,25 +21,25 @@ export function renderAdminRulesList(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.list.title">Regeln verwalten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft &rarr; published &rarr; archived.
</p>
</div>
<div className="admin-rules-header-actions">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
+ Neue Regel
</button>
</div>
@@ -101,7 +101,7 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>

View File

@@ -95,7 +95,7 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
<a href="/admin/rules" className="card card-link">
<a href="/admin/procedural-events" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>

View File

@@ -1,7 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
// row, drives every form field, the preview widget, the audit-log
// timeline and the lifecycle action bar. Every write is gated behind
// a reason modal — the ≥10-char rule is enforced client-side per
@@ -106,7 +106,7 @@ function fmtDateTime(iso: string): string {
}
function parseRuleIDFromPath(): string {
// /admin/rules/{uuid}/edit
// /admin/procedural-events/{uuid}/edit
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
return m ? decodeURIComponent(m[1]) : "";
}
@@ -179,7 +179,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
}
async function loadRule(): Promise<void> {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
if (!resp.ok) {
if (resp.status === 404) {
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
@@ -198,7 +198,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
auditEntries = [];
auditOffset = 0;
}
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
if (!resp.ok) return;
const body = await resp.json();
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
@@ -508,7 +508,7 @@ async function doSaveDraft(reason: string) {
return;
}
payload.reason = reason;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -530,7 +530,7 @@ async function doSaveDraft(reason: string) {
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -552,7 +552,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
async function doClone(reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -565,7 +565,7 @@ async function doClone(reason: string) {
return;
}
const newRule = await resp.json() as Rule;
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
}
// --------------------------------------------------------------------
@@ -591,7 +591,7 @@ async function runPreview() {
if (flagsRaw) qs.set("flags", flagsRaw);
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
out.style.display = "";
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;

View File

@@ -1,10 +1,10 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
// by proceeding type, trigger event, lifecycle state, free-text query)
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
// "Pick" affordance with an inline reason prompt that posts to
// /admin/api/orphans/{id}/resolve.
@@ -145,7 +145,7 @@ function buildFilterURL(): string {
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
if (activeQuery) qs.set("q", activeQuery);
qs.set("limit", "500");
return "/admin/api/rules?" + qs.toString();
return "/admin/api/procedural-events?" + qs.toString();
}
async function loadProceedings(): Promise<void> {
@@ -248,7 +248,7 @@ function renderRulesTable() {
if (target && (target.closest("a") || target.closest("button"))) return;
const id = row.dataset.rowId;
if (!id) return;
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
});
});
}
@@ -392,7 +392,7 @@ async function submitReasonModal(ev: Event) {
submit.disabled = false;
return;
}
const resp = await fetch("/admin/api/rules", {
const resp = await fetch("/admin/api/procedural-events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -416,7 +416,7 @@ async function submitReasonModal(ev: Event) {
return;
}
const created = await resp.json();
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
return;
}

View File

@@ -2905,10 +2905,11 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
// `/admin/procedural-events` (301 redirects from /admin/rules*).
// The i18n keys `admin.rules.*` are kept as the corpus until a
// follow-up slice migrates each reference; canonical
// `admin.procedural_events.*` aliases live after the EN block.
"nav.admin.rules": "Verfahrensschritte verwalten",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",

View File

@@ -204,7 +204,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}

View File

@@ -486,3 +486,66 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
// /admin/rules* paths. New canonical paths live under
// /admin/procedural-events; the redirects keep external bookmarks,
// audit-log entries, and curl scripts working through one
// deprecation cycle.
//
// Three flavours:
//
// * redirectToProceduralEvents(newPath) — fixed redirect target
// (used by the parameter-less paths /admin/rules and
// /admin/api/rules).
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
// an {id} and optional suffix (/clone-as-draft, /publish, …).
//
// All emit 301 Moved Permanently — caches and browsers learn the new
// URL once and stop hitting the legacy path. The IETF Deprecation
// header is added so machine clients see the migration signal
// alongside the redirect.
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
// the supplied destination path. Query string is preserved.
func redirectToProceduralEvents(dst string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
target := dst
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
// /admin/procedural-events/{id}/edit.
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/procedural-events/" + id + "/edit"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/api/procedural-events/" + id + suffix
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}

View File

@@ -722,18 +722,43 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-089 — admin Event-Type moderation panel.
// t-paliad-191 Slice 11a — admin rule-editor API.
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Slice B.6 (t-paliad-305) — canonical URL paths under
// /admin/procedural-events with 301 redirects from the legacy
// /admin/rules paths so existing bookmarks and audit-log
// entries continue to resolve. New paths point at the same
// handlers; the canonical-URL name aligns with the umbrella
// term locked in Slice A.
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Legacy /admin/rules paths — 301 redirect to the canonical
// /admin/procedural-events paths. One-slice deprecation window
// per design §8.2 (B.6 optional; m authorised the rename
// 2026-05-26). After the next slice that audits external
// references, these can be retired entirely.
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))