feat: route bounded hierarchy postbox copies
This commit is contained in:
@@ -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))];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user