Fix routing rule

This commit is contained in:
2026-06-18 14:10:26 +02:00
parent c1633b72d1
commit e70f188633
6 changed files with 667 additions and 177 deletions
+14
View File
@@ -11,6 +11,11 @@ export type ConditionField =
/** Rischio e Riservatezza (N3): verifica il livello gia' impostato sul messaggio */
| 'risk_level'
| 'confidentiality'
/** Campi aggiuntivi del messaggio */
| 'has_attachments' // "true" / "false"
| 'direction' // "inbound" / "outbound"
| 'protocol_type' // "pec_it" / "rem_eu"
| 'body_contains' // testo nel corpo del messaggio
export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
@@ -25,6 +30,15 @@ export type ActionType =
/** Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza */
| 'set_risk_level'
| 'set_confidentiality'
/** Gestione messaggio */
| 'archive'
| 'mark_for_conservation'
/** Scadenzario: valore = giorni (es. "30") o formato "+30d", "+4w", "+1y" */
| 'set_deadline'
/** Fascicolazione: valore = UUID del fascicolo */
| 'add_to_fascicolo'
/** Notifiche multi-canale: valore = UUID del NotificationChannel */
| 'notify_channel'
export interface RoutingRuleCondition {
id: string
@@ -8,10 +8,12 @@ import { Label } from '@/components/ui/Label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
import { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
import { labelsApi } from '@/api/labels.api'
import type { LabelResponse, LabelTreeResponse } from '@/types/api.types'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { fascicoliApi } from '@/api/fascicoli.api'
import { notificationsApi } from '@/api/notifications.api'
import type { LabelResponse } from '@/types/api.types'
import { RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
const FIELD_LABELS: Record<ConditionField, string> = {
@@ -24,6 +26,11 @@ const FIELD_LABELS: Record<ConditionField, string> = {
// Rischio e Riservatezza (N3)
risk_level: 'Livello di rischio',
confidentiality: 'Riservatezza',
// Campi aggiuntivi
has_attachments: 'Ha allegati',
direction: 'Direzione',
protocol_type: 'Protocollo',
body_contains: 'Corpo del messaggio',
}
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
@@ -40,13 +47,24 @@ const ACTION_LABELS: Record<ActionType, string> = {
assign_vbox: 'Assegna Virtual Box',
mark_read: 'Segna come letto',
mark_starred: 'Aggiungi ai preferiti',
notify_webhook: 'Notifica webhook',
notify_webhook: 'Notifica webhook (URL diretto)',
apply_taxonomy: 'Applica classificazione tassonomica',
// Rischio e Riservatezza (N3)
set_risk_level: 'Imposta livello di rischio',
set_confidentiality: 'Imposta riservatezza',
// Gestione messaggio
archive: 'Archivia messaggio',
mark_for_conservation: 'Invia in conservazione digitale',
set_deadline: 'Imposta scadenza (giorni)',
// Fascicolazione
add_to_fascicolo: 'Aggiungi a fascicolo',
// Notifiche multi-canale
notify_channel: 'Notifica tramite canale configurato',
}
// Azioni che NON richiedono un valore aggiuntivo
const ACTIONS_NO_VALUE: ActionType[] = ['mark_read', 'mark_starred', 'archive', 'mark_for_conservation']
/** Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione" */
function buildLabelPath(labelId: string, allLabels: LabelResponse[]): string {
const map = new Map(allLabels.map((l) => [l.id, l]))
@@ -74,6 +92,13 @@ interface Action {
action_value: string
}
const CHANNEL_TYPE_LABELS: Record<string, string> = {
webhook: 'Webhook',
email: 'Email',
telegram: 'Telegram',
whatsapp: 'WhatsApp',
}
export function RoutingRulesPage() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
@@ -92,7 +117,8 @@ export function RoutingRulesPage() {
{ action_type: 'mark_read', action_value: '' }
])
const { data, isLoading } = useQuery({
// Dati di supporto per i selettori
const { data: rulesData, isLoading } = useQuery({
queryKey: ['routing-rules'],
queryFn: () => routingRulesApi.list(),
})
@@ -103,6 +129,27 @@ export function RoutingRulesPage() {
})
const labels = labelsData ?? []
const { data: vboxesData } = useQuery({
queryKey: ['virtual-boxes-brief'],
queryFn: () => virtualBoxesApi.list({ active_only: true, page_size: 200 }),
enabled: showForm,
})
const vboxes = vboxesData?.items ?? []
const { data: fascicoliData } = useQuery({
queryKey: ['fascicoli-brief'],
queryFn: () => fascicoliApi.list({ stato: 'aperto' }),
enabled: showForm,
})
const fascicoli = fascicoliData ?? []
const { data: channelsData } = useQuery({
queryKey: ['notification-channels-brief'],
queryFn: () => notificationsApi.listChannels({ page_size: 200 }),
enabled: showForm,
})
const channels = channelsData?.items ?? []
const createMutation = useMutation({
mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
onSuccess: () => {
@@ -168,7 +215,20 @@ export function RoutingRulesPage() {
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
if (formConditions.some(c => !c.value.trim())) return toast.error('Tutte le condizioni devono avere un valore')
// Le azioni senza valore non richiedono validazione del valore
const conditionsWithValue = formConditions.filter(c => !ACTIONS_NO_VALUE.includes(c.field as ActionType))
if (conditionsWithValue.some(c => !c.value.trim())) {
return toast.error('Tutte le condizioni devono avere un valore')
}
// Verifica azioni che richiedono valore
const actionsNeedingValue: ActionType[] = [
'apply_label', 'assign_vbox', 'notify_webhook', 'apply_taxonomy',
'set_risk_level', 'set_confidentiality', 'set_deadline',
'add_to_fascicolo', 'notify_channel',
]
if (formActions.some(a => actionsNeedingValue.includes(a.action_type) && !a.action_value.trim())) {
return toast.error('Alcune azioni richiedono un valore')
}
const payload: RoutingRuleCreate = {
name: formName.trim(),
@@ -186,7 +246,7 @@ export function RoutingRulesPage() {
}
}
const items = data?.items ?? []
const items = rulesData?.items ?? []
const toggleExpand = (id: string) => {
setExpandedRules(prev => {
@@ -197,6 +257,260 @@ export function RoutingRulesPage() {
})
}
/** Rendering del controllo valore per una condizione in base al field */
const renderConditionValueInput = (cond: Condition, i: number) => {
const update = (val: string) =>
setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: val } : c))
if (cond.field === 'has_label') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
)
}
if (cond.field === 'has_attachments') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona --</option>
<option value="true">Si (ha allegati)</option>
<option value="false">No (senza allegati)</option>
</select>
)
}
if (cond.field === 'direction') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona --</option>
<option value="inbound">In arrivo (inbound)</option>
<option value="outbound">In uscita (outbound)</option>
</select>
)
}
if (cond.field === 'protocol_type') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona --</option>
<option value="pec_it">PEC italiana (pec_it)</option>
<option value="rem_eu">REM europea (rem_eu)</option>
</select>
)
}
if (cond.field === 'risk_level') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona livello --</option>
{RISK_LEVEL_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
if (cond.field === 'confidentiality') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
// Default: testo libero
return (
<Input
className="flex-1"
value={cond.value}
onChange={e => update(e.target.value)}
placeholder={cond.field === 'body_contains' ? 'Parola chiave nel testo...' : 'Valore...'}
/>
)
}
/** Rendering del controllo valore per un'azione in base al tipo */
const renderActionValueInput = (action: Action, i: number) => {
const update = (val: string) =>
setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: val } : a))
if (ACTIONS_NO_VALUE.includes(action.action_type)) {
return null
}
if (action.action_type === 'apply_label') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map((l: LabelResponse) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)
}
if (action.action_type === 'apply_taxonomy') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona classificazione --</option>
{labels.map((l: LabelResponse) => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
)
}
if (action.action_type === 'assign_vbox') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona Virtual Box --</option>
{vboxes.map(v => (
<option key={v.id} value={v.id}>{v.name}{v.label ? ` (${v.label})` : ''}</option>
))}
</select>
)
}
if (action.action_type === 'add_to_fascicolo') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona fascicolo --</option>
{fascicoli.map(f => (
<option key={f.id} value={f.id}>
{f.titolo}{f.numero_pratica ? ` [${f.numero_pratica}]` : ''}
</option>
))}
</select>
)
}
if (action.action_type === 'notify_channel') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona canale --</option>
{channels.map(c => (
<option key={c.id} value={c.id}>
{c.name} ({CHANNEL_TYPE_LABELS[c.channel_type] ?? c.channel_type})
</option>
))}
</select>
)
}
if (action.action_type === 'notify_webhook') {
return (
<Input
className="flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
placeholder="https://..."
/>
)
}
if (action.action_type === 'set_risk_level') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona livello --</option>
{RISK_LEVEL_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
if (action.action_type === 'set_confidentiality') {
return (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => update(e.target.value)}
>
<option value="">-- Seleziona riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
if (action.action_type === 'set_deadline') {
return (
<div className="flex items-center gap-1 flex-1">
<Input
type="number"
min={1}
max={9999}
className="w-24"
value={action.action_value}
onChange={e => update(e.target.value)}
placeholder="30"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">giorni dalla ricezione</span>
</div>
)
}
return null
}
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
@@ -206,7 +520,7 @@ export function RoutingRulesPage() {
Regole di smistamento
</h1>
<p className="text-sm text-muted-foreground">
Applica automaticamente etichette e azioni ai messaggi in arrivo
Applica automaticamente etichette, scadenze e azioni ai messaggi in arrivo
</p>
</div>
<Button onClick={openCreate}>
@@ -289,7 +603,11 @@ export function RoutingRulesPage() {
{rule.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-blue-50 rounded px-3 py-1.5 mb-1">
<span className="font-medium text-blue-700">{ACTION_LABELS[a.action_type as ActionType] ?? a.action_type}</span>
{a.action_value && <span className="font-mono text-blue-600">{a.action_value}</span>}
{a.action_value && (
a.action_type === 'set_deadline'
? <span className="font-mono text-blue-600">{a.action_value} giorni</span>
: <span className="font-mono text-blue-600">{a.action_value}</span>
)}
</div>
))}
</div>
@@ -340,7 +658,7 @@ export function RoutingRulesPage() {
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.field}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, field: e.target.value as ConditionField } : c))}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, field: e.target.value as ConditionField, value: '' } : c))}
>
{(Object.entries(FIELD_LABELS) as [ConditionField, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
@@ -351,27 +669,7 @@ export function RoutingRulesPage() {
>
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{cond.field === 'has_label' ? (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
) : (
<Input
className="flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
placeholder="Valore..."
/>
)}
{renderConditionValueInput(cond, i)}
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
@@ -396,63 +694,7 @@ export function RoutingRulesPage() {
>
{(Object.entries(ACTION_LABELS) as [ActionType, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{(action.action_type === 'apply_label') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map((l: LabelResponse) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)}
{(action.action_type === 'apply_taxonomy') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona classificazione --</option>
{labels.map((l: LabelResponse) => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
)}
{(action.action_type === 'notify_webhook') && (
<Input
className="flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
placeholder="https://..."
/>
)}
{/* Rischio e Riservatezza (N3) */}
{(action.action_type === 'set_risk_level') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona livello --</option>
{RISK_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{(action.action_type === 'set_confidentiality') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{renderActionValueInput(action, i)}
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>