Fix routing rule
This commit is contained in:
@@ -20,6 +20,11 @@ CONDITION_FIELDS = Literal[
|
|||||||
# Rischio e Riservatezza (N3): verifica il livello gia' impostato
|
# Rischio e Riservatezza (N3): verifica il livello gia' impostato
|
||||||
"risk_level",
|
"risk_level",
|
||||||
"confidentiality",
|
"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 (usa operator contains/regex)
|
||||||
]
|
]
|
||||||
# Operatori supportati
|
# Operatori supportati
|
||||||
CONDITION_OPERATORS = Literal[
|
CONDITION_OPERATORS = Literal[
|
||||||
@@ -37,6 +42,19 @@ ACTION_TYPES = Literal[
|
|||||||
# Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza
|
# Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza
|
||||||
"set_risk_level",
|
"set_risk_level",
|
||||||
"set_confidentiality",
|
"set_confidentiality",
|
||||||
|
# Gestione messaggio
|
||||||
|
"archive", # archivia il messaggio
|
||||||
|
"mark_for_conservation", # marca per la conservazione digitale immediata
|
||||||
|
# Scadenzario (Feature 4): imposta una scadenza relativa
|
||||||
|
# action_value = numero di giorni (es. "30") oppure "+30d", "+4w", "+1y"
|
||||||
|
# oppure JSON {"days": 30, "note": "Testo promemoria"}
|
||||||
|
"set_deadline",
|
||||||
|
# Fascicolazione (Feature N5): aggiunge il messaggio a un fascicolo esistente
|
||||||
|
# action_value = UUID del fascicolo
|
||||||
|
"add_to_fascicolo",
|
||||||
|
# Notifiche multi-canale: invia tramite un canale configurato (email/telegram/whatsapp/webhook)
|
||||||
|
# action_value = UUID del NotificationChannel
|
||||||
|
"notify_channel",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -245,6 +246,16 @@ class RoutingRuleService:
|
|||||||
return message.risk_level or ""
|
return message.risk_level or ""
|
||||||
elif field == "confidentiality":
|
elif field == "confidentiality":
|
||||||
return message.confidentiality or ""
|
return message.confidentiality or ""
|
||||||
|
# Campi aggiuntivi
|
||||||
|
elif field == "has_attachments":
|
||||||
|
return "true" if message.has_attachments else "false"
|
||||||
|
elif field == "direction":
|
||||||
|
return message.direction or ""
|
||||||
|
elif field == "protocol_type":
|
||||||
|
return message.protocol_type or ""
|
||||||
|
elif field == "body_contains":
|
||||||
|
# Restituisce il corpo del messaggio per confronto con contains/regex
|
||||||
|
return (message.body_text or "").lower()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _evaluate_condition(
|
def _evaluate_condition(
|
||||||
@@ -279,29 +290,58 @@ class RoutingRuleService:
|
|||||||
try:
|
try:
|
||||||
if action.action_type == "apply_label" and action.action_value:
|
if action.action_type == "apply_label" and action.action_value:
|
||||||
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
||||||
|
|
||||||
elif action.action_type == "apply_taxonomy" and action.action_value:
|
elif action.action_type == "apply_taxonomy" and action.action_value:
|
||||||
# Applica un nodo tassonomico: identico a apply_label
|
# Applica un nodo tassonomico: identico a apply_label
|
||||||
# Il nodo è una Label con parent_id valorizzato (Processo o Classificazione)
|
|
||||||
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
||||||
|
|
||||||
|
elif action.action_type == "assign_vbox" and action.action_value:
|
||||||
|
await self._action_assign_vbox(message, uuid.UUID(action.action_value))
|
||||||
|
|
||||||
elif action.action_type == "mark_read":
|
elif action.action_type == "mark_read":
|
||||||
message.is_read = True
|
message.is_read = True
|
||||||
|
|
||||||
elif action.action_type == "mark_starred":
|
elif action.action_type == "mark_starred":
|
||||||
message.is_starred = True
|
message.is_starred = True
|
||||||
|
|
||||||
|
elif action.action_type == "archive":
|
||||||
|
message.is_archived = True
|
||||||
|
message.archived_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
elif action.action_type == "mark_for_conservation":
|
||||||
|
if not message.is_pending_conservation:
|
||||||
|
message.is_pending_conservation = True
|
||||||
|
message.pending_conservation_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
elif action.action_type == "set_deadline" and action.action_value:
|
||||||
|
await self._action_set_deadline(message, action.action_value)
|
||||||
|
|
||||||
|
elif action.action_type == "add_to_fascicolo" and action.action_value:
|
||||||
|
await self._action_add_to_fascicolo(message, uuid.UUID(action.action_value))
|
||||||
|
|
||||||
elif action.action_type == "notify_webhook" and action.action_value:
|
elif action.action_type == "notify_webhook" and action.action_value:
|
||||||
await self._action_notify_webhook(message, action.action_value)
|
await self._action_notify_webhook(message, action.action_value)
|
||||||
|
|
||||||
|
elif action.action_type == "notify_channel" and action.action_value:
|
||||||
|
await self._action_notify_channel(message, uuid.UUID(action.action_value))
|
||||||
|
|
||||||
# Rischio e Riservatezza (N3)
|
# Rischio e Riservatezza (N3)
|
||||||
elif action.action_type == "set_risk_level" and action.action_value:
|
elif action.action_type == "set_risk_level" and action.action_value:
|
||||||
valid_levels = {"low", "medium", "high", "critical"}
|
valid_levels = {"low", "medium", "high", "critical"}
|
||||||
if action.action_value in valid_levels:
|
if action.action_value in valid_levels:
|
||||||
message.risk_level = action.action_value
|
message.risk_level = action.action_value
|
||||||
|
|
||||||
elif action.action_type == "set_confidentiality" and action.action_value:
|
elif action.action_type == "set_confidentiality" and action.action_value:
|
||||||
valid_levels = {"public", "internal", "confidential", "secret"}
|
valid_levels = {"public", "internal", "confidential", "secret"}
|
||||||
if action.action_value in valid_levels:
|
if action.action_value in valid_levels:
|
||||||
message.confidentiality = action.action_value
|
message.confidentiality = action.action_value
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Le azioni non devono interrompere il flusso principale
|
# Le azioni non devono interrompere il flusso principale
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ─── Implementazioni azioni ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def _action_apply_label(
|
async def _action_apply_label(
|
||||||
self, message: Message, label_id: uuid.UUID
|
self, message: Message, label_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -325,6 +365,166 @@ class RoutingRuleService:
|
|||||||
if not existing.scalar_one_or_none():
|
if not existing.scalar_one_or_none():
|
||||||
self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
|
self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
|
||||||
|
|
||||||
|
async def _action_assign_vbox(
|
||||||
|
self, message: Message, vbox_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Assegna il messaggio alla Virtual Box trovando la Label associata.
|
||||||
|
|
||||||
|
Le Virtual Box sono filtri read-only: per "assegnare" un messaggio a una VBox
|
||||||
|
si applica la Label il cui nome corrisponde al campo 'label' della VBox.
|
||||||
|
Se la VBox non ha un campo 'label' o non esiste una Label con quel nome,
|
||||||
|
l'azione viene saltata silenziosamente.
|
||||||
|
"""
|
||||||
|
from app.models.virtual_box import VirtualBox
|
||||||
|
|
||||||
|
vbox_result = await self.db.execute(
|
||||||
|
select(VirtualBox).where(
|
||||||
|
VirtualBox.id == vbox_id,
|
||||||
|
VirtualBox.tenant_id == message.tenant_id,
|
||||||
|
VirtualBox.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
vbox = vbox_result.scalar_one_or_none()
|
||||||
|
if not vbox or not vbox.label:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cerca la Label con lo stesso nome del campo 'label' della VBox
|
||||||
|
label_result = await self.db.execute(
|
||||||
|
select(Label).where(
|
||||||
|
Label.name == vbox.label,
|
||||||
|
Label.tenant_id == message.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
label = label_result.scalar_one_or_none()
|
||||||
|
if label:
|
||||||
|
await self._action_apply_label(message, label.id)
|
||||||
|
|
||||||
|
async def _action_set_deadline(
|
||||||
|
self, message: Message, value: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Imposta una scadenza relativa al messaggio.
|
||||||
|
|
||||||
|
Formati accettati per action_value:
|
||||||
|
- Numero intero: "30" → +30 giorni
|
||||||
|
- Suffisso giorni: "+30d" → +30 giorni
|
||||||
|
- Suffisso settimane:"+4w" → +28 giorni
|
||||||
|
- Suffisso mesi: "+2m" → +60 giorni
|
||||||
|
- Suffisso anni: "+1y" → +365 giorni
|
||||||
|
- JSON: {"days": 30, "note": "Risposta entro 30 giorni"}
|
||||||
|
|
||||||
|
Non sovrascrive una scadenza gia' impostata manualmente.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
value = value.strip()
|
||||||
|
days: int | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
# Prova JSON
|
||||||
|
try:
|
||||||
|
parsed = _json.loads(value)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
days = int(parsed.get("days", 0)) or None
|
||||||
|
note = parsed.get("note")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Prova formato stringa: "30", "+30d", "+4w", "+2m", "+1y"
|
||||||
|
if days is None:
|
||||||
|
m = re.match(r"^\+?(\d+)([dDwWmMyY]?)$", value)
|
||||||
|
if m:
|
||||||
|
n = int(m.group(1))
|
||||||
|
unit = m.group(2).lower() if m.group(2) else "d"
|
||||||
|
if unit == "w":
|
||||||
|
days = n * 7
|
||||||
|
elif unit == "m":
|
||||||
|
days = n * 30
|
||||||
|
elif unit == "y":
|
||||||
|
days = n * 365
|
||||||
|
else:
|
||||||
|
days = n
|
||||||
|
|
||||||
|
if days and days > 0:
|
||||||
|
base = message.received_at or datetime.now(timezone.utc)
|
||||||
|
message.deadline_at = base + timedelta(days=days)
|
||||||
|
if note and not message.deadline_note:
|
||||||
|
message.deadline_note = note
|
||||||
|
|
||||||
|
async def _action_add_to_fascicolo(
|
||||||
|
self, message: Message, fascicolo_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Aggiunge il messaggio a un fascicolo esistente (se non gia' presente).
|
||||||
|
|
||||||
|
Verifica che il fascicolo appartenga al tenant e non sia gia' archiviato.
|
||||||
|
"""
|
||||||
|
from app.models.fascicolo import Fascicolo, FascicoloMessage
|
||||||
|
|
||||||
|
# Verifica che il fascicolo esista, appartenga al tenant e non sia archiviato
|
||||||
|
fascicolo_result = await self.db.execute(
|
||||||
|
select(Fascicolo).where(
|
||||||
|
Fascicolo.id == fascicolo_id,
|
||||||
|
Fascicolo.tenant_id == message.tenant_id,
|
||||||
|
Fascicolo.stato != "archiviato",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not fascicolo_result.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verifica che il messaggio non sia gia' nel fascicolo
|
||||||
|
existing = await self.db.execute(
|
||||||
|
select(FascicoloMessage).where(
|
||||||
|
FascicoloMessage.fascicolo_id == fascicolo_id,
|
||||||
|
FascicoloMessage.message_id == message.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not existing.scalar_one_or_none():
|
||||||
|
self.db.add(FascicoloMessage(
|
||||||
|
fascicolo_id=fascicolo_id,
|
||||||
|
message_id=message.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def _action_notify_channel(
|
||||||
|
self, message: Message, channel_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Invia una notifica tramite un canale configurato (webhook/email/telegram/whatsapp).
|
||||||
|
|
||||||
|
Crea un NotificationLog con status 'pending' che il worker processerà
|
||||||
|
tramite il meccanismo di retry esistente.
|
||||||
|
"""
|
||||||
|
from app.models.notification import NotificationChannel, NotificationLog
|
||||||
|
|
||||||
|
channel_result = await self.db.execute(
|
||||||
|
select(NotificationChannel).where(
|
||||||
|
NotificationChannel.id == channel_id,
|
||||||
|
NotificationChannel.tenant_id == message.tenant_id,
|
||||||
|
NotificationChannel.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
channel = channel_result.scalar_one_or_none()
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Crea il log per l'invio asincrono da parte del worker
|
||||||
|
self.db.add(NotificationLog(
|
||||||
|
tenant_id=message.tenant_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
event_type="routing_rule_match",
|
||||||
|
event_payload={
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"subject": message.subject,
|
||||||
|
"from_address": message.from_address,
|
||||||
|
"pec_type": message.pec_type,
|
||||||
|
"mailbox_id": str(message.mailbox_id),
|
||||||
|
"direction": message.direction,
|
||||||
|
},
|
||||||
|
status="pending",
|
||||||
|
max_attempts=3,
|
||||||
|
))
|
||||||
|
|
||||||
async def _action_notify_webhook(self, message: Message, url: str) -> None:
|
async def _action_notify_webhook(self, message: Message, url: str) -> None:
|
||||||
"""Invia una notifica webhook per il messaggio."""
|
"""Invia una notifica webhook per il messaggio."""
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export type ConditionField =
|
|||||||
/** Rischio e Riservatezza (N3): verifica il livello gia' impostato sul messaggio */
|
/** Rischio e Riservatezza (N3): verifica il livello gia' impostato sul messaggio */
|
||||||
| 'risk_level'
|
| 'risk_level'
|
||||||
| 'confidentiality'
|
| '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'
|
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 */
|
/** Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza */
|
||||||
| 'set_risk_level'
|
| 'set_risk_level'
|
||||||
| 'set_confidentiality'
|
| '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 {
|
export interface RoutingRuleCondition {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { Label } from '@/components/ui/Label'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
|
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 { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
|
||||||
import { labelsApi } from '@/api/labels.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 { RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
|
||||||
import { getErrorMessage } from '@/api/client'
|
import { getErrorMessage } from '@/api/client'
|
||||||
import { formatDate } from '@/lib/utils'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const FIELD_LABELS: Record<ConditionField, string> = {
|
const FIELD_LABELS: Record<ConditionField, string> = {
|
||||||
@@ -24,6 +26,11 @@ const FIELD_LABELS: Record<ConditionField, string> = {
|
|||||||
// Rischio e Riservatezza (N3)
|
// Rischio e Riservatezza (N3)
|
||||||
risk_level: 'Livello di rischio',
|
risk_level: 'Livello di rischio',
|
||||||
confidentiality: 'Riservatezza',
|
confidentiality: 'Riservatezza',
|
||||||
|
// Campi aggiuntivi
|
||||||
|
has_attachments: 'Ha allegati',
|
||||||
|
direction: 'Direzione',
|
||||||
|
protocol_type: 'Protocollo',
|
||||||
|
body_contains: 'Corpo del messaggio',
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
|
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
|
||||||
@@ -40,13 +47,24 @@ const ACTION_LABELS: Record<ActionType, string> = {
|
|||||||
assign_vbox: 'Assegna Virtual Box',
|
assign_vbox: 'Assegna Virtual Box',
|
||||||
mark_read: 'Segna come letto',
|
mark_read: 'Segna come letto',
|
||||||
mark_starred: 'Aggiungi ai preferiti',
|
mark_starred: 'Aggiungi ai preferiti',
|
||||||
notify_webhook: 'Notifica webhook',
|
notify_webhook: 'Notifica webhook (URL diretto)',
|
||||||
apply_taxonomy: 'Applica classificazione tassonomica',
|
apply_taxonomy: 'Applica classificazione tassonomica',
|
||||||
// Rischio e Riservatezza (N3)
|
// Rischio e Riservatezza (N3)
|
||||||
set_risk_level: 'Imposta livello di rischio',
|
set_risk_level: 'Imposta livello di rischio',
|
||||||
set_confidentiality: 'Imposta riservatezza',
|
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" */
|
/** Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione" */
|
||||||
function buildLabelPath(labelId: string, allLabels: LabelResponse[]): string {
|
function buildLabelPath(labelId: string, allLabels: LabelResponse[]): string {
|
||||||
const map = new Map(allLabels.map((l) => [l.id, l]))
|
const map = new Map(allLabels.map((l) => [l.id, l]))
|
||||||
@@ -74,6 +92,13 @@ interface Action {
|
|||||||
action_value: string
|
action_value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHANNEL_TYPE_LABELS: Record<string, string> = {
|
||||||
|
webhook: 'Webhook',
|
||||||
|
email: 'Email',
|
||||||
|
telegram: 'Telegram',
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
}
|
||||||
|
|
||||||
export function RoutingRulesPage() {
|
export function RoutingRulesPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
@@ -92,7 +117,8 @@ export function RoutingRulesPage() {
|
|||||||
{ action_type: 'mark_read', action_value: '' }
|
{ action_type: 'mark_read', action_value: '' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
// Dati di supporto per i selettori
|
||||||
|
const { data: rulesData, isLoading } = useQuery({
|
||||||
queryKey: ['routing-rules'],
|
queryKey: ['routing-rules'],
|
||||||
queryFn: () => routingRulesApi.list(),
|
queryFn: () => routingRulesApi.list(),
|
||||||
})
|
})
|
||||||
@@ -103,6 +129,27 @@ export function RoutingRulesPage() {
|
|||||||
})
|
})
|
||||||
const labels = labelsData ?? []
|
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({
|
const createMutation = useMutation({
|
||||||
mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
|
mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -168,7 +215,20 @@ export function RoutingRulesPage() {
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
|
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 = {
|
const payload: RoutingRuleCreate = {
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
@@ -186,7 +246,7 @@ export function RoutingRulesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = data?.items ?? []
|
const items = rulesData?.items ?? []
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpandedRules(prev => {
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
<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
|
Regole di smistamento
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
@@ -289,7 +603,11 @@ export function RoutingRulesPage() {
|
|||||||
{rule.actions.map((a, i) => (
|
{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">
|
<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>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +658,7 @@ export function RoutingRulesPage() {
|
|||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||||
value={cond.field}
|
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>)}
|
{(Object.entries(FIELD_LABELS) as [ConditionField, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</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>)}
|
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{cond.field === 'has_label' ? (
|
{renderConditionValueInput(cond, i)}
|
||||||
<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..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</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>)}
|
{(Object.entries(ACTION_LABELS) as [ActionType, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
{(action.action_type === 'apply_label') && (
|
{renderActionValueInput(action, i)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+68
-54
@@ -213,10 +213,13 @@ async def sync_new_messages(
|
|||||||
|
|
||||||
synced_count = 0
|
synced_count = 0
|
||||||
max_uid_synced = last_uid
|
max_uid_synced = last_uid
|
||||||
|
# Raccoglie gli ID per i job da accodare DOPO il commit (evita race condition)
|
||||||
|
routing_ids: list[str] = []
|
||||||
|
notification_log_ids: list[str] = []
|
||||||
|
|
||||||
for seq in seq_numbers:
|
for seq in seq_numbers:
|
||||||
try:
|
try:
|
||||||
uid, synced = await _fetch_and_save_message_by_seq(
|
uid, synced, routing_id, notif_ids = await _fetch_and_save_message_by_seq(
|
||||||
imap_client=imap_client,
|
imap_client=imap_client,
|
||||||
seq=seq,
|
seq=seq,
|
||||||
last_uid=last_uid,
|
last_uid=last_uid,
|
||||||
@@ -230,6 +233,9 @@ async def sync_new_messages(
|
|||||||
if synced and uid and uid > max_uid_synced:
|
if synced and uid and uid > max_uid_synced:
|
||||||
synced_count += 1
|
synced_count += 1
|
||||||
max_uid_synced = uid
|
max_uid_synced = uid
|
||||||
|
if routing_id:
|
||||||
|
routing_ids.append(routing_id)
|
||||||
|
notification_log_ids.extend(notif_ids)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[{mailbox.email_address}] Errore fetch INBOX seq {seq}: {e}",
|
f"[{mailbox.email_address}] Errore fetch INBOX seq {seq}: {e}",
|
||||||
@@ -248,6 +254,24 @@ async def sync_new_messages(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Accoda i job di smistamento e notifiche DOPO il commit per evitare
|
||||||
|
# race condition: i job aprono nuove sessioni DB e devono trovare i record.
|
||||||
|
if redis_client:
|
||||||
|
for msg_id in routing_ids:
|
||||||
|
try:
|
||||||
|
await redis_client.enqueue_job("apply_routing_rules", msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mailbox.email_address}] Impossibile enqueue apply_routing_rules: {e}"
|
||||||
|
)
|
||||||
|
for log_id in notification_log_ids:
|
||||||
|
try:
|
||||||
|
await redis_client.enqueue_job("dispatch_notification", log_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mailbox.email_address}] Impossibile enqueue dispatch_notification: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return synced_count
|
return synced_count
|
||||||
|
|
||||||
|
|
||||||
@@ -320,7 +344,8 @@ async def sync_sent_messages(
|
|||||||
|
|
||||||
for seq in seq_numbers:
|
for seq in seq_numbers:
|
||||||
try:
|
try:
|
||||||
uid, synced = await _fetch_and_save_message_by_seq(
|
# I messaggi outbound non generano routing rules ne' notifiche: ignoriamo i valori
|
||||||
|
uid, synced, _, _ = await _fetch_and_save_message_by_seq(
|
||||||
imap_client=imap_client,
|
imap_client=imap_client,
|
||||||
seq=seq,
|
seq=seq,
|
||||||
last_uid=last_uid,
|
last_uid=last_uid,
|
||||||
@@ -339,37 +364,12 @@ async def sync_sent_messages(
|
|||||||
f"[{mailbox.email_address}] Errore fetch {sent_folder!r} seq {seq}: {e}",
|
f"[{mailbox.email_address}] Errore fetch {sent_folder!r} seq {seq}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Aggiorna sent_last_sync_uid
|
|
||||||
if max_uid_synced > last_uid:
|
|
||||||
mailbox.sent_last_sync_uid = max_uid_synced
|
|
||||||
await db.flush()
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[{mailbox.email_address}] Sync Sent completata: {synced_count} messaggi nuovi"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ri-seleziona INBOX per tornare allo stato normale
|
|
||||||
try:
|
|
||||||
await imap_client.select("INBOX")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[{mailbox.email_address}] Re-SELECT INBOX dopo Sent sync: {e}")
|
|
||||||
|
|
||||||
return synced_count
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_and_save_message_by_seq(
|
|
||||||
imap_client: aioimaplib.IMAP4 | aioimaplib.IMAP4_SSL,
|
|
||||||
seq: str,
|
|
||||||
last_uid: int,
|
|
||||||
mailbox: Mailbox,
|
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
redis_client: aioredis.Redis,
|
redis_client: aioredis.Redis,
|
||||||
imap_folder: str = "INBOX",
|
imap_folder: str = "INBOX",
|
||||||
direction: str = "inbound",
|
direction: str = "inbound",
|
||||||
state: str = "received",
|
state: str = "received",
|
||||||
) -> tuple[int | None, bool]:
|
) -> tuple[int | None, bool, str | None, list[str]]:
|
||||||
"""
|
"""
|
||||||
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
|
Fetcha un singolo messaggio per NUMERO DI SEQUENZA (non UID).
|
||||||
|
|
||||||
@@ -379,19 +379,23 @@ async def _fetch_and_save_message_by_seq(
|
|||||||
state: 'received' per INBOX, 'sent' per Sent
|
state: 'received' per INBOX, 'sent' per Sent
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(uid, saved): UID del messaggio e True se salvato, False altrimenti.
|
(uid, saved, routing_msg_id, notification_log_ids):
|
||||||
|
- uid: UID IMAP del messaggio
|
||||||
|
- saved: True se il messaggio e' stato salvato
|
||||||
|
- routing_msg_id: str(message.id) se routing rules vanno applicate
|
||||||
|
- notification_log_ids: lista di log_id per dispatch_notification
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)")
|
status, fetch_data = await imap_client.fetch(seq, "(UID RFC822 RFC822.SIZE)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{mailbox.email_address}] FETCH seq {seq} fallito: {e}")
|
logger.error(f"[{mailbox.email_address}] FETCH seq {seq} fallito: {e}")
|
||||||
return None, False
|
return None, False, None, []
|
||||||
|
|
||||||
if status != "OK" or not fetch_data:
|
if status != "OK" or not fetch_data:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{mailbox.email_address}] FETCH seq {seq} risposta vuota: {status}"
|
f"[{mailbox.email_address}] FETCH seq {seq} risposta vuota: {status}"
|
||||||
)
|
)
|
||||||
return None, False
|
return None, False, None, []
|
||||||
|
|
||||||
items_info = [(type(x).__name__, len(x) if isinstance(x, (bytes, str)) else str(x)) for x in fetch_data]
|
items_info = [(type(x).__name__, len(x) if isinstance(x, (bytes, str)) else str(x)) for x in fetch_data]
|
||||||
logger.debug(f"[{mailbox.email_address}] fetch_data seq {seq}: {items_info}")
|
logger.debug(f"[{mailbox.email_address}] fetch_data seq {seq}: {items_info}")
|
||||||
@@ -421,16 +425,16 @@ async def _fetch_and_save_message_by_seq(
|
|||||||
size_bytes = int(size_match.group(1))
|
size_bytes = int(size_match.group(1))
|
||||||
|
|
||||||
if uid is None or uid <= last_uid:
|
if uid is None or uid <= last_uid:
|
||||||
return uid, False
|
return uid, False, None, []
|
||||||
|
|
||||||
if not raw_eml:
|
if not raw_eml:
|
||||||
logger.warning(f"[{mailbox.email_address}] seq {seq} UID {uid}: body mancante")
|
logger.warning(f"[{mailbox.email_address}] seq {seq} UID {uid}: body mancante")
|
||||||
return uid, False
|
return uid, False, None, []
|
||||||
|
|
||||||
if size_bytes is None:
|
if size_bytes is None:
|
||||||
size_bytes = len(raw_eml)
|
size_bytes = len(raw_eml)
|
||||||
|
|
||||||
return uid, await _save_message(
|
saved, routing_id, notif_ids = await _save_message(
|
||||||
uid=uid,
|
uid=uid,
|
||||||
raw_eml=raw_eml,
|
raw_eml=raw_eml,
|
||||||
size_bytes=size_bytes,
|
size_bytes=size_bytes,
|
||||||
@@ -441,6 +445,7 @@ async def _fetch_and_save_message_by_seq(
|
|||||||
direction=direction,
|
direction=direction,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
|
return uid, saved, routing_id, notif_ids
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_and_save_message(
|
async def _fetch_and_save_message(
|
||||||
@@ -487,7 +492,7 @@ async def _fetch_and_save_message(
|
|||||||
if not raw_eml:
|
if not raw_eml:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return await _save_message(
|
saved, _, _ = await _save_message(
|
||||||
uid=uid,
|
uid=uid,
|
||||||
raw_eml=raw_eml,
|
raw_eml=raw_eml,
|
||||||
size_bytes=size_bytes or len(raw_eml),
|
size_bytes=size_bytes or len(raw_eml),
|
||||||
@@ -498,6 +503,7 @@ async def _fetch_and_save_message(
|
|||||||
direction="inbound",
|
direction="inbound",
|
||||||
state="received",
|
state="received",
|
||||||
)
|
)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
# ─── Save message (Fase 3 – con parser completo, allegati, state machine) ────
|
# ─── Save message (Fase 3 – con parser completo, allegati, state machine) ────
|
||||||
@@ -512,10 +518,19 @@ async def _save_message(
|
|||||||
imap_folder: str = "INBOX",
|
imap_folder: str = "INBOX",
|
||||||
direction: str = "inbound",
|
direction: str = "inbound",
|
||||||
state: str = "received",
|
state: str = "received",
|
||||||
) -> bool:
|
) -> tuple[bool, str | None, list[str]]:
|
||||||
"""
|
"""
|
||||||
Salva un messaggio EML in DB e su MinIO.
|
Salva un messaggio EML in DB e su MinIO.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple (saved, routing_msg_id, notification_log_ids):
|
||||||
|
- saved: True se il messaggio e' stato salvato
|
||||||
|
- routing_msg_id: str(message.id) se routing rules devono essere applicate, None altrimenti
|
||||||
|
- notification_log_ids: lista di UUID stringa dei NotificationLog creati
|
||||||
|
|
||||||
|
IMPORTANTE: il chiamante deve accodare i job apply_routing_rules e
|
||||||
|
dispatch_notification DOPO db.commit() per evitare race condition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
imap_folder: cartella IMAP di provenienza ('INBOX', 'Sent', ecc.)
|
imap_folder: cartella IMAP di provenienza ('INBOX', 'Sent', ecc.)
|
||||||
direction: 'inbound' per posta in arrivo, 'outbound' per posta inviata
|
direction: 'inbound' per posta in arrivo, 'outbound' per posta inviata
|
||||||
@@ -542,7 +557,7 @@ async def _save_message(
|
|||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
|
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
|
||||||
return False
|
return False, None, []
|
||||||
|
|
||||||
# ── Classificazione tipo messaggio da header (veloce, senza body) ────────
|
# ── Classificazione tipo messaggio da header (veloce, senza body) ────────
|
||||||
# La classificazione avviene PRIMA del parsing completo perche' il parser
|
# La classificazione avviene PRIMA del parsing completo perche' il parser
|
||||||
@@ -632,7 +647,7 @@ async def _save_message(
|
|||||||
f"message_id={parsed.message_id!r} con imap_uid={uid} "
|
f"message_id={parsed.message_id!r} con imap_uid={uid} "
|
||||||
f"folder={imap_folder!r} (evitato duplicato outbound)"
|
f"folder={imap_folder!r} (evitato duplicato outbound)"
|
||||||
)
|
)
|
||||||
return True
|
return True, None, []
|
||||||
|
|
||||||
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
|
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
|
||||||
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
|
# Solo per messaggi inbound che sono ricevute PEC (non per posta inviata)
|
||||||
@@ -740,27 +755,18 @@ async def _save_message(
|
|||||||
# sia il messaggio che gli allegati dalla sessione corrente.
|
# sia il messaggio che gli allegati dalla sessione corrente.
|
||||||
await index_message(message.id, db)
|
await index_message(message.id, db)
|
||||||
|
|
||||||
# ── Valutazione e accodamento notifiche (non bloccante) ───────────────────
|
# ── Valutazione notifiche (crea NotificationLog, NON accoda job) ──────────
|
||||||
# Solo per messaggi inbound: le ricevute PEC e la posta in arrivo
|
# Solo per messaggi inbound. I job dispatch_notification vengono accodati
|
||||||
# possono triggerare regole di notifica configurate dal tenant.
|
# dal chiamante DOPO db.commit() per evitare race condition:
|
||||||
# I messaggi outbound (Sent) non generano notifiche automatiche.
|
# il job apre una nuova sessione DB e deve trovare il record gia' committato.
|
||||||
|
notification_log_ids: list[str] = []
|
||||||
if direction == "inbound":
|
if direction == "inbound":
|
||||||
await evaluate_and_enqueue_notifications(
|
notification_log_ids = await evaluate_and_enqueue_notifications(
|
||||||
message=message,
|
message=message,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
db=db,
|
db=db,
|
||||||
redis_client=redis_client,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
|
|
||||||
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
|
|
||||||
# Valido anche per messaggi REM (REMDispatch e' equivalente a posta_certificata).
|
|
||||||
if direction == "inbound" and _pec_type == "posta_certificata":
|
|
||||||
try:
|
|
||||||
await redis_client.enqueue_job("apply_routing_rules", str(message.id))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[{mailbox.email_address}] Impossibile enqueue apply_routing_rules: {e}")
|
|
||||||
|
|
||||||
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
|
# ── Auto-save mittente nella rubrica (Feature 6) ──────────────────────────
|
||||||
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
|
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
|
||||||
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
|
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
|
||||||
@@ -779,7 +785,15 @@ async def _save_message(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
|
logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
|
||||||
|
|
||||||
return True
|
# ── Routing rules: segnala il messaggio al chiamante ──────────────────────
|
||||||
|
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
|
||||||
|
# Il job apply_routing_rules viene accodato dal chiamante DOPO db.commit()
|
||||||
|
# per evitare race condition (il job apre una nuova sessione DB).
|
||||||
|
routing_msg_id: str | None = None
|
||||||
|
if direction == "inbound" and _pec_type == "posta_certificata":
|
||||||
|
routing_msg_id = str(message.id)
|
||||||
|
|
||||||
|
return True, routing_msg_id, notification_log_ids
|
||||||
|
|
||||||
|
|
||||||
async def _apply_outbound_state_machine(
|
async def _apply_outbound_state_machine(
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ Flusso completo:
|
|||||||
- legge le NotificationRule attive del tenant con event_type="new_message"
|
- legge le NotificationRule attive del tenant con event_type="new_message"
|
||||||
- applica i filtri opzionali (mailbox_id, direction, pec_type)
|
- applica i filtri opzionali (mailbox_id, direction, pec_type)
|
||||||
- per ogni regola che matcha: crea NotificationLog(status=pending)
|
- per ogni regola che matcha: crea NotificationLog(status=pending)
|
||||||
- enqueue del job arq dispatch_notification con defer 5s
|
- restituisce lista di log_id: il chiamante accoda i job DOPO db.commit()
|
||||||
(per dare tempo al DB di committare prima che il job legga)
|
(evita race condition: il job apre una nuova sessione DB)
|
||||||
4. dispatch_notification(ctx, notification_log_id):
|
4. dispatch_notification(ctx, notification_log_id):
|
||||||
- legge NotificationLog + NotificationChannel dal DB
|
- legge NotificationLog + NotificationChannel dal DB
|
||||||
- controlla circuit breaker
|
- controlla circuit breaker
|
||||||
@@ -234,10 +234,14 @@ async def evaluate_and_enqueue_notifications(
|
|||||||
message: Message,
|
message: Message,
|
||||||
mailbox: Mailbox,
|
mailbox: Mailbox,
|
||||||
db: Any, # AsyncSession – evito import circolare
|
db: Any, # AsyncSession – evito import circolare
|
||||||
redis_client: Any, # ArqRedis
|
) -> list[str]:
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Valuta le regole di notifica per un messaggio appena salvato e accoda i job.
|
Valuta le regole di notifica per un messaggio appena salvato,
|
||||||
|
crea i NotificationLog e restituisce i loro ID.
|
||||||
|
|
||||||
|
NON accoda i job arq: il chiamante deve farlo DOPO db.commit()
|
||||||
|
per evitare race condition (il job dispatch_notification apre una nuova
|
||||||
|
sessione DB e deve trovare il NotificationLog gia' committato).
|
||||||
|
|
||||||
Chiamata da sync.py dopo _save_message e index_message.
|
Chiamata da sync.py dopo _save_message e index_message.
|
||||||
Non solleva eccezioni: gli errori vengono loggati ma non propagati per
|
Non solleva eccezioni: gli errori vengono loggati ma non propagati per
|
||||||
@@ -247,25 +251,32 @@ async def evaluate_and_enqueue_notifications(
|
|||||||
message: messaggio appena salvato nel DB (flush, non commit)
|
message: messaggio appena salvato nel DB (flush, non commit)
|
||||||
mailbox: casella di appartenenza
|
mailbox: casella di appartenenza
|
||||||
db: sessione DB (open, con flush del messaggio)
|
db: sessione DB (open, con flush del messaggio)
|
||||||
redis_client: ArqRedis per enqueue_job
|
|
||||||
|
Returns:
|
||||||
|
Lista di notification_log_id (str) da accodare dopo il commit.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await _do_evaluate_and_enqueue(message, mailbox, db, redis_client)
|
return await _do_evaluate_and_enqueue(message, mailbox, db)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Errore evaluate_and_enqueue_notifications per messaggio "
|
f"Errore evaluate_and_enqueue_notifications per messaggio "
|
||||||
f"{message.id}: {exc}",
|
f"{message.id}: {exc}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _do_evaluate_and_enqueue(
|
async def _do_evaluate_and_enqueue(
|
||||||
message: Message,
|
message: Message,
|
||||||
mailbox: Mailbox,
|
mailbox: Mailbox,
|
||||||
db: Any,
|
db: Any,
|
||||||
redis_client: Any,
|
) -> list[str]:
|
||||||
) -> None:
|
"""
|
||||||
"""Logica interna – puo' sollevare eccezioni."""
|
Logica interna – puo' sollevare eccezioni.
|
||||||
|
|
||||||
|
Crea i NotificationLog e restituisce i loro ID.
|
||||||
|
NON accoda job arq: il chiamante lo fa dopo db.commit().
|
||||||
|
"""
|
||||||
|
|
||||||
# Carica regole attive per questo tenant con event_type = "new_message"
|
# Carica regole attive per questo tenant con event_type = "new_message"
|
||||||
rules_result = await db.execute(
|
rules_result = await db.execute(
|
||||||
@@ -280,9 +291,9 @@ async def _do_evaluate_and_enqueue(
|
|||||||
rules: list[NotificationRule] = list(rules_result.scalars().all())
|
rules: list[NotificationRule] = list(rules_result.scalars().all())
|
||||||
|
|
||||||
if not rules:
|
if not rules:
|
||||||
return
|
return []
|
||||||
|
|
||||||
enqueued_count = 0
|
log_ids: list[str] = []
|
||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
channel = rule.channel
|
channel = rule.channel
|
||||||
@@ -336,31 +347,22 @@ async def _do_evaluate_and_enqueue(
|
|||||||
db.add(log)
|
db.add(log)
|
||||||
await db.flush() # ottieni log.id
|
await db.flush() # ottieni log.id
|
||||||
|
|
||||||
# Enqueue arq job con defer 5s per attendere il commit DB
|
# Raccoglie log_id: il job viene accodato dal chiamante DOPO db.commit()
|
||||||
try:
|
log_ids.append(str(log.id))
|
||||||
await redis_client.enqueue_job(
|
|
||||||
"dispatch_notification",
|
|
||||||
str(log.id),
|
|
||||||
_defer_by=timedelta(seconds=5),
|
|
||||||
)
|
|
||||||
enqueued_count += 1
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[notify] Enqueued dispatch_notification per regola "
|
f"[notify] NotificationLog creato per regola "
|
||||||
f"{rule.name!r} -> canale {channel.name!r} "
|
f"{rule.name!r} -> canale {channel.name!r} "
|
||||||
f"(log_id={log.id})"
|
f"(log_id={log.id})"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[notify] Impossibile enqueue dispatch_notification "
|
|
||||||
f"per log {log.id}: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if enqueued_count > 0:
|
if log_ids:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[notify] Messaggio {message.id}: "
|
f"[notify] Messaggio {message.id}: "
|
||||||
f"{enqueued_count} notifiche accodate"
|
f"{len(log_ids)} notifiche pronte per dispatch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return log_ids
|
||||||
|
|
||||||
|
|
||||||
# ─── Job arq principale ───────────────────────────────────────────────────────
|
# ─── Job arq principale ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user