feat: route bounded hierarchy postbox copies

This commit is contained in:
2026-07-30 04:00:31 +02:00
parent d848a9a503
commit 28b60782de
16 changed files with 2715 additions and 17 deletions
@@ -39,7 +39,9 @@ import {
type PostboxDirectoryItem,
type PostboxExactCreatePayload,
type PostboxOrganizationFunction,
type PostboxOrganizationStructure,
type PostboxOrganizationUnit,
type PostboxRoutingPolicy,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplateRevisionPayload
@@ -56,6 +58,31 @@ type MaterializeDraft = {
context_key: string;
};
const routingDefaults = (): PostboxRoutingPolicy => ({
linked_copy: {
enabled: false,
structure_id: null,
relation_type_ids: [],
max_depth: 1,
stop_unit_id: null,
stop_unit_type_id: null,
target_function_type_id: null,
target_template_id: null,
fanout: "nearest",
allowed_classifications: ["internal"],
allowed_producer_modules: [],
require_expiry: false,
max_retention_days: null
},
attention: {
mode: "none",
delay_minutes: null
},
shared_visibility: {
mode: "none"
}
});
const templateDefaults = (): TemplateDraft => ({
templateId: "",
slug: "",
@@ -67,7 +94,8 @@ const templateDefaults = (): TemplateDraft => ({
name_pattern: "{unit_name} / {function_name}",
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
allow_vacant_delivery: true
allow_vacant_delivery: true,
routing_policy: routingDefaults()
});
const exactDefaults = (): ExactDraft => ({
@@ -94,6 +122,7 @@ export default function PostboxAdminPanel({
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
const [structures, setStructures] = useState<PostboxOrganizationStructure[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [loading, setLoading] = useState(true);
@@ -146,14 +175,15 @@ export default function PostboxAdminPanel({
setLoading(true);
setError("");
try {
const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([
const [nextTemplates, nextPostboxes, organizationTargets] = await Promise.all([
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
listPostboxOrganizationTargets(settings)
]);
setTemplates(nextTemplates);
setPostboxes(nextPostboxes);
setUnits(nextUnits);
setUnits(organizationTargets.units);
setStructures(organizationTargets.structures);
setSelectedTemplateId((current) =>
current && nextTemplates.some((template) => template.id === current)
? current
@@ -194,7 +224,8 @@ export default function PostboxAdminPanel({
name_pattern: revision.name_pattern,
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery
allow_vacant_delivery: revision.allow_vacant_delivery,
routing_policy: revision.routing_policy ?? routingDefaults()
});
setTemplateDialogOpen(true);
}
@@ -409,6 +440,8 @@ export default function PostboxAdminPanel({
open={templateDialogOpen}
draft={templateDraft}
units={units}
structures={structures}
templates={templates}
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
@@ -531,6 +564,22 @@ function TemplateWorkspace({
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
<div>
<dt>Hierarchy copies</dt>
<dd>
{revision.routing_policy.linked_copy.enabled
? `${revision.routing_policy.linked_copy.fanout} · depth ${revision.routing_policy.linked_copy.max_depth}`
: "Disabled"}
</dd>
</div>
<div>
<dt>Vacancy escalation</dt>
<dd>
{revision.routing_policy.attention.mode === "vacancy_escalation"
? `${revision.routing_policy.attention.delay_minutes} minutes`
: "Disabled"}
</dd>
</div>
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
@@ -642,6 +691,8 @@ function TemplateDialog({
open,
draft,
units,
structures,
templates,
functionTypes,
unitTypes,
busy,
@@ -652,6 +703,8 @@ function TemplateDialog({
open: boolean;
draft: TemplateDraft;
units: PostboxOrganizationUnit[];
structures: PostboxOrganizationStructure[];
templates: PostboxTemplate[];
functionTypes: Array<{ id: string; name: string }>;
unitTypes: Array<{ id: string; example: string }>;
busy: boolean;
@@ -663,12 +716,41 @@ function TemplateDialog({
const scopeOptions = draft.scope_kind === "unit_type"
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
: units.map((unit) => ({ id: unit.id, label: unit.name }));
const linkedCopy = draft.routing_policy.linked_copy;
const attention = draft.routing_policy.attention;
const selectedStructure = structures.find(
(item) => item.id === linkedCopy.structure_id
);
const updateRouting = (routing_policy: PostboxRoutingPolicy) => {
onChange({ ...draft, routing_policy });
};
const updateLinkedCopy = (
next: Partial<PostboxRoutingPolicy["linked_copy"]>
) => {
updateRouting({
...draft.routing_policy,
linked_copy: {
...linkedCopy,
...next
}
});
};
const valid =
draft.name.trim() &&
draft.slug.trim() &&
draft.name_pattern.trim() &&
draft.address_pattern.trim() &&
(draft.scope_kind === "tenant" || Boolean(draft.scope_id));
(draft.scope_kind === "tenant" || Boolean(draft.scope_id)) &&
(
!linkedCopy.enabled
|| Boolean(
linkedCopy.structure_id
&& linkedCopy.target_function_type_id
&& linkedCopy.target_template_id
&& linkedCopy.allowed_classifications.length
&& linkedCopy.allowed_producer_modules.length
)
);
return (
<Dialog
open={open}
@@ -777,6 +859,212 @@ function TemplateDialog({
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/>
</div>
<div className="postbox-routing-section">
<div className="postbox-routing-heading">
<div>
<strong>Hierarchy linked copies</strong>
<span>Copy to explicitly bounded function Postboxes in one selected structure.</span>
</div>
<ToggleSwitch
label="Enable hierarchy linked copies"
checked={linkedCopy.enabled}
onChange={(enabled) => updateLinkedCopy({
enabled,
allowed_classifications: linkedCopy.allowed_classifications.length
? linkedCopy.allowed_classifications
: [draft.classification]
})}
/>
</div>
{linkedCopy.enabled ? (
<div className="postbox-form-grid two-columns">
<FormField label="Organization structure">
<select
value={linkedCopy.structure_id || ""}
onChange={(event) => updateLinkedCopy({
structure_id: event.target.value || null,
relation_type_ids: []
})}
>
<option value="">Select structure</option>
{structures.filter((item) => item.status === "active").map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Relation type">
<select
value={linkedCopy.relation_type_ids[0] || ""}
onChange={(event) => updateLinkedCopy({
relation_type_ids: event.target.value ? [event.target.value] : []
})}
>
<option value="">All hierarchical relations</option>
{(selectedStructure?.relation_types || [])
.filter((item) => item.status === "active" && item.is_hierarchical)
.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Target function type">
<select
value={linkedCopy.target_function_type_id || ""}
onChange={(event) => updateLinkedCopy({
target_function_type_id: event.target.value || null
})}
>
<option value="">Select function type</option>
{functionTypes.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Target Postbox template">
<select
value={linkedCopy.target_template_id || ""}
onChange={(event) => updateLinkedCopy({
target_template_id: event.target.value || null
})}
>
<option value="">Select published template</option>
{templates
.filter((item) => item.status === "published")
.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Maximum hierarchy depth">
<input
type="number"
min={1}
max={20}
value={linkedCopy.max_depth}
onChange={(event) => updateLinkedCopy({
max_depth: Math.max(1, Math.min(20, Number(event.target.value) || 1))
})}
/>
</FormField>
<FormField label="Copy behavior">
<select
value={linkedCopy.fanout}
onChange={(event) => {
const fanout = event.target.value as "nearest" | "all";
updateRouting({
...draft.routing_policy,
linked_copy: { ...linkedCopy, fanout },
attention: fanout === "all"
? { mode: "none", delay_minutes: null }
: attention
});
}}
>
<option value="nearest">Nearest matching ancestor</option>
<option value="all">All matching ancestors</option>
</select>
</FormField>
<FormField label="Optional stop unit">
<select
value={linkedCopy.stop_unit_id || ""}
onChange={(event) => updateLinkedCopy({
stop_unit_id: event.target.value || null
})}
>
<option value="">No unit stop</option>
{units.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Optional stop unit type">
<select
value={linkedCopy.stop_unit_type_id || ""}
onChange={(event) => updateLinkedCopy({
stop_unit_type_id: event.target.value || null
})}
>
<option value="">No unit-type stop</option>
{unitTypes.map((item) => (
<option key={item.id} value={item.id}>{item.id}</option>
))}
</select>
</FormField>
<FormField label="Allowed classifications">
<input
value={linkedCopy.allowed_classifications.join(", ")}
onChange={(event) => updateLinkedCopy({
allowed_classifications: commaSeparated(event.target.value)
})}
/>
</FormField>
<FormField label="Authorized producer modules">
<input
placeholder="campaigns, workflow"
value={linkedCopy.allowed_producer_modules.join(", ")}
onChange={(event) => updateLinkedCopy({
allowed_producer_modules: commaSeparated(event.target.value)
})}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Require message expiry"
checked={linkedCopy.require_expiry}
onChange={(require_expiry) => updateLinkedCopy({ require_expiry })}
/>
</div>
<FormField label="Maximum retention days">
<input
type="number"
min={1}
max={36500}
value={linkedCopy.max_retention_days ?? ""}
onChange={(event) => updateLinkedCopy({
max_retention_days: event.target.value
? Math.max(1, Math.min(36500, Number(event.target.value)))
: null
})}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Escalate when the nearest target remains vacant"
checked={attention.mode === "vacancy_escalation"}
onChange={(checked) => updateRouting({
...draft.routing_policy,
attention: checked
? {
mode: "vacancy_escalation",
delay_minutes: attention.delay_minutes || 1440
}
: { mode: "none", delay_minutes: null }
})}
/>
</div>
{attention.mode === "vacancy_escalation" ? (
<FormField label="Vacancy escalation delay (minutes)">
<input
type="number"
min={1}
max={43200}
value={attention.delay_minutes ?? 1440}
onChange={(event) => updateRouting({
...draft.routing_policy,
attention: {
mode: "vacancy_escalation",
delay_minutes: Math.max(
1,
Math.min(43200, Number(event.target.value) || 1)
)
}
})}
/>
</FormField>
) : <div />}
</div>
) : null}
</div>
</div>
<p className="postbox-form-note">
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
@@ -954,7 +1242,8 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
name_pattern: draft.name_pattern,
address_pattern: draft.address_pattern,
classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery
allow_vacant_delivery: draft.allow_vacant_delivery,
routing_policy: draft.routing_policy
};
}
@@ -975,3 +1264,7 @@ function compatibleTargets(
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}
function commaSeparated(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}