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
+52 -4
View File
@@ -110,6 +110,54 @@ export type PostboxOrganizationUnit = {
functions: PostboxOrganizationFunction[];
};
export type PostboxOrganizationRelationType = {
id: string;
slug: string;
name: string;
structure_id?: string | null;
is_hierarchical: boolean;
status: string;
};
export type PostboxOrganizationStructure = {
id: string;
slug: string;
name: string;
structure_kind: string;
status: string;
relation_types: PostboxOrganizationRelationType[];
};
export type PostboxOrganizationTargets = {
units: PostboxOrganizationUnit[];
structures: PostboxOrganizationStructure[];
};
export type PostboxRoutingPolicy = {
linked_copy: {
enabled: boolean;
structure_id?: string | null;
relation_type_ids: string[];
max_depth: number;
stop_unit_id?: string | null;
stop_unit_type_id?: string | null;
target_function_type_id?: string | null;
target_template_id?: string | null;
fanout: "nearest" | "all";
allowed_classifications: string[];
allowed_producer_modules: string[];
require_expiry: boolean;
max_retention_days?: number | null;
};
attention: {
mode: "none" | "vacancy_escalation";
delay_minutes?: number | null;
};
shared_visibility: {
mode: "none";
};
};
export type PostboxTemplateRevision = {
id: string;
revision: number;
@@ -122,7 +170,7 @@ export type PostboxTemplateRevision = {
allow_vacant_delivery: boolean;
encryption_profile: string;
history_policy: Record<string, unknown>;
routing_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy;
retention_policy: Record<string, unknown>;
published_at?: string | null;
created_at: string;
@@ -151,6 +199,7 @@ export type PostboxTemplateRevisionPayload = Pick<
| "address_pattern"
| "classification"
| "allow_vacant_delivery"
| "routing_policy"
>;
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
@@ -269,12 +318,11 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
export async function listPostboxOrganizationTargets(
settings: ApiSettings
): Promise<PostboxOrganizationUnit[]> {
const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>(
): Promise<PostboxOrganizationTargets> {
return apiFetch<PostboxOrganizationTargets>(
settings,
"/api/v1/postbox/admin/organization-targets"
);
return response.units;
}
export function createExactPostbox(
@@ -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))];
}
+30
View File
@@ -441,6 +441,31 @@
padding-bottom: 7px;
}
.postbox-routing-section {
grid-column: 1 / -1;
display: grid;
gap: 14px;
border-top: var(--border-line);
padding-top: 16px;
}
.postbox-routing-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.postbox-routing-heading > div:first-child {
display: grid;
gap: 3px;
}
.postbox-routing-heading span {
color: var(--muted);
font-size: 12px;
}
.postbox-form-note {
margin: 15px 0 0;
color: var(--muted);
@@ -637,4 +662,9 @@
.postbox-message-detail {
padding: 16px;
}
.postbox-routing-heading {
align-items: flex-start;
flex-direction: column;
}
}