mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Merge branch 'main' of https://github.com/idrainformatica/PecFlow
This commit is contained in:
@@ -161,3 +161,4 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
KnowledgeBaseCline.md
|
||||
|
||||
@@ -888,44 +888,6 @@ END $$;
|
||||
|
||||
## 4. Decisioni Architetturali
|
||||
|
||||
---
|
||||
|
||||
### ADR-001 – Multi-tenancy: Schema-per-tenant vs Row-level con tenant_id
|
||||
|
||||
**Opzione A – Schema-per-tenant (un PostgreSQL schema per ogni organizzazione)**
|
||||
|
||||
*Pro:*
|
||||
- Isolamento totale dei dati: impossibile data leak cross-tenant per bug SQL
|
||||
- Backup e restore per singolo tenant molto semplici
|
||||
- Possibilità di migrare un tenant su un DB separato senza refactoring
|
||||
|
||||
*Contro:*
|
||||
- Alembic migrations vanno applicate a tutti gli schema (N schema × migrazione)
|
||||
- Connection pooling complesso: PgBouncer deve gestire schema switching
|
||||
- Difficile fare query aggregate cross-tenant (es. monitoraggio globale SaaS)
|
||||
- Overhead operativo significativo oltre i 100 tenant
|
||||
|
||||
**Opzione B – Row-level con `tenant_id` + PostgreSQL RLS**
|
||||
|
||||
*Pro:*
|
||||
- Un solo schema, migrations applicate una volta
|
||||
- Query cross-tenant per operazioni di sistema (monitoring, billing)
|
||||
- Molto più semplice da gestire con Alembic e ORM
|
||||
- Scala bene fino a decine di migliaia di tenant
|
||||
|
||||
*Contro:*
|
||||
- Bug nell'impostazione `current_tenant_id` può causare data leak → mitigato con RLS come secondo livello
|
||||
- Restore di singolo tenant richiede WHERE clause su dump
|
||||
|
||||
**Raccomandazione: Opzione B (row-level + RLS)**
|
||||
|
||||
Per un SaaS B2B con caselle PEC, il numero di tenant nel medio termine è nell'ordine delle centinaia, non decine di migliaia. Il rischio principale (data leak cross-tenant) è mitigato da due strati indipendenti:
|
||||
1. Applicativo: ogni query include `WHERE tenant_id = :current_tenant_id`
|
||||
2. DB: PostgreSQL RLS come safety net, impostata tramite `SET LOCAL app.current_tenant_id = '...'` in ogni transazione
|
||||
|
||||
Il vantaggio operativo (migrazioni semplici, query di monitoring, pool unico) supera i rischi, a condizione di avere test di integrazione che verificano il corretto isolamento.
|
||||
|
||||
---
|
||||
|
||||
### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo
|
||||
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
Report Gap Analysis – PEChub PEC Manager SaaS
|
||||
Analisi condotta il 25/03/2026 sul codice sorgente (backend, worker, frontend).
|
||||
|
||||
COSA E' IMPLEMENTATO E FUNZIONANTE
|
||||
Infrastruttura e autenticazione
|
||||
|
||||
Stack Docker completo (PostgreSQL, Redis, MinIO, Nginx, backend, worker, frontend)
|
||||
Autenticazione JWT con refresh token silenzioso
|
||||
2FA TOTP (setup + verifica)
|
||||
Multi-tenancy row-level con RLS PostgreSQL
|
||||
Cifratura credenziali IMAP/SMTP (AES-256-GCM in security.py)
|
||||
Rate limiting su endpoint auth
|
||||
WebSocket real-time per nuovi messaggi
|
||||
CRUD completo: caselle, utenti, permessi, Virtual Box, notifiche, etichette
|
||||
IMAP Sync Engine
|
||||
|
||||
Pool asincrono con N coroutine IMAP, IDLE + polling fallback
|
||||
Backoff esponenziale su disconnessione
|
||||
Download EML grezzo su MinIO
|
||||
Aggiornamento stato casella (error, sync_error_count)
|
||||
Parser PEC
|
||||
|
||||
Classificazione tipo messaggio da header X-Ricevuta/X-TipoRicevuta
|
||||
Parsing MIME completo, estrazione allegati
|
||||
EML-in-EML (ricevute annidate)
|
||||
State machine outbound: sent → accepted → delivered / anomaly
|
||||
Invio SMTP
|
||||
|
||||
API POST /send con validazione e creazione send_job
|
||||
Job send_pec con retry esponenziale (5 tentativi)
|
||||
receipt_watcher: attesa ricevuta accettazione con alert anomalia a 24h
|
||||
Upload raw EML inviato su MinIO
|
||||
Frontend
|
||||
|
||||
Inbox multi-casella con filtri, selezione multipla, azioni bulk
|
||||
Posta inviata, Preferiti, Archiviati, Cestino
|
||||
Dettaglio messaggio: corpo HTML/testo, allegati, ReceiptTree, download ZIP
|
||||
Composizione PEC con RichTextEditor, To/Cc multipli, allegati
|
||||
Gestione caselle, utenti, permessi, Virtual Box, notifiche, impostazioni
|
||||
Pagina Multi-tenant (Super Admin)
|
||||
Tag/etichette con colori su messaggi
|
||||
Virtual Box con regole e assegnazioni utenti
|
||||
|
||||
COSA MANCA – PRIORITA' ALTA
|
||||
|
||||
3. Archiviazione Sostitutiva (Fase 6 – ~15% implementata)
|
||||
|
||||
worker/app/archival/conservatore_client.py esiste (mock + produzione) ma non e' mai chiamato da nessun job reale
|
||||
Mancano completamente:
|
||||
worker/app/archival/sip_builder.py (generazione pacchetto SIP UNI SInCRO)
|
||||
worker/app/archival/rdv_processor.py (parsing RdV XML)
|
||||
worker/app/jobs/archive_batch.py (job selezione messaggi + upload SIP)
|
||||
backend/app/api/v1/archival.py (endpoint GET /archival/batches, POST /archival/dip)
|
||||
frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP)
|
||||
Il modello archival.py esiste ma la tabella archival_batches non e' nella migrazione corrente
|
||||
La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste
|
||||
|
||||
|
||||
6. Worker – job mancanti
|
||||
|
||||
dispatch_notification.py – notifiche automatiche
|
||||
archive_batch.py – versamenti verso conservatore
|
||||
generate_report.py – export PDF/CSV
|
||||
index_message.py – indicizzazione FTS allegati via Tika
|
||||
7. Sicurezza – punti critici
|
||||
|
||||
La cifratura dei segreti notifiche usa base64.b64encode() senza encryption reale: chiunque abbia accesso al DB puo' leggere bot_token Telegram, webhook secret, SMTP password in chiaro
|
||||
Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, test o build su PR
|
||||
Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate)
|
||||
Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan
|
||||
|
||||
|
||||
10. Gestione quote casella
|
||||
|
||||
L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP)
|
||||
COSA MANCA – PRIORITA' BASSA (Hardening / Go-Live)
|
||||
11. Monitoring e osservabilita'
|
||||
|
||||
Non c'e' infra/prometheus/ ne' infra/grafana/ (previsti in ARCHITECTURE.md ma non creati)
|
||||
Non c'e' log aggregation (Loki/ELK)
|
||||
Non ci sono metriche esposte dal backend (es. /metrics endpoint Prometheus)
|
||||
12. Backup automatico
|
||||
|
||||
Non c'e' script o cronjob per pg_dump automatico verso MinIO
|
||||
13. Test coverage
|
||||
|
||||
I test di integrazione esistenti coprono auth, users, send API
|
||||
Non ci sono test per: messages API, permissions API, virtual_boxes, notifications, archival
|
||||
Non c'e' copertura frontend (nessun test Vitest/Playwright presente)
|
||||
14. GDPR
|
||||
|
||||
Non c'e' endpoint DELETE /tenants/{id} per cancellazione completa dati tenant con audit trail
|
||||
RIEPILOGO STATO PER FASE
|
||||
Fase Descrizione Stato
|
||||
1 Fondamenta + Auth + Multi-tenancy Completa
|
||||
1-A Permessi granulari per casella Completa
|
||||
2 IMAP Sync Engine Completa
|
||||
3 Parser PEC e Tracking Ricevute Completa
|
||||
4 Invio SMTP con retry Completa
|
||||
5 Frontend base (inbox, compose, admin) Completa
|
||||
5-A Virtual Box Completa
|
||||
5-B Ricerca avanzata full-text Non iniziata
|
||||
5-C Notifiche multi-canale Struttura pronta, dispatch mancante
|
||||
6 Archiviazione sostitutiva ~15% (client mock presente, tutto il resto mancante)
|
||||
7 Dashboard e Reportistica Non iniziata
|
||||
8 Hardening, test, go-live Parziale (sicurezza base presente, monitoring/backup/CI mancanti)
|
||||
PRIORITA' DI INTERVENTO CONSIGLIATA
|
||||
Notifiche dispatch – e' la funzionalita' piu' vicina al completamento: la struttura dati e' pronta, manca solo il wiring tra IMAP sync, il servizio di valutazione regole e il job worker. Ha alto impatto operativo.
|
||||
Ricerca avanzata – blocca l'usabilita' su volumi di posta significativi. La ricerca ILIKE attuale scala male.
|
||||
Archiviazione sostitutiva – obbligatoria per la compliance normativa dei clienti PA/professionisti.
|
||||
Fix sicurezza cifratura notifiche – critico: i segreti (bot token, webhook secret) sono attualmente non cifrati nel DB.
|
||||
Audit log – necessario per compliance e per l'utilizzo enterprise.
|
||||
Dashboard/Report – utile commercialmente ma non bloccante per l'operativita'.
|
||||
@@ -30,7 +30,7 @@ Porta: 465
|
||||
SSL: Sì
|
||||
|
||||
|
||||
Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
||||
Quando necessario, effettua i test di invio solo al destinatario matteo1801@spidmail.it
|
||||
|
||||
Tutto il frontend deve essere in italiano
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-ita \
|
||||
tesseract-ocr-eng \
|
||||
poppler-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add is_trashed and trashed_at to messages
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-03-25
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0007'
|
||||
down_revision = '0006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'messages',
|
||||
sa.Column('is_trashed', sa.Boolean(), nullable=False, server_default=sa.text('false')),
|
||||
)
|
||||
op.add_column(
|
||||
'messages',
|
||||
sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('messages', 'trashed_at')
|
||||
op.drop_column('messages', 'is_trashed')
|
||||
@@ -0,0 +1,76 @@
|
||||
"""add full text search vector to messages and extracted_text to attachments
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-03-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0008'
|
||||
down_revision = '0007'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Aggiunge colonna search_vector a messages
|
||||
op.add_column(
|
||||
'messages',
|
||||
sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True),
|
||||
)
|
||||
|
||||
# 2. Aggiunge colonna extracted_text ad attachments (testo estratto da PDF/DOCX)
|
||||
op.add_column(
|
||||
'attachments',
|
||||
sa.Column('extracted_text', sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
# 3. Indice GIN per ricerca full-text veloce
|
||||
op.execute(
|
||||
"CREATE INDEX idx_messages_fts ON messages USING gin(search_vector) "
|
||||
"WHERE search_vector IS NOT NULL"
|
||||
)
|
||||
|
||||
# 4. Funzione trigger che aggiorna search_vector quando cambiano i campi testuali
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('italian', coalesce(NEW.subject, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.from_address, '')), 'B') ||
|
||||
setweight(to_tsvector('simple',
|
||||
coalesce(array_to_string(NEW.to_addresses, ' '), '')), 'B') ||
|
||||
setweight(to_tsvector('italian', coalesce(NEW.body_text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 5. Crea trigger (si attiva su INSERT e UPDATE dei campi rilevanti)
|
||||
op.execute("""
|
||||
CREATE TRIGGER trg_messages_search_vector
|
||||
BEFORE INSERT OR UPDATE OF subject, from_address, to_addresses, body_text
|
||||
ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update();
|
||||
""")
|
||||
|
||||
# 6. Backfill: popola search_vector per i messaggi esistenti
|
||||
op.execute("""
|
||||
UPDATE messages SET search_vector =
|
||||
setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
|
||||
setweight(to_tsvector('simple',
|
||||
coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
|
||||
setweight(to_tsvector('italian', coalesce(body_text, '')), 'C')
|
||||
WHERE search_vector IS NULL
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_messages_search_vector ON messages")
|
||||
op.execute("DROP FUNCTION IF EXISTS messages_search_vector_update()")
|
||||
op.execute("DROP INDEX IF EXISTS idx_messages_fts")
|
||||
op.drop_column('attachments', 'extracted_text')
|
||||
op.drop_column('messages', 'search_vector')
|
||||
@@ -0,0 +1,95 @@
|
||||
"""add conservation fields to messages and mailbox_permissions
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-03-27
|
||||
|
||||
Aggiunge:
|
||||
- messages.is_pending_conservation BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- messages.pending_conservation_at TIMESTAMP WITH TIME ZONE
|
||||
- messages.is_conserved BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- messages.conserved_at TIMESTAMP WITH TIME ZONE
|
||||
- mailbox_permissions.can_conserve BOOLEAN NOT NULL DEFAULT FALSE
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "0009"
|
||||
down_revision = "0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Nuovi campi sulla tabella messages
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"is_pending_conservation",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"pending_conservation_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"is_conserved",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column(
|
||||
"conserved_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Indici parziali per query sulle cartelle Conservazione
|
||||
op.create_index(
|
||||
"idx_messages_pending_conservation",
|
||||
"messages",
|
||||
["tenant_id"],
|
||||
postgresql_where=sa.text("is_pending_conservation = true"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_messages_conserved",
|
||||
"messages",
|
||||
["tenant_id"],
|
||||
postgresql_where=sa.text("is_conserved = true"),
|
||||
)
|
||||
|
||||
# Permesso can_conserve sulla tabella mailbox_permissions
|
||||
op.add_column(
|
||||
"mailbox_permissions",
|
||||
sa.Column(
|
||||
"can_conserve",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("mailbox_permissions", "can_conserve")
|
||||
op.drop_index("idx_messages_conserved", table_name="messages")
|
||||
op.drop_index("idx_messages_pending_conservation", table_name="messages")
|
||||
op.drop_column("messages", "conserved_at")
|
||||
op.drop_column("messages", "is_conserved")
|
||||
op.drop_column("messages", "pending_conservation_at")
|
||||
op.drop_column("messages", "is_pending_conservation")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Migrazione 0010: tabella message_templates (Feature 1 – Template messaggi).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"message_templates",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("subject", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("body_text", sa.Text, nullable=True),
|
||||
sa.Column("body_html", sa.Text, nullable=True),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="uq_template_name_tenant"),
|
||||
)
|
||||
op.create_index("idx_templates_tenant", "message_templates", ["tenant_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_templates_tenant", table_name="message_templates")
|
||||
op.drop_table("message_templates")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Migrazione 0011: tabelle routing_rules, routing_rule_conditions, routing_rule_actions
|
||||
(Feature 2 – Regole di smistamento automatico).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Tabella principale regole
|
||||
op.create_table(
|
||||
"routing_rules",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column("priority", sa.Integer, nullable=False, server_default="100"),
|
||||
sa.Column("stop_processing", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("idx_routing_rules_tenant", "routing_rules", ["tenant_id"])
|
||||
op.create_index(
|
||||
"idx_routing_rules_active",
|
||||
"routing_rules",
|
||||
["tenant_id", "priority"],
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
|
||||
# Condizioni delle regole
|
||||
op.create_table(
|
||||
"routing_rule_conditions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# field: from_address | to_address | subject | mailbox_id | pec_type
|
||||
sa.Column("field", sa.String(50), nullable=False),
|
||||
# operator: contains | equals | starts_with | ends_with | regex | not_contains
|
||||
sa.Column("operator", sa.String(30), nullable=False, server_default="contains"),
|
||||
sa.Column("value", sa.Text, nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_routing_conditions_rule",
|
||||
"routing_rule_conditions",
|
||||
["rule_id"],
|
||||
)
|
||||
|
||||
# Azioni delle regole
|
||||
op.create_table(
|
||||
"routing_rule_actions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("routing_rules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# action_type: apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
|
||||
sa.Column("action_type", sa.String(50), nullable=False),
|
||||
# action_value: UUID di label/vbox, URL del webhook, ecc.
|
||||
sa.Column("action_value", sa.Text, nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_routing_actions_rule",
|
||||
"routing_rule_actions",
|
||||
["rule_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_routing_actions_rule", table_name="routing_rule_actions")
|
||||
op.drop_table("routing_rule_actions")
|
||||
op.drop_index("idx_routing_conditions_rule", table_name="routing_rule_conditions")
|
||||
op.drop_table("routing_rule_conditions")
|
||||
op.drop_index("idx_routing_rules_active", table_name="routing_rules")
|
||||
op.drop_index("idx_routing_rules_tenant", table_name="routing_rules")
|
||||
op.drop_table("routing_rules")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Migrazione 0012: campi deadline su tabella messages
|
||||
(Feature 4 – Scadenzario e tracking deadlines).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"messages",
|
||||
sa.Column("deadline_note", sa.Text, nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_messages_deadline",
|
||||
"messages",
|
||||
["tenant_id", "deadline_at"],
|
||||
postgresql_where=sa.text("deadline_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_messages_deadline", table_name="messages")
|
||||
op.drop_column("messages", "deadline_note")
|
||||
op.drop_column("messages", "deadline_at")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Migrazione 0013: campo scheduled_at su send_jobs
|
||||
(Feature 5 – Invio differito).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0013"
|
||||
down_revision = "0012"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"send_jobs",
|
||||
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_sendjobs_scheduled",
|
||||
"send_jobs",
|
||||
["scheduled_at"],
|
||||
postgresql_where=sa.text("status = 'pending' AND scheduled_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_sendjobs_scheduled", table_name="send_jobs")
|
||||
op.drop_column("send_jobs", "scheduled_at")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Migrazione 0014: tabella pec_contacts (Feature 6 – Rubrica indirizzi PEC).
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0014"
|
||||
down_revision = "0013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"pec_contacts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=True),
|
||||
sa.Column("organization", sa.String(255), nullable=True),
|
||||
sa.Column("notes", sa.Text, nullable=True),
|
||||
sa.Column("is_favorite", sa.Boolean, nullable=False, server_default="false"),
|
||||
# auto_saved=True: contatto creato automaticamente dal sistema durante la sync
|
||||
# auto_saved=False: contatto aggiunto manualmente dall'utente
|
||||
sa.Column("auto_saved", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"created_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("tenant_id", "email", name="uq_pec_contact_email_tenant"),
|
||||
)
|
||||
op.create_index("idx_pec_contacts_tenant", "pec_contacts", ["tenant_id"])
|
||||
op.create_index("idx_pec_contacts_email", "pec_contacts", ["tenant_id", "email"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_pec_contacts_email", table_name="pec_contacts")
|
||||
op.drop_index("idx_pec_contacts_tenant", table_name="pec_contacts")
|
||||
op.drop_table("pec_contacts")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Router Audit Log – consultazione degli eventi di sistema.
|
||||
|
||||
Endpoint:
|
||||
GET /api/v1/audit-log – lista paginata con filtri (solo admin/super_admin)
|
||||
|
||||
Permessi:
|
||||
- admin: vede solo gli eventi del proprio tenant
|
||||
- super_admin: vede tutti i tenant (filtrabile per tenant_id)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.dependencies import AdminUser, DB
|
||||
from app.schemas.audit_log import AuditLogListResponse
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
router = APIRouter(prefix="/audit-log", tags=["Audit Log"])
|
||||
|
||||
|
||||
@router.get("", response_model=AuditLogListResponse)
|
||||
async def list_audit_log(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
page: int = Query(1, ge=1, description="Numero di pagina"),
|
||||
page_size: int = Query(25, ge=1, le=100, description="Elementi per pagina"),
|
||||
action: Optional[str] = Query(None, description="Filtra per azione (es. auth.login, user.*)"),
|
||||
user_id: Optional[uuid.UUID] = Query(None, description="Filtra per utente"),
|
||||
outcome: Optional[str] = Query(None, pattern="^(success|failure)$", description="Esito: success o failure"),
|
||||
date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
|
||||
date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
|
||||
resource_type: Optional[str] = Query(None, description="Tipo risorsa (user, mailbox, message, ...)"),
|
||||
tenant_id: Optional[uuid.UUID] = Query(None, description="Filtra per tenant (solo super_admin)"),
|
||||
) -> AuditLogListResponse:
|
||||
"""
|
||||
Restituisce la lista paginata degli eventi di audit.
|
||||
|
||||
- Admin: vede solo gli eventi del proprio tenant (tenant_id ignorato).
|
||||
- Super Admin: vede tutti i tenant, filtrabile per tenant_id.
|
||||
"""
|
||||
svc = AuditService(db)
|
||||
|
||||
# Determina il tenant_id effettivo da applicare al filtro
|
||||
if current_user.is_super_admin:
|
||||
# Super admin: usa il tenant_id passato come filtro (None = tutti)
|
||||
effective_tenant_id = tenant_id
|
||||
else:
|
||||
# Admin normale: sempre vincolato al proprio tenant
|
||||
effective_tenant_id = current_user.tenant_id
|
||||
|
||||
return await svc.list(
|
||||
tenant_id=effective_tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
outcome=outcome,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
resource_type=resource_type,
|
||||
)
|
||||
@@ -161,13 +161,34 @@ async def totp_disable(
|
||||
summary="Cambio password utente corrente",
|
||||
)
|
||||
async def change_password(
|
||||
request: Request,
|
||||
body: PasswordChangeRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
from app.core.security import verify_password, hash_password
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
if not verify_password(body.current_password, current_user.password_hash):
|
||||
from app.services.audit_service import log_audit as _la
|
||||
await _la(
|
||||
db,
|
||||
"auth.password_changed",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
outcome="failure",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={"reason": "wrong_current_password"},
|
||||
)
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
current_user.password_hash = hash_password(body.new_password)
|
||||
await log_audit(
|
||||
db,
|
||||
"auth.password_changed",
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Router rubrica indirizzi PEC (Feature 6).
|
||||
|
||||
Endpoint:
|
||||
GET /contacts – lista contatti (con ricerca)
|
||||
POST /contacts – crea contatto manuale
|
||||
GET /contacts/autocomplete – autocomplete per compose
|
||||
GET /contacts/{id} – dettaglio contatto
|
||||
PUT /contacts/{id} – aggiorna contatto
|
||||
DELETE /contacts/{id} – elimina contatto
|
||||
POST /contacts/import – importa da CSV
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query, UploadFile, File, status
|
||||
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.schemas.pec_contact import (
|
||||
PecContactCreate,
|
||||
PecContactImportResult,
|
||||
PecContactListResponse,
|
||||
PecContactResponse,
|
||||
PecContactUpdate,
|
||||
)
|
||||
from app.services.pec_contact_service import PecContactService
|
||||
|
||||
router = APIRouter(tags=["Contacts"])
|
||||
|
||||
|
||||
@router.get("/contacts", response_model=PecContactListResponse)
|
||||
async def list_contacts(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
q: str | None = Query(None, description="Ricerca per email, nome o organizzazione"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> PecContactListResponse:
|
||||
"""Elenca i contatti della rubrica PEC del tenant."""
|
||||
svc = PecContactService(db)
|
||||
items, total = await svc.list_contacts(
|
||||
current_user.tenant_id, q=q, page=page, page_size=page_size
|
||||
)
|
||||
return PecContactListResponse(
|
||||
items=[PecContactResponse.model_validate(c) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/contacts/autocomplete", response_model=list[PecContactResponse])
|
||||
async def autocomplete_contacts(
|
||||
q: str,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
limit: int = Query(10, ge=1, le=20),
|
||||
) -> list[PecContactResponse]:
|
||||
"""Ricerca rapida contatti per autocomplete nel compose (minimo 2 caratteri)."""
|
||||
svc = PecContactService(db)
|
||||
items = await svc.search_for_autocomplete(current_user.tenant_id, q=q, limit=limit)
|
||||
return [PecContactResponse.model_validate(c) for c in items]
|
||||
|
||||
|
||||
@router.post("/contacts", response_model=PecContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
data: PecContactCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> PecContactResponse:
|
||||
"""Aggiunge un contatto manualmente alla rubrica."""
|
||||
svc = PecContactService(db)
|
||||
contact = await svc.create_contact(
|
||||
current_user.tenant_id, data, created_by=current_user.id
|
||||
)
|
||||
return PecContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}", response_model=PecContactResponse)
|
||||
async def get_contact(
|
||||
contact_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> PecContactResponse:
|
||||
"""Restituisce il dettaglio di un contatto."""
|
||||
svc = PecContactService(db)
|
||||
contact = await svc.get_contact(current_user.tenant_id, contact_id)
|
||||
return PecContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.put("/contacts/{contact_id}", response_model=PecContactResponse)
|
||||
async def update_contact(
|
||||
contact_id: uuid.UUID,
|
||||
data: PecContactUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> PecContactResponse:
|
||||
"""Aggiorna un contatto della rubrica."""
|
||||
svc = PecContactService(db)
|
||||
contact = await svc.update_contact(current_user.tenant_id, contact_id, data)
|
||||
return PecContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_contact(
|
||||
contact_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""Elimina un contatto dalla rubrica."""
|
||||
svc = PecContactService(db)
|
||||
await svc.delete_contact(current_user.tenant_id, contact_id)
|
||||
|
||||
|
||||
@router.post("/contacts/import", response_model=PecContactImportResult)
|
||||
async def import_contacts_csv(
|
||||
file: UploadFile = File(..., description="File CSV con colonne: email, name, organization"),
|
||||
current_user: CurrentUser = ..., # type: ignore
|
||||
db: DB = ..., # type: ignore
|
||||
) -> PecContactImportResult:
|
||||
"""
|
||||
Importa contatti dalla rubrica da file CSV.
|
||||
|
||||
Il CSV deve avere le intestazioni: email, name, organization
|
||||
(solo email e' obbligatoria).
|
||||
"""
|
||||
content = await file.read()
|
||||
try:
|
||||
csv_text = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
csv_text = content.decode("latin-1")
|
||||
|
||||
svc = PecContactService(db)
|
||||
return await svc.import_csv(current_user.tenant_id, csv_text, created_by=current_user.id)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Router scadenzario e tracking deadlines (Feature 4).
|
||||
|
||||
Endpoint:
|
||||
GET /deadlines – messaggi con scadenze imminenti
|
||||
POST /messages/{id}/deadline – imposta/modifica/rimuove scadenza
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.models.message import Message
|
||||
from app.schemas.message import MessageResponse
|
||||
from app.core.exceptions import NotFoundError
|
||||
|
||||
router = APIRouter(tags=["Deadlines"])
|
||||
|
||||
|
||||
class DeadlineSetRequest(BaseModel):
|
||||
deadline_at: datetime | None = None
|
||||
"""Imposta a null per rimuovere la scadenza."""
|
||||
deadline_note: str | None = None
|
||||
|
||||
|
||||
class DeadlineMessageResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
subject: str | None
|
||||
from_address: str | None
|
||||
to_addresses: list[str] | None = None
|
||||
direction: str
|
||||
pec_type: str
|
||||
state: str
|
||||
mailbox_id: uuid.UUID
|
||||
deadline_at: datetime | None = None
|
||||
deadline_note: str | None = None
|
||||
is_overdue: bool = False
|
||||
received_at: datetime | None = None
|
||||
sent_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@router.get("/deadlines", response_model=list[DeadlineMessageResponse])
|
||||
async def list_deadlines(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
days_ahead: int = Query(30, ge=1, le=365, description="Giorni da considerare in avanti"),
|
||||
include_overdue: bool = Query(True, description="Includi scadenze gia' passate"),
|
||||
) -> list[DeadlineMessageResponse]:
|
||||
"""
|
||||
Restituisce i messaggi con scadenze nel range specificato.
|
||||
|
||||
Ordinati per: scaduti prima, poi per deadline_at ASC.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
future_limit = now + timedelta(days=days_ahead)
|
||||
|
||||
conditions = [
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
Message.deadline_at.is_not(None),
|
||||
Message.is_trashed == False, # noqa: E712
|
||||
]
|
||||
|
||||
if include_overdue:
|
||||
# Include scaduti e futuri fino al limite
|
||||
conditions.append(Message.deadline_at <= future_limit)
|
||||
else:
|
||||
# Solo scadenze future
|
||||
conditions.append(and_(Message.deadline_at > now, Message.deadline_at <= future_limit))
|
||||
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(and_(*conditions))
|
||||
.order_by(Message.deadline_at)
|
||||
.limit(200)
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
items = []
|
||||
for msg in messages:
|
||||
is_overdue = msg.deadline_at < now if msg.deadline_at else False
|
||||
items.append(DeadlineMessageResponse(
|
||||
id=msg.id,
|
||||
subject=msg.subject,
|
||||
from_address=msg.from_address,
|
||||
to_addresses=msg.to_addresses,
|
||||
direction=msg.direction,
|
||||
pec_type=msg.pec_type,
|
||||
state=msg.state,
|
||||
mailbox_id=msg.mailbox_id,
|
||||
deadline_at=msg.deadline_at,
|
||||
deadline_note=msg.deadline_note,
|
||||
is_overdue=is_overdue,
|
||||
received_at=msg.received_at,
|
||||
sent_at=msg.sent_at,
|
||||
created_at=msg.created_at,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/deadline", response_model=DeadlineMessageResponse)
|
||||
async def set_deadline(
|
||||
message_id: uuid.UUID,
|
||||
data: DeadlineSetRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> DeadlineMessageResponse:
|
||||
"""
|
||||
Imposta, modifica o rimuove la scadenza di un messaggio.
|
||||
|
||||
Passa deadline_at=null per rimuovere la scadenza.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Message).where(
|
||||
Message.id == message_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise NotFoundError(f"Messaggio {message_id} non trovato")
|
||||
|
||||
msg.deadline_at = data.deadline_at
|
||||
msg.deadline_note = data.deadline_note
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
is_overdue = msg.deadline_at < now if msg.deadline_at else False
|
||||
|
||||
return DeadlineMessageResponse(
|
||||
id=msg.id,
|
||||
subject=msg.subject,
|
||||
from_address=msg.from_address,
|
||||
to_addresses=msg.to_addresses,
|
||||
direction=msg.direction,
|
||||
pec_type=msg.pec_type,
|
||||
state=msg.state,
|
||||
mailbox_id=msg.mailbox_id,
|
||||
deadline_at=msg.deadline_at,
|
||||
deadline_note=msg.deadline_note,
|
||||
is_overdue=is_overdue,
|
||||
received_at=msg.received_at,
|
||||
sent_at=msg.sent_at,
|
||||
created_at=msg.created_at,
|
||||
)
|
||||
@@ -21,6 +21,8 @@ from app.schemas.mailbox import (
|
||||
MailboxCreateRequest,
|
||||
MailboxListResponse,
|
||||
MailboxResponse,
|
||||
MailboxSyncResponse,
|
||||
MailboxUnreadCountsResponse,
|
||||
MailboxUpdateRequest,
|
||||
)
|
||||
from app.services.mailbox_service import MailboxService
|
||||
@@ -75,15 +77,15 @@ async def list_mailboxes(
|
||||
"""
|
||||
svc = _svc(db)
|
||||
|
||||
if current_user.is_admin:
|
||||
# Admin: tutte le caselle del tenant
|
||||
if current_user.is_supervisor_or_admin:
|
||||
# Admin e supervisor: tutte le caselle del tenant
|
||||
items, total = await svc.list_mailboxes(
|
||||
tenant_id=current_user.tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
else:
|
||||
# Operatori: caselle con permesso
|
||||
# Operator e readonly: caselle con permesso esplicito
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
visible_ids = await perm_svc.get_visible_mailboxes(current_user)
|
||||
@@ -123,6 +125,54 @@ async def list_mailboxes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-counts", response_model=MailboxUnreadCountsResponse)
|
||||
async def get_unread_counts(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> MailboxUnreadCountsResponse:
|
||||
"""
|
||||
Restituisce il numero di messaggi non letti per ciascuna casella accessibile.
|
||||
Usato dalla sidebar per mostrare i badge per casella.
|
||||
- Admin: conta su tutte le caselle del tenant.
|
||||
- Operatori: solo le caselle con permesso can_read.
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
from app.models.message import Message
|
||||
|
||||
# Determina le caselle visibili
|
||||
# Admin e supervisor: nessun filtro (accesso a tutto il tenant)
|
||||
# Operator e readonly: solo caselle con permesso esplicito can_read
|
||||
if current_user.is_supervisor_or_admin:
|
||||
visible_ids = None # nessun filtro
|
||||
else:
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
visible_ids = await perm_svc.get_visible_mailboxes(current_user)
|
||||
if not visible_ids:
|
||||
return MailboxUnreadCountsResponse(counts={})
|
||||
|
||||
q = (
|
||||
select(Message.mailbox_id, func.count().label("cnt"))
|
||||
.where(
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
Message.is_read == False, # noqa: E712
|
||||
Message.direction == "inbound",
|
||||
Message.is_trashed == False, # noqa: E712
|
||||
Message.is_archived == False, # noqa: E712
|
||||
Message.parent_message_id.is_(None),
|
||||
)
|
||||
.group_by(Message.mailbox_id)
|
||||
)
|
||||
|
||||
if visible_ids is not None:
|
||||
from app.models.mailbox import Mailbox
|
||||
q = q.where(Message.mailbox_id.in_(visible_ids))
|
||||
|
||||
rows = (await db.execute(q)).all()
|
||||
counts = {str(row.mailbox_id): row.cnt for row in rows}
|
||||
return MailboxUnreadCountsResponse(counts=counts)
|
||||
|
||||
|
||||
@router.get("/{mailbox_id}", response_model=MailboxResponse)
|
||||
async def get_mailbox(
|
||||
mailbox_id: uuid.UUID,
|
||||
@@ -200,3 +250,52 @@ async def test_mailbox_connection(
|
||||
tenant_id=current_user.tenant_id,
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{mailbox_id}/sync",
|
||||
response_model=MailboxSyncResponse,
|
||||
)
|
||||
async def force_sync_mailbox(
|
||||
mailbox_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> MailboxSyncResponse:
|
||||
"""
|
||||
Forza una sincronizzazione IMAP immediata della casella.
|
||||
Accoda il job sync_mailbox nel worker tramite arq/Redis.
|
||||
Utile dopo un errore di connessione o per forzare un aggiornamento.
|
||||
Richiede ruolo **admin**.
|
||||
"""
|
||||
from app.core.exceptions import NotFoundError
|
||||
|
||||
svc = _svc(db)
|
||||
mailbox = await svc.get_mailbox(mailbox_id, current_user.tenant_id)
|
||||
|
||||
if mailbox.status == "deleted":
|
||||
raise NotFoundError("Casella non trovata o eliminata")
|
||||
|
||||
try:
|
||||
from arq.connections import RedisSettings, create_pool as arq_create_pool
|
||||
from app.config import get_settings
|
||||
|
||||
cfg = get_settings()
|
||||
arq_settings = RedisSettings.from_dsn(cfg.redis_url)
|
||||
arq_redis = await arq_create_pool(arq_settings)
|
||||
await arq_redis.enqueue_job("sync_mailbox", str(mailbox_id))
|
||||
await arq_redis.aclose()
|
||||
except Exception as exc:
|
||||
from app.core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"[force_sync] Impossibile accodare job per {mailbox_id}: {exc}")
|
||||
return MailboxSyncResponse(
|
||||
status="error",
|
||||
mailbox_id=mailbox_id,
|
||||
message=f"Impossibile accodare il job: {exc}",
|
||||
)
|
||||
|
||||
return MailboxSyncResponse(
|
||||
status="enqueued",
|
||||
mailbox_id=mailbox_id,
|
||||
message=f"Sincronizzazione avviata per {mailbox.email_address}",
|
||||
)
|
||||
|
||||
+535
-72
@@ -2,17 +2,21 @@
|
||||
Router messaggi PEC.
|
||||
|
||||
Fornisce:
|
||||
- GET /messages – lista messaggi con filtri (inbox/sent/search/...)
|
||||
- GET /messages/{id} – singolo messaggio
|
||||
- PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived)
|
||||
- GET /messages/{id}/attachments – lista allegati
|
||||
- GET /messages/{id}/attachments/{att_id}/download – scarica allegato da MinIO
|
||||
- GET /messages/{id}/receipts – ricevute (messaggi figlio)
|
||||
- GET /messages - lista messaggi con filtri (inbox/sent/search/...)
|
||||
- GET /messages/{id} - singolo messaggio
|
||||
- PATCH /messages/{id} - aggiorna flags (is_read, is_starred, is_archived, is_trashed,
|
||||
is_pending_conservation, is_conserved)
|
||||
- PATCH /messages/bulk - aggiorna in blocco piu messaggi
|
||||
- GET /messages/{id}/attachments - lista allegati
|
||||
- GET /messages/{id}/attachments/{att_id}/download - scarica allegato da MinIO
|
||||
- GET /messages/{id}/receipts - ricevute (messaggi figlio)
|
||||
|
||||
Permessi:
|
||||
- Admin: accede a tutti i messaggi del proprio tenant.
|
||||
- Operator/Supervisor/Readonly: solo i messaggi delle caselle su cui
|
||||
hanno almeno il permesso can_read.
|
||||
- is_pending_conservation / is_conserved: richiedono can_conserve
|
||||
(implicito per admin, esplicito per supervisor/operator).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
@@ -25,6 +29,8 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.services.search_service import SearchService
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||
from app.database import get_db
|
||||
@@ -58,7 +64,6 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str):
|
||||
elif field == "from_address":
|
||||
col = Message.from_address
|
||||
elif field == "to_address":
|
||||
# to_addresses è ARRAY(Text) – converte in stringa per il confronto
|
||||
arr_text = func.array_to_string(Message.to_addresses, ",")
|
||||
if operator == "contains":
|
||||
return q.where(arr_text.ilike(f"%{value}%"))
|
||||
@@ -74,7 +79,7 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str):
|
||||
elif field == "imap_folder":
|
||||
col = Message.imap_folder
|
||||
else:
|
||||
return q # campo non supportato – ignorato
|
||||
return q
|
||||
|
||||
if operator == "contains":
|
||||
return q.where(col.ilike(f"%{value}%"))
|
||||
@@ -93,11 +98,11 @@ async def _get_visible_mailbox_ids(
|
||||
user, db: AsyncSession
|
||||
) -> Optional[list[uuid.UUID]]:
|
||||
"""
|
||||
Per utenti non-admin restituisce la lista di mailbox_id accessibili.
|
||||
Restituisce None se l'utente è admin (accesso illimitato al tenant).
|
||||
Per utenti non-admin/supervisor restituisce la lista di mailbox_id accessibili.
|
||||
Restituisce None se l'utente e' admin o supervisor (accesso illimitato al tenant).
|
||||
"""
|
||||
if user.is_admin:
|
||||
return None # nessun filtro per admin
|
||||
if user.is_supervisor_or_admin:
|
||||
return None
|
||||
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
@@ -109,13 +114,7 @@ async def _resolve_message(
|
||||
current_user,
|
||||
db: AsyncSession,
|
||||
) -> Message:
|
||||
"""Carica il messaggio e verifica i permessi di accesso.
|
||||
|
||||
L'accesso è consentito se:
|
||||
1. L'utente è admin del tenant, oppure
|
||||
2. L'utente ha un permesso diretto can_read sulla casella, oppure
|
||||
3. L'utente è assegnato a una Virtual Box attiva che include la casella.
|
||||
"""
|
||||
"""Carica il messaggio e verifica i permessi di accesso."""
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
@@ -134,9 +133,6 @@ async def _resolve_message(
|
||||
has_direct_access = await perm_svc.check_can_read(current_user, message.mailbox_id)
|
||||
|
||||
if not has_direct_access:
|
||||
# Verifica accesso tramite Virtual Box:
|
||||
# l'utente deve essere assegnato a una VBox attiva
|
||||
# che include la casella del messaggio.
|
||||
from app.models.virtual_box import (
|
||||
VirtualBox,
|
||||
VirtualBoxAssignment,
|
||||
@@ -182,9 +178,13 @@ async def list_messages(
|
||||
is_read: Optional[bool] = Query(None),
|
||||
is_starred: Optional[bool] = Query(None),
|
||||
is_archived: Optional[bool] = Query(False),
|
||||
search: Optional[str] = Query(None, max_length=200),
|
||||
is_trashed: Optional[bool] = Query(False),
|
||||
is_pending_conservation: Optional[bool] = Query(None, description="Filtra per messaggi in attesa di conservazione"),
|
||||
is_conserved: Optional[bool] = Query(None, description="Filtra per messaggi gia' conservati"),
|
||||
search: Optional[str] = Query(None, max_length=500),
|
||||
pec_type: Optional[str] = Query(None),
|
||||
# Paginazione
|
||||
date_from: Optional[datetime] = Query(None, description="Data minima (received_at o sent_at)"),
|
||||
date_to: Optional[datetime] = Query(None, description="Data massima (received_at o sent_at)"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
) -> MessageListResponse:
|
||||
@@ -192,10 +192,11 @@ async def list_messages(
|
||||
Elenca i messaggi PEC con filtri opzionali.
|
||||
|
||||
- `is_archived=False` (default) esclude i messaggi archiviati.
|
||||
- `search` cerca su subject, from_address, to_addresses.
|
||||
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
|
||||
- `is_trashed=False` (default) esclude i messaggi nel cestino.
|
||||
- `is_pending_conservation` filtra messaggi da conservare (cartella Da Conservare).
|
||||
- `is_conserved` filtra messaggi gia' conservati (cartella Storico).
|
||||
- `search` usa ricerca full-text (tsvector) con fallback ILIKE.
|
||||
"""
|
||||
# Determinare le caselle visibili (normale check permessi)
|
||||
visible_mailbox_ids = await _get_visible_mailbox_ids(current_user, db)
|
||||
|
||||
# ── Filtro Virtual Box ────────────────────────────────────────────────────
|
||||
@@ -219,7 +220,6 @@ async def list_messages(
|
||||
if not vbox:
|
||||
raise NotFoundError("Virtual Box")
|
||||
|
||||
# Non-admin: verifica che l'utente sia assegnato alla VBox
|
||||
if not current_user.is_admin:
|
||||
assign_result = await db.execute(
|
||||
select(VirtualBoxAssignment).where(
|
||||
@@ -230,32 +230,23 @@ async def list_messages(
|
||||
if not assign_result.scalar_one_or_none():
|
||||
raise ForbiddenError("Virtual Box non accessibile")
|
||||
|
||||
# L'assegnazione alla VBox garantisce accesso alle sue caselle:
|
||||
# sovrascrive il filtro permessi normali per questa query.
|
||||
if vbox.mailboxes:
|
||||
visible_mailbox_ids = [m.id for m in vbox.mailboxes]
|
||||
# Se la VBox non ha caselle esplicitamente associate,
|
||||
# si mantiene il filtro permessi normale (visible_mailbox_ids invariato).
|
||||
|
||||
vbox_rules = vbox.rules or []
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Query base
|
||||
q = select(Message).where(
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
Message.parent_message_id.is_(None), # escludi ricevute (messaggi figlio)
|
||||
Message.parent_message_id.is_(None),
|
||||
)
|
||||
|
||||
# Filtro caselle visibili per non-admin (o dopo override VBox)
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
# Nessuna casella accessibile → lista vuota
|
||||
return MessageListResponse(items=[], total=0, page=page, page_size=page_size)
|
||||
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||
|
||||
# Filtri opzionali
|
||||
if mailbox_id is not None:
|
||||
# Verifica che l'utente abbia accesso a questa casella specifica
|
||||
if visible_mailbox_ids is not None and mailbox_id not in visible_mailbox_ids:
|
||||
raise ForbiddenError("Accesso alla casella non autorizzato")
|
||||
q = q.where(Message.mailbox_id == mailbox_id)
|
||||
@@ -278,31 +269,60 @@ async def list_messages(
|
||||
if is_archived is not None:
|
||||
q = q.where(Message.is_archived == is_archived)
|
||||
|
||||
if is_trashed is not None:
|
||||
q = q.where(Message.is_trashed == is_trashed)
|
||||
|
||||
# ── Filtri Conservazione ──────────────────────────────────────────────────
|
||||
if is_pending_conservation is not None:
|
||||
q = q.where(Message.is_pending_conservation == is_pending_conservation)
|
||||
|
||||
if is_conserved is not None:
|
||||
q = q.where(Message.is_conserved == is_conserved)
|
||||
|
||||
# ── Full-text search ──────────────────────────────────────────────────────
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
from sqlalchemy import case as sa_case
|
||||
|
||||
tsquery = func.websearch_to_tsquery("italian", search)
|
||||
term_like = f"%{search}%"
|
||||
q = q.where(
|
||||
or_(
|
||||
Message.subject.ilike(term),
|
||||
Message.from_address.ilike(term),
|
||||
Message.body_text.ilike(term),
|
||||
Message.search_vector.op("@@")(tsquery),
|
||||
Message.search_vector.is_(None) & or_(
|
||||
Message.subject.ilike(term_like),
|
||||
Message.from_address.ilike(term_like),
|
||||
Message.body_text.ilike(term_like),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Applica le regole della Virtual Box (AND tra le regole)
|
||||
# ── Filtri data ───────────────────────────────────────────────────────────
|
||||
if date_from:
|
||||
q = q.where(or_(Message.received_at >= date_from, Message.sent_at >= date_from))
|
||||
if date_to:
|
||||
q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= date_to))
|
||||
|
||||
for rule in vbox_rules:
|
||||
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
|
||||
|
||||
# Conteggio totale
|
||||
count_q = select(func.count()).select_from(q.subquery())
|
||||
total = (await db.execute(count_q)).scalar_one()
|
||||
|
||||
# Ordinamento e paginazione
|
||||
if search:
|
||||
from sqlalchemy import case as sa_case
|
||||
|
||||
tsquery_ord = func.websearch_to_tsquery("italian", search)
|
||||
rank_expr = sa_case(
|
||||
(Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery_ord)),
|
||||
else_=0.0,
|
||||
)
|
||||
order_clauses = [rank_expr.desc(), Message.received_at.desc().nullslast(), Message.created_at.desc()]
|
||||
else:
|
||||
order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()]
|
||||
|
||||
q = (
|
||||
q.options(selectinload(Message.labels))
|
||||
.order_by(
|
||||
Message.received_at.desc().nullslast(),
|
||||
Message.created_at.desc(),
|
||||
)
|
||||
.order_by(*order_clauses)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
@@ -325,15 +345,13 @@ async def bulk_update_messages(
|
||||
db: DB,
|
||||
) -> MessageBulkUpdateResponse:
|
||||
"""
|
||||
Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi.
|
||||
Aggiorna in blocco i flag operativi di piu messaggi.
|
||||
|
||||
Restituisce il numero di messaggi aggiornati e la lista aggiornata.
|
||||
I messaggi non trovati o non accessibili vengono silenziosamente ignorati.
|
||||
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
|
||||
"""
|
||||
if not data.ids:
|
||||
return MessageBulkUpdateResponse(updated=0, items=[])
|
||||
|
||||
# Carica tutti i messaggi del tenant
|
||||
result = await db.execute(
|
||||
select(Message).where(
|
||||
Message.id.in_(data.ids),
|
||||
@@ -342,7 +360,7 @@ async def bulk_update_messages(
|
||||
)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# Filtra per permessi se non admin
|
||||
# Filtra per permessi di lettura
|
||||
if not current_user.is_admin:
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
@@ -350,8 +368,26 @@ async def bulk_update_messages(
|
||||
visible_set = set(visible) if visible else set()
|
||||
messages = [m for m in messages if m.mailbox_id in visible_set]
|
||||
|
||||
# Se si tenta di modificare flag di conservazione, verifica can_conserve
|
||||
conservation_change = (
|
||||
data.is_pending_conservation is not None or data.is_conserved is not None
|
||||
)
|
||||
if conservation_change and messages:
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
# Verifica per ogni casella unica coinvolta
|
||||
mailbox_ids = {m.mailbox_id for m in messages}
|
||||
for mb_id in mailbox_ids:
|
||||
can = await perm_svc.check_can_conserve(current_user, mb_id)
|
||||
if not can:
|
||||
raise ForbiddenError(
|
||||
"Permesso 'conservazione' non assegnato per questa casella"
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for message in messages:
|
||||
if data.is_read is not None:
|
||||
message.is_read = data.is_read
|
||||
if data.is_starred is not None:
|
||||
message.is_starred = data.is_starred
|
||||
if data.is_archived is not None:
|
||||
@@ -360,10 +396,27 @@ async def bulk_update_messages(
|
||||
message.archived_at = now
|
||||
elif not data.is_archived:
|
||||
message.archived_at = None
|
||||
if data.is_trashed is not None:
|
||||
message.is_trashed = data.is_trashed
|
||||
if data.is_trashed and not message.trashed_at:
|
||||
message.trashed_at = now
|
||||
elif not data.is_trashed:
|
||||
message.trashed_at = None
|
||||
if data.is_pending_conservation is not None:
|
||||
message.is_pending_conservation = data.is_pending_conservation
|
||||
if data.is_pending_conservation and not message.pending_conservation_at:
|
||||
message.pending_conservation_at = now
|
||||
elif not data.is_pending_conservation:
|
||||
message.pending_conservation_at = None
|
||||
if data.is_conserved is not None:
|
||||
message.is_conserved = data.is_conserved
|
||||
if data.is_conserved and not message.conserved_at:
|
||||
message.conserved_at = now
|
||||
elif not data.is_conserved:
|
||||
message.conserved_at = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Ricarica i messaggi aggiornati con selectinload per evitare MissingGreenlet sui labels
|
||||
if messages:
|
||||
updated_ids = [m.id for m in messages]
|
||||
refreshed_result = await db.execute(
|
||||
@@ -398,11 +451,20 @@ async def update_message(
|
||||
db: DB,
|
||||
) -> MessageResponse:
|
||||
"""
|
||||
Aggiorna i flag operativi di un messaggio:
|
||||
is_read, is_starred, is_archived.
|
||||
Aggiorna i flag operativi di un messaggio.
|
||||
|
||||
Per is_pending_conservation=True o is_conserved=True richiede can_conserve.
|
||||
"""
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
|
||||
# Verifica permesso conservazione se necessario
|
||||
if data.is_pending_conservation is not None or data.is_conserved is not None:
|
||||
from app.services.permission_service import PermissionService
|
||||
perm_svc = PermissionService(db)
|
||||
await perm_svc.require_can_conserve(current_user, message.mailbox_id)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if data.is_read is not None:
|
||||
message.is_read = data.is_read
|
||||
if data.is_starred is not None:
|
||||
@@ -410,12 +472,29 @@ async def update_message(
|
||||
if data.is_archived is not None:
|
||||
message.is_archived = data.is_archived
|
||||
if data.is_archived and not message.archived_at:
|
||||
message.archived_at = datetime.now(timezone.utc)
|
||||
message.archived_at = now
|
||||
elif not data.is_archived:
|
||||
message.archived_at = None
|
||||
if data.is_trashed is not None:
|
||||
message.is_trashed = data.is_trashed
|
||||
if data.is_trashed and not message.trashed_at:
|
||||
message.trashed_at = now
|
||||
elif not data.is_trashed:
|
||||
message.trashed_at = None
|
||||
if data.is_pending_conservation is not None:
|
||||
message.is_pending_conservation = data.is_pending_conservation
|
||||
if data.is_pending_conservation and not message.pending_conservation_at:
|
||||
message.pending_conservation_at = now
|
||||
elif not data.is_pending_conservation:
|
||||
message.pending_conservation_at = None
|
||||
if data.is_conserved is not None:
|
||||
message.is_conserved = data.is_conserved
|
||||
if data.is_conserved and not message.conserved_at:
|
||||
message.conserved_at = now
|
||||
elif not data.is_conserved:
|
||||
message.conserved_at = None
|
||||
|
||||
await db.commit()
|
||||
# Re-query con selectinload per evitare MissingGreenlet sui labels
|
||||
refreshed = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.id == message_id)
|
||||
@@ -450,14 +529,9 @@ async def download_attachment(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Scarica un allegato direttamente da MinIO.
|
||||
Il file viene streamato al client con i header Content-Disposition corretti.
|
||||
"""
|
||||
# Verifica accesso al messaggio
|
||||
"""Scarica un allegato direttamente da MinIO."""
|
||||
await _resolve_message(message_id, current_user, db)
|
||||
|
||||
# Carica allegato
|
||||
result = await db.execute(
|
||||
select(Attachment).where(
|
||||
Attachment.id == attachment_id,
|
||||
@@ -468,7 +542,6 @@ async def download_attachment(
|
||||
if not attachment:
|
||||
raise NotFoundError(f"Allegato {attachment_id} non trovato")
|
||||
|
||||
# Stream da MinIO
|
||||
try:
|
||||
from miniopy_async import Minio
|
||||
|
||||
@@ -479,7 +552,6 @@ async def download_attachment(
|
||||
secure=settings.minio_use_ssl,
|
||||
)
|
||||
|
||||
# storage_path è del tipo "tenant_id/attachments/filename"
|
||||
storage_path = attachment.storage_path
|
||||
response = await client.get_object(settings.minio_bucket, storage_path)
|
||||
|
||||
@@ -506,17 +578,127 @@ async def download_attachment(
|
||||
raise NotFoundError("File non disponibile al momento")
|
||||
|
||||
|
||||
@router.get("/{message_id}/download-package")
|
||||
async def download_package(
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> StreamingResponse:
|
||||
"""Scarica un archivio ZIP con tutti i file originali della PEC."""
|
||||
import io
|
||||
import zipfile as _zipfile
|
||||
|
||||
from miniopy_async import Minio
|
||||
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
|
||||
client = Minio(
|
||||
endpoint=settings.minio_endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=settings.minio_use_ssl,
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
|
||||
async def _read_minio(path: str) -> bytes:
|
||||
try:
|
||||
resp = await client.get_object(settings.minio_bucket, path)
|
||||
data = await resp.content.read()
|
||||
resp.close()
|
||||
return data
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf:
|
||||
att_result = await db.execute(
|
||||
select(Attachment)
|
||||
.where(Attachment.message_id == message.id)
|
||||
.order_by(Attachment.created_at)
|
||||
)
|
||||
main_attachments = list(att_result.scalars().all())
|
||||
|
||||
for att in main_attachments:
|
||||
data = await _read_minio(att.storage_path)
|
||||
if data:
|
||||
zf.writestr(att.filename, data)
|
||||
|
||||
if message.raw_eml_path:
|
||||
data = await _read_minio(message.raw_eml_path)
|
||||
if data:
|
||||
eml_name = message.raw_eml_path.rsplit("/", 1)[-1]
|
||||
if not eml_name.endswith(".eml"):
|
||||
eml_name = "messaggio_originale.eml"
|
||||
existing = {info.filename for info in zf.infolist()}
|
||||
if eml_name not in existing:
|
||||
zf.writestr(eml_name, data)
|
||||
|
||||
if message.direction == "outbound":
|
||||
receipts_result = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.parent_message_id == message.id)
|
||||
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
|
||||
)
|
||||
receipts = list(receipts_result.scalars().all())
|
||||
|
||||
for receipt in receipts:
|
||||
pec_type = receipt.pec_type or "ricevuta"
|
||||
folder = f"ricevute/{pec_type}"
|
||||
|
||||
r_att_result = await db.execute(
|
||||
select(Attachment)
|
||||
.where(Attachment.message_id == receipt.id)
|
||||
.order_by(Attachment.created_at)
|
||||
)
|
||||
r_attachments = list(r_att_result.scalars().all())
|
||||
|
||||
for att in r_attachments:
|
||||
data = await _read_minio(att.storage_path)
|
||||
if data:
|
||||
zip_path = f"{folder}/{att.filename}"
|
||||
existing = {info.filename for info in zf.infolist()}
|
||||
final_path = zip_path
|
||||
counter = 1
|
||||
while final_path in existing:
|
||||
name, _, ext = att.filename.rpartition(".")
|
||||
final_path = f"{folder}/{name}_{counter}.{ext}" if ext else f"{folder}/{att.filename}_{counter}"
|
||||
counter += 1
|
||||
zf.writestr(final_path, data)
|
||||
|
||||
if receipt.raw_eml_path:
|
||||
data = await _read_minio(receipt.raw_eml_path)
|
||||
if data:
|
||||
eml_name = receipt.raw_eml_path.rsplit("/", 1)[-1]
|
||||
if not eml_name.endswith(".eml"):
|
||||
eml_name = f"{pec_type}.eml"
|
||||
zip_path = f"{folder}/{eml_name}"
|
||||
existing = {info.filename for info in zf.infolist()}
|
||||
if zip_path not in existing:
|
||||
zf.writestr(zip_path, data)
|
||||
|
||||
buf.seek(0)
|
||||
zip_bytes = buf.getvalue()
|
||||
|
||||
safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50]
|
||||
zip_filename = f"pec_{safe_subject}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([zip_bytes]),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{zip_filename}"',
|
||||
"Content-Length": str(len(zip_bytes)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{message_id}/receipts", response_model=list[MessageResponse])
|
||||
async def list_receipts(
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[MessageResponse]:
|
||||
"""
|
||||
Elenca le ricevute associate a un messaggio outbound
|
||||
(messaggi con parent_message_id = message_id).
|
||||
"""
|
||||
# Verifica accesso al messaggio padre
|
||||
"""Elenca le ricevute associate a un messaggio outbound."""
|
||||
await _resolve_message(message_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
@@ -527,3 +709,284 @@ async def list_receipts(
|
||||
)
|
||||
receipts = list(result.scalars().all())
|
||||
return [MessageResponse.model_validate(r) for r in receipts]
|
||||
|
||||
|
||||
# ─── Feature 3: Thread/conversazioni ─────────────────────────────────────────
|
||||
|
||||
@router.get("/{message_id}/thread", response_model=list[MessageResponse])
|
||||
async def get_thread(
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> list[MessageResponse]:
|
||||
"""
|
||||
Restituisce l'intera conversazione (thread) di cui fa parte il messaggio.
|
||||
|
||||
Risale alla radice della conversazione (risalendo i parent_message_id),
|
||||
poi carica tutti i messaggi del thread ordinati cronologicamente.
|
||||
Esclude le ricevute PEC (pec_type != posta_certificata).
|
||||
"""
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
|
||||
# Risale alla radice del thread
|
||||
root_id = message.id
|
||||
visited: set[uuid.UUID] = {message.id}
|
||||
current = message
|
||||
while current.parent_message_id and current.parent_message_id not in visited:
|
||||
visited.add(current.parent_message_id)
|
||||
parent_result = await db.execute(
|
||||
select(Message).where(
|
||||
Message.id == current.parent_message_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
break
|
||||
current = parent
|
||||
root_id = current.id
|
||||
|
||||
# Carica ricorsivamente tutti i messaggi del thread dalla radice
|
||||
# Limita a posta_certificata (esclude accettazioni, consegne, ecc.)
|
||||
thread_messages: list[Message] = []
|
||||
|
||||
async def _collect(msg_id: uuid.UUID) -> None:
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.id == msg_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
Message.pec_type == "posta_certificata",
|
||||
)
|
||||
.options(selectinload(Message.labels))
|
||||
)
|
||||
msg = result.scalar_one_or_none()
|
||||
if msg:
|
||||
thread_messages.append(msg)
|
||||
|
||||
children_result = await db.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.parent_message_id == msg_id,
|
||||
Message.tenant_id == current_user.tenant_id,
|
||||
Message.pec_type == "posta_certificata",
|
||||
)
|
||||
.options(selectinload(Message.labels))
|
||||
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
|
||||
)
|
||||
children = list(children_result.scalars().all())
|
||||
for child in children:
|
||||
await _collect(child.id)
|
||||
|
||||
await _collect(root_id)
|
||||
|
||||
# Ordina cronologicamente
|
||||
thread_messages.sort(
|
||||
key=lambda m: m.received_at or m.sent_at or m.created_at
|
||||
)
|
||||
|
||||
return [MessageResponse.model_validate(m) for m in thread_messages]
|
||||
|
||||
|
||||
# ─── Feature 7: Preview allegati (presigned URL) ──────────────────────────────
|
||||
|
||||
@router.get("/{message_id}/attachments/{attachment_id}/preview-url")
|
||||
async def get_attachment_preview_url(
|
||||
message_id: uuid.UUID,
|
||||
attachment_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> dict:
|
||||
"""
|
||||
Restituisce una presigned URL MinIO per la preview inline dell'allegato.
|
||||
|
||||
La URL e' valida per 5 minuti. Supporta PDF e immagini.
|
||||
Per altri tipi di file reindirizza al download normale.
|
||||
"""
|
||||
await _resolve_message(message_id, current_user, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Attachment).where(
|
||||
Attachment.id == attachment_id,
|
||||
Attachment.message_id == message_id,
|
||||
)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
if not attachment:
|
||||
raise NotFoundError(f"Allegato {attachment_id} non trovato")
|
||||
|
||||
content_type = attachment.content_type or "application/octet-stream"
|
||||
previewable = (
|
||||
content_type.startswith("image/") or
|
||||
content_type == "application/pdf"
|
||||
)
|
||||
|
||||
if not previewable:
|
||||
return {
|
||||
"previewable": False,
|
||||
"content_type": content_type,
|
||||
"filename": attachment.filename,
|
||||
}
|
||||
|
||||
try:
|
||||
from datetime import timedelta as _timedelta
|
||||
from miniopy_async import Minio
|
||||
|
||||
client = Minio(
|
||||
endpoint=settings.minio_endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=settings.minio_use_ssl,
|
||||
)
|
||||
presigned_url = await client.presigned_get_object(
|
||||
settings.minio_bucket,
|
||||
attachment.storage_path,
|
||||
expires=_timedelta(minutes=5),
|
||||
)
|
||||
return {
|
||||
"previewable": True,
|
||||
"content_type": content_type,
|
||||
"filename": attachment.filename,
|
||||
"url": presigned_url,
|
||||
}
|
||||
except Exception as e:
|
||||
from app.core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Errore generazione presigned URL allegato {attachment_id}: {e}")
|
||||
return {
|
||||
"previewable": False,
|
||||
"content_type": content_type,
|
||||
"filename": attachment.filename,
|
||||
}
|
||||
|
||||
|
||||
# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
|
||||
|
||||
@router.get("/{message_id}/print")
|
||||
async def print_message(
|
||||
message_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> "HTMLResponse":
|
||||
"""
|
||||
Restituisce una rappresentazione HTML ottimizzata per la stampa del messaggio.
|
||||
|
||||
Include: intestazione, corpo, lista allegati, albero ricevute.
|
||||
Pronto per window.print() o salvataggio come PDF tramite browser.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
message = await _resolve_message(message_id, current_user, db)
|
||||
|
||||
att_result = await db.execute(
|
||||
select(Attachment).where(Attachment.message_id == message.id).order_by(Attachment.created_at)
|
||||
)
|
||||
attachments = list(att_result.scalars().all())
|
||||
|
||||
receipts_html = ""
|
||||
if message.direction == "outbound":
|
||||
rec_result = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.parent_message_id == message.id)
|
||||
.order_by(Message.received_at.asc().nullslast(), Message.created_at.asc())
|
||||
)
|
||||
receipts = list(rec_result.scalars().all())
|
||||
|
||||
PEC_TYPE_LABELS = {
|
||||
"accettazione": "Accettazione",
|
||||
"avvenuta_consegna": "Avvenuta consegna",
|
||||
"non_accettazione": "Non accettazione",
|
||||
"mancata_consegna": "Mancata consegna",
|
||||
"errore_consegna": "Errore consegna",
|
||||
"presa_in_carico": "Presa in carico",
|
||||
"preavviso_mancata_consegna": "Preavviso mancata consegna",
|
||||
"rilevazione_virus": "Rilevazione virus",
|
||||
}
|
||||
|
||||
receipt_rows = ""
|
||||
for r in receipts:
|
||||
label = PEC_TYPE_LABELS.get(r.pec_type, r.pec_type)
|
||||
date_str = ""
|
||||
if r.received_at:
|
||||
date_str = r.received_at.strftime("%d/%m/%Y %H:%M:%S")
|
||||
receipt_rows += f"<tr><td>{label}</td><td>{date_str}</td></tr>"
|
||||
|
||||
if receipt_rows:
|
||||
receipts_html = f"""
|
||||
<section>
|
||||
<h3>Tracciamento invio</h3>
|
||||
<table>
|
||||
<thead><tr><th>Tipo ricevuta</th><th>Data</th></tr></thead>
|
||||
<tbody>{receipt_rows}</tbody>
|
||||
</table>
|
||||
</section>"""
|
||||
|
||||
att_rows = ""
|
||||
for att in attachments:
|
||||
size_str = f"{att.size_bytes:,} byte" if att.size_bytes else ""
|
||||
att_rows += f"<li>{att.filename} ({att.content_type or ''}) {size_str}</li>"
|
||||
|
||||
att_html = f"<section><h3>Allegati ({len(attachments)})</h3><ul>{att_rows}</ul></section>" if attachments else ""
|
||||
|
||||
from_label = "Da" if message.direction == "inbound" else "A"
|
||||
from_val = message.from_address if message.direction == "inbound" else ", ".join(message.to_addresses or [])
|
||||
date_val = ""
|
||||
date_field = message.received_at or message.sent_at or message.created_at
|
||||
if date_field:
|
||||
date_val = date_field.strftime("%d/%m/%Y %H:%M:%S")
|
||||
|
||||
body_html = ""
|
||||
if message.body_html:
|
||||
body_html = f"<div class='body'>{message.body_html}</div>"
|
||||
elif message.body_text:
|
||||
body_html = f"<pre class='body'>{message.body_text}</pre>"
|
||||
|
||||
deadline_html = ""
|
||||
if message.deadline_at:
|
||||
dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M")
|
||||
deadline_html = f"<p class='deadline'><strong>Scadenza:</strong> {dl_str}</p>"
|
||||
if message.deadline_note:
|
||||
deadline_html += f"<p><em>Nota scadenza: {message.deadline_note}</em></p>"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>PEC - {message.subject or '(nessun oggetto)'}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; font-size: 12pt; margin: 2cm; color: #000; }}
|
||||
h1 {{ font-size: 16pt; border-bottom: 2px solid #333; padding-bottom: 8px; }}
|
||||
h3 {{ font-size: 13pt; margin-top: 20px; border-bottom: 1px solid #ccc; }}
|
||||
.meta {{ background: #f5f5f5; border: 1px solid #ddd; padding: 12px; margin-bottom: 16px; }}
|
||||
.meta p {{ margin: 4px 0; }}
|
||||
.body {{ border: 1px solid #ddd; padding: 12px; white-space: pre-wrap; word-wrap: break-word; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 6px 10px; text-align: left; }}
|
||||
th {{ background: #eee; }}
|
||||
ul {{ padding-left: 20px; }}
|
||||
.deadline {{ color: #c00; font-weight: bold; }}
|
||||
@media print {{ body {{ margin: 1cm; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{message.subject or '(nessun oggetto)'}</h1>
|
||||
<div class="meta">
|
||||
<p><strong>{from_label}:</strong> {from_val}</p>
|
||||
{'<p><strong>Da:</strong> ' + message.from_address + '</p>' if message.direction == "outbound" and message.from_address else ''}
|
||||
{'<p><strong>A:</strong> ' + ', '.join(message.to_addresses or []) + '</p>' if message.direction == "inbound" and message.to_addresses else ''}
|
||||
{'<p><strong>Cc:</strong> ' + ', '.join(message.cc_addresses or []) + '</p>' if message.cc_addresses else ''}
|
||||
<p><strong>Data:</strong> {date_val}</p>
|
||||
<p><strong>Stato:</strong> {message.state}</p>
|
||||
<p><strong>Tipo:</strong> {message.pec_type}</p>
|
||||
</div>
|
||||
{deadline_html}
|
||||
{body_html}
|
||||
{att_html}
|
||||
{receipts_html}
|
||||
<p style="margin-top: 30px; font-size: 9pt; color: #888;">
|
||||
Documento generato da PEChub il {date_val} – ID messaggio: {message.id}
|
||||
</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
return HTMLResponse(content=html, media_type="text/html; charset=utf-8")
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Router Reports – Dashboard e Reportistica (Fase 7).
|
||||
|
||||
Endpoint:
|
||||
GET /reports/summary – KPI + serie storica + breakdown caselle
|
||||
GET /reports/export – export CSV o PDF
|
||||
|
||||
Permessi:
|
||||
- Tutti gli utenti autenticati possono accedere.
|
||||
- Admin e super_admin: vedono tutto il tenant.
|
||||
- Operator/Supervisor/Readonly: vedono solo le caselle su cui hanno can_read.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.dependencies import CurrentUser, DB
|
||||
from app.schemas.reports import ReportSummaryResponse
|
||||
from app.services.report_service import ReportService
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["Reports"])
|
||||
|
||||
|
||||
async def _get_visible_ids(user, db) -> Optional[list[uuid.UUID]]:
|
||||
"""Restituisce None per admin (nessun filtro), lista per non-admin."""
|
||||
if user.is_admin:
|
||||
return None
|
||||
from app.services.permission_service import PermissionService
|
||||
svc = PermissionService(db)
|
||||
return await svc.get_visible_mailboxes(user)
|
||||
|
||||
|
||||
# ─── GET /reports/summary ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/summary", response_model=ReportSummaryResponse)
|
||||
async def get_report_summary(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
days: int = Query(7, ge=1, le=365, description="Periodo in giorni per la serie storica"),
|
||||
) -> ReportSummaryResponse:
|
||||
"""
|
||||
Restituisce il riepilogo completo per la dashboard:
|
||||
- KPI (PEC ricevute/inviate oggi, anomalie, tasso consegna, ...)
|
||||
- Serie storica giornaliera per il grafico a barre
|
||||
- Distribuzione stati outbound per il grafico a torta
|
||||
- Statistiche per casella
|
||||
"""
|
||||
visible = await _get_visible_ids(current_user, db)
|
||||
svc = ReportService(db)
|
||||
return await svc.get_summary(
|
||||
tenant_id=current_user.tenant_id,
|
||||
period_days=days,
|
||||
visible_mailbox_ids=visible,
|
||||
)
|
||||
|
||||
|
||||
# ─── GET /reports/export ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/export")
|
||||
async def export_report(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
format: str = Query("csv", pattern="^(csv|pdf)$", description="Formato: csv o pdf"),
|
||||
date_from: Optional[datetime] = Query(None, description="Data inizio (ISO 8601)"),
|
||||
date_to: Optional[datetime] = Query(None, description="Data fine (ISO 8601)"),
|
||||
mailbox_id: Optional[uuid.UUID] = Query(None, description="Filtra per casella specifica"),
|
||||
) -> Response:
|
||||
"""
|
||||
Esporta i dati in formato CSV o PDF.
|
||||
|
||||
- CSV: lista completa dei messaggi del periodo con tutti i metadati.
|
||||
- PDF: riepilogo KPI + tabella caselle (generato con reportlab).
|
||||
"""
|
||||
visible = await _get_visible_ids(current_user, db)
|
||||
svc = ReportService(db)
|
||||
|
||||
now_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
if format == "csv":
|
||||
data = await svc.export_csv(
|
||||
tenant_id=current_user.tenant_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
mailbox_id=mailbox_id,
|
||||
visible_mailbox_ids=visible,
|
||||
)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="text/csv; charset=utf-8-sig",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="pechub_report_{now_str}.csv"',
|
||||
"Content-Length": str(len(data)),
|
||||
},
|
||||
)
|
||||
|
||||
# PDF
|
||||
data = await svc.export_pdf(
|
||||
tenant_id=current_user.tenant_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
visible_mailbox_ids=visible,
|
||||
)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="pechub_report_{now_str}.pdf"',
|
||||
"Content-Length": str(len(data)),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Router regole di smistamento automatico (Feature 2).
|
||||
|
||||
Endpoint:
|
||||
GET /routing-rules – lista regole del tenant
|
||||
POST /routing-rules – crea regola (admin)
|
||||
GET /routing-rules/{id} – dettaglio regola
|
||||
PUT /routing-rules/{id} – aggiorna regola (admin)
|
||||
DELETE /routing-rules/{id} – elimina regola (admin)
|
||||
POST /routing-rules/{id}/toggle – abilita/disabilita regola (admin)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.schemas.routing_rule import (
|
||||
RoutingRuleCreate,
|
||||
RoutingRuleListResponse,
|
||||
RoutingRuleResponse,
|
||||
RoutingRuleUpdate,
|
||||
)
|
||||
from app.services.routing_rule_service import RoutingRuleService
|
||||
|
||||
router = APIRouter(tags=["Routing Rules"])
|
||||
|
||||
|
||||
@router.get("/routing-rules", response_model=RoutingRuleListResponse)
|
||||
async def list_rules(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> RoutingRuleListResponse:
|
||||
"""Elenca le regole di smistamento del tenant."""
|
||||
svc = RoutingRuleService(db)
|
||||
items, total = await svc.list_rules(current_user.tenant_id)
|
||||
return RoutingRuleListResponse(
|
||||
items=[RoutingRuleResponse.model_validate(r) for r in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/routing-rules", response_model=RoutingRuleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_rule(
|
||||
data: RoutingRuleCreate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> RoutingRuleResponse:
|
||||
"""Crea una nuova regola di smistamento (solo admin)."""
|
||||
svc = RoutingRuleService(db)
|
||||
rule = await svc.create_rule(current_user.tenant_id, data, created_by=current_user.id)
|
||||
return RoutingRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@router.get("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
|
||||
async def get_rule(
|
||||
rule_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> RoutingRuleResponse:
|
||||
"""Restituisce il dettaglio di una regola."""
|
||||
svc = RoutingRuleService(db)
|
||||
rule = await svc.get_rule(current_user.tenant_id, rule_id)
|
||||
return RoutingRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@router.put("/routing-rules/{rule_id}", response_model=RoutingRuleResponse)
|
||||
async def update_rule(
|
||||
rule_id: uuid.UUID,
|
||||
data: RoutingRuleUpdate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> RoutingRuleResponse:
|
||||
"""Aggiorna una regola di smistamento (solo admin)."""
|
||||
svc = RoutingRuleService(db)
|
||||
rule = await svc.update_rule(current_user.tenant_id, rule_id, data)
|
||||
return RoutingRuleResponse.model_validate(rule)
|
||||
|
||||
|
||||
@router.delete("/routing-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_rule(
|
||||
rule_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""Elimina una regola di smistamento (solo admin)."""
|
||||
svc = RoutingRuleService(db)
|
||||
await svc.delete_rule(current_user.tenant_id, rule_id)
|
||||
|
||||
|
||||
@router.post("/routing-rules/{rule_id}/toggle", response_model=RoutingRuleResponse)
|
||||
async def toggle_rule(
|
||||
rule_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> RoutingRuleResponse:
|
||||
"""Abilita o disabilita una regola di smistamento (admin)."""
|
||||
svc = RoutingRuleService(db)
|
||||
rule = await svc.get_rule(current_user.tenant_id, rule_id)
|
||||
from app.schemas.routing_rule import RoutingRuleUpdate
|
||||
updated = await svc.update_rule(
|
||||
current_user.tenant_id,
|
||||
rule_id,
|
||||
RoutingRuleUpdate(is_active=not rule.is_active),
|
||||
)
|
||||
return RoutingRuleResponse.model_validate(updated)
|
||||
+289
-11
@@ -1,23 +1,57 @@
|
||||
"""
|
||||
Router Impostazioni Tenant (Fase 6).
|
||||
Router Impostazioni Tenant.
|
||||
|
||||
Endpoint:
|
||||
GET /settings → legge le impostazioni del tenant corrente (admin)
|
||||
PUT /settings → aggiorna le impostazioni del tenant corrente (admin)
|
||||
Endpoint esistenti:
|
||||
GET /settings -> legge le impostazioni del tenant corrente (admin)
|
||||
PUT /settings -> aggiorna le impostazioni del tenant corrente (admin)
|
||||
|
||||
Solo gli admin e super_admin possono accedere.
|
||||
La sezione "archiviazione" gestisce il toggle mock/produzione per il
|
||||
conservatore AgID (Fase 6 – Archiviazione Sostitutiva).
|
||||
Endpoint indicizzazione full-text (Fase 8):
|
||||
GET /settings/indexing/stats -> statistiche copertura indicizzazione
|
||||
GET /settings/indexing/status -> stato job reindex in corso
|
||||
POST /settings/indexing/reindex -> avvia reindex (full o differential)
|
||||
DELETE /settings/indexing/reindex -> cancella job in corso
|
||||
|
||||
Solo admin e super_admin possono accedere.
|
||||
Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=<uuid>.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
import uuid
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.config import get_settings as get_app_settings
|
||||
from app.dependencies import AdminUser, DB
|
||||
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
|
||||
from app.schemas.tenant_settings import (
|
||||
IndexingJobStatus,
|
||||
IndexingStats,
|
||||
StartReindexRequest,
|
||||
StartRescanRequest,
|
||||
TenantSettingsResponse,
|
||||
TenantSettingsUpdate,
|
||||
)
|
||||
from app.services.indexing_service import IndexingService
|
||||
from app.services.tenant_settings_service import TenantSettingsService
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["Impostazioni"])
|
||||
|
||||
# ─── Helper tenant_id resolution ─────────────────────────────────────────────
|
||||
|
||||
def _resolve_tenant_id(
|
||||
current_user: AdminUser,
|
||||
tenant_id_param: Optional[uuid.UUID] = None,
|
||||
) -> uuid.UUID:
|
||||
"""
|
||||
Risolve il tenant_id da usare per l'operazione.
|
||||
- super_admin: puo' passare un tenant_id arbitrario
|
||||
- admin: usa sempre il proprio tenant_id (tenant_id_param ignorato)
|
||||
"""
|
||||
if current_user.role == "super_admin" and tenant_id_param is not None:
|
||||
return tenant_id_param
|
||||
return current_user.tenant_id
|
||||
|
||||
|
||||
# ─── Impostazioni generali ────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
@@ -25,7 +59,7 @@ router = APIRouter(prefix="/settings", tags=["Impostazioni"])
|
||||
summary="Legge le impostazioni del tenant",
|
||||
description=(
|
||||
"Restituisce la configurazione operativa del tenant: "
|
||||
"modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
|
||||
"modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
|
||||
),
|
||||
)
|
||||
async def get_settings(
|
||||
@@ -44,7 +78,7 @@ async def get_settings(
|
||||
description=(
|
||||
"Aggiorna la configurazione operativa del tenant. "
|
||||
"Tutti i campi sono opzionali (semantica PATCH). "
|
||||
"Il passaggio a modalità 'production' richiede un endpoint conservatore configurato."
|
||||
"Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato."
|
||||
),
|
||||
)
|
||||
async def update_settings(
|
||||
@@ -57,3 +91,247 @@ async def update_settings(
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
return TenantSettingsService.to_response(settings)
|
||||
|
||||
|
||||
# ─── Indicizzazione full-text ─────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/indexing/stats",
|
||||
response_model=IndexingStats,
|
||||
summary="Statistiche indicizzazione full-text",
|
||||
description=(
|
||||
"Restituisce il numero di messaggi totali, indicizzati e non indicizzati "
|
||||
"per il tenant, con percentuale di copertura. "
|
||||
"Include anche le statistiche sugli allegati PDF/DOCX con testo estratto. "
|
||||
"Il super_admin puo' specificare ?tenant_id=<uuid> per un tenant arbitrario."
|
||||
),
|
||||
)
|
||||
async def get_indexing_stats(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingStats:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
service = IndexingService(db)
|
||||
stats = await service.get_stats(target_tenant_id)
|
||||
return IndexingStats(**stats)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/indexing/status",
|
||||
response_model=IndexingJobStatus,
|
||||
summary="Stato job indicizzazione in corso",
|
||||
description=(
|
||||
"Restituisce lo stato del job di reindex per il tenant: "
|
||||
"idle, running (con progresso), completed, failed o cancelled. "
|
||||
"Se il job e' running da piu' di 2 ore, il flag is_stale e' True."
|
||||
),
|
||||
)
|
||||
async def get_indexing_status(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingJobStatus:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
app_settings = get_app_settings()
|
||||
status_data = await IndexingService.get_job_status(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
return IndexingJobStatus(**status_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/indexing/reindex",
|
||||
response_model=IndexingJobStatus,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Avvia job di reindex",
|
||||
description=(
|
||||
"Avvia un job di reindex full-text in background. "
|
||||
"mode='differential' indicizza solo i messaggi con search_vector NULL (piu' veloce). "
|
||||
"mode='full' riscrive il vettore di tutti i messaggi del tenant. "
|
||||
"Restituisce 409 se un job e' gia' in corso."
|
||||
),
|
||||
)
|
||||
async def start_reindex(
|
||||
body: StartReindexRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingJobStatus:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
app_settings = get_app_settings()
|
||||
|
||||
try:
|
||||
await IndexingService.start_reindex(
|
||||
tenant_id=target_tenant_id,
|
||||
mode=body.mode,
|
||||
started_by_email=current_user.email,
|
||||
redis_url=app_settings.redis_url,
|
||||
db_url=app_settings.database_url,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
# Ritorna lo stato appena creato
|
||||
status_data = await IndexingService.get_job_status(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
return IndexingJobStatus(**status_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/indexing/reindex",
|
||||
response_model=IndexingJobStatus,
|
||||
summary="Cancella job di reindex in corso",
|
||||
description=(
|
||||
"Invia il segnale di cancellazione al job di reindex in corso. "
|
||||
"Il job si fermera' alla fine del batch corrente (max qualche secondo). "
|
||||
"Se non c'e' nessun job in corso, ritorna 404."
|
||||
),
|
||||
)
|
||||
async def cancel_reindex(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingJobStatus:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
app_settings = get_app_settings()
|
||||
|
||||
cancelled = await IndexingService.cancel_reindex(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
|
||||
if not cancelled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Nessun job di reindex in corso per questo tenant",
|
||||
)
|
||||
|
||||
status_data = await IndexingService.get_job_status(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
return IndexingJobStatus(**status_data)
|
||||
|
||||
|
||||
# ─── Scansione allegati ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/indexing/rescan-status",
|
||||
response_model=IndexingJobStatus,
|
||||
summary="Stato job scansione allegati in corso",
|
||||
description=(
|
||||
"Restituisce lo stato del job di scansione allegati per il tenant: "
|
||||
"idle, running (con progresso), completed, failed o cancelled."
|
||||
),
|
||||
)
|
||||
async def get_rescan_status(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingJobStatus:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
app_settings = get_app_settings()
|
||||
status_data = await IndexingService.get_rescan_status(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
return IndexingJobStatus(**status_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/indexing/rescan",
|
||||
response_model=IndexingJobStatus,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
summary="Avvia job di scansione allegati",
|
||||
description=(
|
||||
"Avvia un job di scansione allegati in background. "
|
||||
"force=false (default): estrae il testo solo dagli allegati non ancora elaborati. "
|
||||
"force=true: ri-estrae il testo da tutti gli allegati del tenant. "
|
||||
"Al termine di ogni batch aggiorna anche il search_vector dei messaggi interessati. "
|
||||
"Restituisce 409 se un job di scansione o reindex e' gia' in corso."
|
||||
),
|
||||
)
|
||||
async def start_rescan(
|
||||
body: StartRescanRequest,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingJobStatus:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
app_settings = get_app_settings()
|
||||
|
||||
try:
|
||||
await IndexingService.start_rescan(
|
||||
tenant_id=target_tenant_id,
|
||||
started_by_email=current_user.email,
|
||||
redis_url=app_settings.redis_url,
|
||||
db_url=app_settings.database_url,
|
||||
force=body.force,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
status_data = await IndexingService.get_rescan_status(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
return IndexingJobStatus(**status_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/indexing/rescan",
|
||||
response_model=IndexingJobStatus,
|
||||
summary="Cancella job di scansione allegati in corso",
|
||||
description=(
|
||||
"Invia il segnale di cancellazione al job di scansione allegati in corso. "
|
||||
"Il job si fermera' alla fine del batch corrente. "
|
||||
"Se non c'e' nessun job in corso, ritorna 404."
|
||||
),
|
||||
)
|
||||
async def cancel_rescan(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
tenant_id: Optional[uuid.UUID] = Query(
|
||||
default=None,
|
||||
description="(solo super_admin) UUID del tenant su cui operare",
|
||||
),
|
||||
) -> IndexingJobStatus:
|
||||
target_tenant_id = _resolve_tenant_id(current_user, tenant_id)
|
||||
app_settings = get_app_settings()
|
||||
|
||||
cancelled = await IndexingService.cancel_rescan(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
|
||||
if not cancelled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Nessun job di scansione allegati in corso per questo tenant",
|
||||
)
|
||||
|
||||
status_data = await IndexingService.get_rescan_status(
|
||||
target_tenant_id, app_settings.redis_url
|
||||
)
|
||||
return IndexingJobStatus(**status_data)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Router template messaggi (Feature 1).
|
||||
|
||||
Endpoint:
|
||||
GET /templates – lista template del tenant
|
||||
POST /templates – crea template (admin)
|
||||
GET /templates/{id} – dettaglio template
|
||||
PUT /templates/{id} – aggiorna template (admin)
|
||||
DELETE /templates/{id} – elimina template (admin)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.dependencies import AdminUser, CurrentUser, DB
|
||||
from app.schemas.template import TemplateCreate, TemplateListResponse, TemplateResponse, TemplateUpdate
|
||||
from app.services.template_service import TemplateService
|
||||
|
||||
router = APIRouter(tags=["Templates"])
|
||||
|
||||
|
||||
@router.get("/templates", response_model=TemplateListResponse)
|
||||
async def list_templates(
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
q: str | None = Query(None, description="Filtro per nome"),
|
||||
) -> TemplateListResponse:
|
||||
"""Elenca i template del tenant corrente."""
|
||||
svc = TemplateService(db)
|
||||
items, total = await svc.list_templates(current_user.tenant_id, q=q)
|
||||
return TemplateListResponse(
|
||||
items=[TemplateResponse.model_validate(t) for t in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/templates", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_template(
|
||||
data: TemplateCreate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> TemplateResponse:
|
||||
"""Crea un nuovo template (solo admin)."""
|
||||
svc = TemplateService(db)
|
||||
template = await svc.create_template(current_user.tenant_id, data, created_by=current_user.id)
|
||||
return TemplateResponse.model_validate(template)
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def get_template(
|
||||
template_id: uuid.UUID,
|
||||
current_user: CurrentUser,
|
||||
db: DB,
|
||||
) -> TemplateResponse:
|
||||
"""Restituisce il dettaglio di un template."""
|
||||
svc = TemplateService(db)
|
||||
template = await svc.get_template(current_user.tenant_id, template_id)
|
||||
return TemplateResponse.model_validate(template)
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=TemplateResponse)
|
||||
async def update_template(
|
||||
template_id: uuid.UUID,
|
||||
data: TemplateUpdate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> TemplateResponse:
|
||||
"""Aggiorna un template esistente (solo admin)."""
|
||||
svc = TemplateService(db)
|
||||
template = await svc.update_template(current_user.tenant_id, template_id, data)
|
||||
return TemplateResponse.model_validate(template)
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_template(
|
||||
template_id: uuid.UUID,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> None:
|
||||
"""Elimina un template (solo admin)."""
|
||||
svc = TemplateService(db)
|
||||
await svc.delete_template(current_user.tenant_id, template_id)
|
||||
@@ -153,6 +153,20 @@ async def require_super_admin(
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_supervisor_or_admin(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""
|
||||
Richiede ruolo supervisor, admin o super_admin.
|
||||
|
||||
Il supervisor ha accesso in lettura implicito a tutte le caselle del tenant
|
||||
ma non puo' gestire la configurazione (caselle, utenti, permessi, impostazioni).
|
||||
"""
|
||||
if not current_user.is_supervisor_or_admin:
|
||||
raise ForbiddenError("Richiesto ruolo supervisore o amministratore")
|
||||
return current_user
|
||||
|
||||
|
||||
# ─── Protezione endpoint admin con X-Admin-Key header ─────────────────────────
|
||||
|
||||
async def verify_admin_key(
|
||||
@@ -176,4 +190,5 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)]
|
||||
AdminUser = Annotated[User, Depends(require_admin)]
|
||||
SuperAdminUser = Annotated[User, Depends(require_super_admin)]
|
||||
SupervisorOrAdminUser = Annotated[User, Depends(require_supervisor_or_admin)]
|
||||
DB = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
+7
-1
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
||||
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, tenants, templates, users, virtual_boxes, ws
|
||||
from app.api.v1 import settings as settings_router
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
@@ -96,6 +96,12 @@ app.include_router(virtual_boxes.router, prefix=API_PREFIX)
|
||||
app.include_router(notifications.router, prefix=API_PREFIX)
|
||||
app.include_router(labels.router, prefix=API_PREFIX)
|
||||
app.include_router(settings_router.router, prefix=API_PREFIX)
|
||||
app.include_router(reports.router, prefix=API_PREFIX)
|
||||
app.include_router(audit_log.router, prefix=API_PREFIX)
|
||||
app.include_router(templates.router, prefix=API_PREFIX)
|
||||
app.include_router(routing_rules.router, prefix=API_PREFIX)
|
||||
app.include_router(contacts.router, prefix=API_PREFIX)
|
||||
app.include_router(deadlines.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,3 +10,6 @@ from app.models.permission import MailboxPermission # noqa: F401
|
||||
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
|
||||
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
|
||||
from app.models.tenant_settings import TenantSettings # noqa: F401
|
||||
from app.models.template import MessageTemplate # noqa: F401
|
||||
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
|
||||
from app.models.pec_contact import PecContact # noqa: F401
|
||||
|
||||
@@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob.
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
@@ -18,7 +19,7 @@ from sqlalchemy import (
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -91,9 +92,24 @@ class Message(Base):
|
||||
is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Scadenzario (Feature 4)
|
||||
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deadline_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Conservazione
|
||||
is_pending_conservation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
pending_conservation_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Full-text search vector (aggiornato da trigger DB + worker per allegati)
|
||||
search_vector: Mapped[Any | None] = mapped_column(TSVECTOR(), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -124,6 +140,7 @@ class Message(Base):
|
||||
postgresql_where="parent_message_id IS NOT NULL",
|
||||
),
|
||||
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
|
||||
Index("idx_messages_fts", "search_vector", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -147,6 +164,8 @@ class Attachment(Base):
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Testo estratto dal worker (solo PDF e DOCX) per la ricerca full-text
|
||||
extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -179,6 +198,8 @@ class SendJob(Base):
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Invio differito (Feature 5): se impostato, il job non viene processato prima di questa data
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
queued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Modello PecContact – rubrica indirizzi PEC del tenant.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PecContact(Base):
|
||||
__tablename__ = "pec_contacts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
organization: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# auto_saved=True → salvato automaticamente dalla sincronizzazione IMAP
|
||||
# auto_saved=False → aggiunto manualmente dall'utente
|
||||
auto_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_pec_contact_email_tenant"),
|
||||
Index("idx_pec_contacts_tenant", "tenant_id"),
|
||||
Index("idx_pec_contacts_email", "tenant_id", "email"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PecContact {self.email!r}>"
|
||||
@@ -41,6 +41,7 @@ class MailboxPermission(Base):
|
||||
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
can_conserve: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
granted_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Modelli RoutingRule, RoutingRuleCondition, RoutingRuleAction.
|
||||
|
||||
Le regole di smistamento automatico vengono valutate a ogni messaggio in arrivo:
|
||||
1. Si caricano tutte le regole attive del tenant, ordinate per priority ASC
|
||||
2. Per ogni regola si valutano le condizioni (AND tra le condizioni della stessa regola)
|
||||
3. Se tutte le condizioni sono soddisfatte, si eseguono le azioni
|
||||
4. Se stop_processing=True, si ferma l'elaborazione (non vengono valutate regole successive)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RoutingRule(Base):
|
||||
__tablename__ = "routing_rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
# Se True, le regole successive non vengono valutate una volta che questa fa match
|
||||
stop_processing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relazioni
|
||||
conditions: Mapped[list["RoutingRuleCondition"]] = relationship(
|
||||
"RoutingRuleCondition", back_populates="rule", cascade="all, delete-orphan"
|
||||
)
|
||||
actions: Mapped[list["RoutingRuleAction"]] = relationship(
|
||||
"RoutingRuleAction", back_populates="rule", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_routing_rules_tenant", "tenant_id"),
|
||||
Index("idx_routing_rules_active", "tenant_id", "priority"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RoutingRule {self.name!r} priority={self.priority}>"
|
||||
|
||||
|
||||
class RoutingRuleCondition(Base):
|
||||
"""
|
||||
Singola condizione di una regola.
|
||||
|
||||
field : from_address | to_address | subject | mailbox_id | pec_type
|
||||
operator : contains | equals | starts_with | ends_with | regex | not_contains
|
||||
value : valore da confrontare (stringa)
|
||||
"""
|
||||
|
||||
__tablename__ = "routing_rule_conditions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
rule_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
field: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
operator: Mapped[str] = mapped_column(String(30), nullable=False, default="contains")
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="conditions")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_routing_conditions_rule", "rule_id"),
|
||||
)
|
||||
|
||||
|
||||
class RoutingRuleAction(Base):
|
||||
"""
|
||||
Singola azione di una regola.
|
||||
|
||||
action_type : apply_label | assign_vbox | mark_read | mark_starred | notify_webhook
|
||||
action_value : UUID della label/vbox, o URL del webhook
|
||||
"""
|
||||
|
||||
__tablename__ = "routing_rule_actions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
rule_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("routing_rules.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
action_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
action_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
rule: Mapped["RoutingRule"] = relationship("RoutingRule", back_populates="actions")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_routing_actions_rule", "rule_id"),
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Modello MessageTemplate – template riutilizzabili per la composizione PEC.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MessageTemplate(Base):
|
||||
__tablename__ = "message_templates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
subject: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
body_html: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "name", name="uq_template_name_tenant"),
|
||||
Index("idx_templates_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MessageTemplate {self.name!r}>"
|
||||
@@ -86,6 +86,16 @@ class User(Base):
|
||||
def is_super_admin(self) -> bool:
|
||||
return self.role == "super_admin"
|
||||
|
||||
@property
|
||||
def is_supervisor(self) -> bool:
|
||||
"""Ruolo supervisor: lettura implicita su tutte le caselle, senza poteri di gestione."""
|
||||
return self.role == "supervisor"
|
||||
|
||||
@property
|
||||
def is_supervisor_or_admin(self) -> bool:
|
||||
"""True per super_admin, admin e supervisor (accesso in lettura a tutto il tenant)."""
|
||||
return self.role in ("super_admin", "admin", "supervisor")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email!r} role={self.role!r}>"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Modulo notifiche – mittenti multi-canale
|
||||
-# Modulo notifiche – mittenti multi-canale
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Email SMTP sender – invio notifiche via SMTP con TLS/SSL o STARTTLS.
|
||||
|
||||
Config non sensibile (config):
|
||||
{
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 465,
|
||||
"smtp_use_tls": true, # SSL/TLS diretto (porta 465)
|
||||
"smtp_use_starttls": false, # STARTTLS (porta 587) – alternativo a use_tls
|
||||
"from_email": "noreply@example.com",
|
||||
"from_name": "PEChub Notifiche",
|
||||
"to_email": "destinatario@example.com"
|
||||
}
|
||||
|
||||
Config sensibile (config_enc → config_secret):
|
||||
{ "smtp_password": "..." }
|
||||
|
||||
Dipendenza: aiosmtplib (gia' in backend/pyproject.toml)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class EmailSMTPError(Exception):
|
||||
"""Errore durante l'invio di un'email di notifica."""
|
||||
|
||||
def __init__(self, message: str, smtp_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.smtp_code = smtp_code
|
||||
|
||||
|
||||
async def send_email_notification(
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_user: str,
|
||||
smtp_password: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: str | None = None,
|
||||
from_name: str = "PEChub Notifiche",
|
||||
use_tls: bool = True,
|
||||
use_starttls: bool = False,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
"""
|
||||
Invia un'email di notifica via SMTP.
|
||||
|
||||
Args:
|
||||
smtp_host: host SMTP
|
||||
smtp_port: porta SMTP
|
||||
smtp_user: username autenticazione
|
||||
smtp_password: password autenticazione
|
||||
from_email: indirizzo mittente
|
||||
to_email: indirizzo destinatario
|
||||
subject: oggetto email
|
||||
body_text: testo plain
|
||||
body_html: testo HTML (opzionale)
|
||||
from_name: nome visualizzato mittente
|
||||
use_tls: usa SSL/TLS diretto (porta 465)
|
||||
use_starttls: usa STARTTLS (porta 587) – alternativo a use_tls
|
||||
timeout: timeout connessione in secondi
|
||||
|
||||
Raises:
|
||||
EmailSMTPError: in caso di errori di autenticazione, connessione o invio
|
||||
"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
|
||||
msg["To"] = to_email
|
||||
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
msg["X-Mailer"] = "PEChub/1.0"
|
||||
|
||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||
if body_html:
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=smtp_host,
|
||||
port=smtp_port,
|
||||
username=smtp_user,
|
||||
password=smtp_password,
|
||||
use_tls=use_tls,
|
||||
start_tls=use_starttls,
|
||||
timeout=timeout,
|
||||
)
|
||||
except aiosmtplib.SMTPAuthenticationError as exc:
|
||||
raise EmailSMTPError(
|
||||
f"Autenticazione SMTP fallita per {smtp_user}@{smtp_host}: {exc}",
|
||||
smtp_code=535,
|
||||
) from exc
|
||||
except aiosmtplib.SMTPConnectError as exc:
|
||||
raise EmailSMTPError(
|
||||
f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
|
||||
) from exc
|
||||
except aiosmtplib.SMTPServerDisconnected as exc:
|
||||
raise EmailSMTPError(
|
||||
f"Server SMTP {smtp_host} ha chiuso la connessione: {exc}"
|
||||
) from exc
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise EmailSMTPError(f"Errore invio email: {exc}") from exc
|
||||
|
||||
|
||||
async def send_test_email(
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_user: str,
|
||||
smtp_password: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
channel_name: str = "PEChub",
|
||||
from_name: str = "PEChub Notifiche",
|
||||
use_tls: bool = True,
|
||||
use_starttls: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Invia un'email di test per verificare la configurazione del canale.
|
||||
|
||||
Raises:
|
||||
EmailSMTPError: se la connessione o l'autenticazione falliscono
|
||||
"""
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
subject = f"[PEChub] Test canale email: {channel_name}"
|
||||
body_text = (
|
||||
f"PEChub – Test canale Email\n\n"
|
||||
f"Il canale '{channel_name}' e' configurato correttamente.\n\n"
|
||||
f"Data/ora: {ts}\n"
|
||||
f"Destinatario: {to_email}"
|
||||
)
|
||||
body_html = (
|
||||
f"<h2>PEChub – Test canale Email</h2>"
|
||||
f"<p>Il canale <strong>{channel_name}</strong> e' configurato correttamente.</p>"
|
||||
f"<p>Data/ora: <em>{ts}</em><br>"
|
||||
f"Destinatario: {to_email}</p>"
|
||||
f"<hr><p style='font-size:11px;color:#888'>Inviato da PEChub Notification Engine</p>"
|
||||
)
|
||||
await send_email_notification(
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=smtp_port,
|
||||
smtp_user=smtp_user,
|
||||
smtp_password=smtp_password,
|
||||
from_email=from_email,
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
from_name=from_name,
|
||||
use_tls=use_tls,
|
||||
use_starttls=use_starttls,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Webhook sender – POST HTTP con firma HMAC-SHA256.
|
||||
|
||||
Config non sensibile (config):
|
||||
{ "url": "https://...", "content_type": "application/json" }
|
||||
|
||||
Config sensibile (config_enc → config_secret):
|
||||
{ "webhook_secret": "..." } # opzionale – usato per firma HMAC
|
||||
|
||||
Header inviati:
|
||||
Content-Type: application/json
|
||||
X-PEChub-Event: {event_type}
|
||||
X-Hub-Signature-256: sha256={hex} (solo se webhook_secret configurato)
|
||||
X-Delivery: {uuid}
|
||||
User-Agent: PEChub-Webhook/1.0
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid as uuid_mod
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class WebhookError(Exception):
|
||||
"""Errore durante l'invio di una notifica webhook."""
|
||||
|
||||
def __init__(self, message: str, http_status: int | None = None):
|
||||
super().__init__(message)
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
async def send_webhook(
|
||||
url: str,
|
||||
payload: dict,
|
||||
event_type: str = "new_message",
|
||||
webhook_secret: str | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""
|
||||
Invia un payload JSON a un webhook URL.
|
||||
|
||||
Args:
|
||||
url: URL destinatario del webhook
|
||||
payload: dict serializzato come JSON nel body
|
||||
event_type: valore dell'header X-PEChub-Event
|
||||
webhook_secret: segreto per firma HMAC-SHA256 (opzionale)
|
||||
timeout: timeout HTTP in secondi
|
||||
|
||||
Returns:
|
||||
dict con http_status, response_text, delivery_id
|
||||
|
||||
Raises:
|
||||
WebhookError: in caso di timeout, errore di rete o HTTP >= 400
|
||||
"""
|
||||
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||
delivery_id = str(uuid_mod.uuid4())
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-PEChub-Event": event_type,
|
||||
"X-Delivery": delivery_id,
|
||||
"User-Agent": "PEChub-Webhook/1.0",
|
||||
}
|
||||
|
||||
if webhook_secret:
|
||||
sig = hmac.new(
|
||||
webhook_secret.encode("utf-8"),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
headers["X-Hub-Signature-256"] = f"sha256={sig}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, content=body, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WebhookError(
|
||||
f"Timeout webhook dopo {timeout}s"
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise WebhookError(f"Errore di rete webhook: {exc}") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise WebhookError(
|
||||
f"Webhook ha risposto con HTTP {response.status_code}: "
|
||||
f"{response.text[:200]}",
|
||||
http_status=response.status_code,
|
||||
)
|
||||
|
||||
return {
|
||||
"http_status": response.status_code,
|
||||
"response_text": response.text[:500],
|
||||
"delivery_id": delivery_id,
|
||||
}
|
||||
|
||||
|
||||
async def send_test_webhook(
|
||||
url: str,
|
||||
webhook_secret: str | None = None,
|
||||
channel_name: str = "PEChub",
|
||||
) -> dict:
|
||||
"""Invia un payload di test al webhook per verificare la configurazione."""
|
||||
payload = {
|
||||
"event": "test",
|
||||
"channel": channel_name,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"message": "Notifica di test da PEChub",
|
||||
"data": {
|
||||
"subject": "[TEST] PEC di prova",
|
||||
"from_address": "test@pec.example.it",
|
||||
"pec_type": "posta_certificata",
|
||||
"direction": "inbound",
|
||||
},
|
||||
}
|
||||
return await send_webhook(
|
||||
url=url,
|
||||
payload=payload,
|
||||
event_type="test",
|
||||
webhook_secret=webhook_secret,
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
WhatsApp sender – Meta Cloud API v18.
|
||||
|
||||
Config non sensibile (config):
|
||||
{
|
||||
"phone_number_id": "123456789", # ID numero mittente Meta Business
|
||||
"to_phone": "+393331234567" # numero destinatario con prefisso
|
||||
}
|
||||
|
||||
Config sensibile (config_enc → config_secret):
|
||||
{ "access_token": "EAABs..." } # Meta Graph API token
|
||||
|
||||
API endpoint:
|
||||
POST https://graph.facebook.com/v18.0/{phone_number_id}/messages
|
||||
|
||||
Nota: richiede un account Meta Business verificato con WhatsApp Business API.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class WhatsAppError(Exception):
|
||||
"""Errore durante l'invio di un messaggio WhatsApp."""
|
||||
|
||||
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.http_status = http_status
|
||||
self.api_code = api_code
|
||||
|
||||
|
||||
async def send_whatsapp_message(
|
||||
phone_number_id: str,
|
||||
to_phone: str,
|
||||
text: str,
|
||||
access_token: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""
|
||||
Invia un messaggio di testo WhatsApp via Meta Cloud API.
|
||||
|
||||
Args:
|
||||
phone_number_id: ID del numero WhatsApp Business mittente
|
||||
to_phone: numero destinatario (formato E.164, es. +393331234567)
|
||||
text: testo del messaggio
|
||||
access_token: Meta Graph API Bearer token
|
||||
timeout: timeout HTTP in secondi
|
||||
|
||||
Returns:
|
||||
dict con message_id dalla risposta API
|
||||
|
||||
Raises:
|
||||
WhatsAppError: in caso di errore HTTP o risposta API non-ok
|
||||
"""
|
||||
url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_phone.replace(" ", "").replace("-", ""),
|
||||
"type": "text",
|
||||
"text": {"body": text},
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WhatsAppError(
|
||||
f"Timeout WhatsApp API dopo {timeout}s"
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
|
||||
|
||||
if response.status_code == 401:
|
||||
raise WhatsAppError(
|
||||
"Token Meta non valido o scaduto",
|
||||
http_status=401,
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
err_data = response.json()
|
||||
err_msg = err_data.get("error", {}).get("message", response.text[:200])
|
||||
err_code = err_data.get("error", {}).get("code")
|
||||
except Exception:
|
||||
err_msg = response.text[:200]
|
||||
err_code = None
|
||||
raise WhatsAppError(
|
||||
f"Meta API errore HTTP {response.status_code}: {err_msg}",
|
||||
http_status=response.status_code,
|
||||
api_code=err_code,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
messages = data.get("messages", [])
|
||||
message_id = messages[0].get("id") if messages else None
|
||||
return {"message_id": message_id, "http_status": response.status_code}
|
||||
|
||||
|
||||
async def send_test_whatsapp(
|
||||
phone_number_id: str,
|
||||
to_phone: str,
|
||||
access_token: str,
|
||||
channel_name: str = "PEChub",
|
||||
) -> dict:
|
||||
"""Invia un messaggio WhatsApp di test per verificare la configurazione."""
|
||||
from datetime import datetime
|
||||
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
text = (
|
||||
f"*PEChub – Test canale WhatsApp*\n\n"
|
||||
f"Il canale _{channel_name}_ e' configurato correttamente.\n\n"
|
||||
f"Data/ora: {ts}"
|
||||
)
|
||||
return await send_whatsapp_message(
|
||||
phone_number_id=phone_number_id,
|
||||
to_phone=to_phone,
|
||||
text=text,
|
||||
access_token=access_token,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Schemi Pydantic per Audit Log.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.core.pagination import PaginatedResponse
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
"""Risposta singolo evento audit."""
|
||||
|
||||
id: int
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
action: str
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[uuid.UUID] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
payload: Optional[dict] = None
|
||||
outcome: str
|
||||
occurred_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("ip_address", mode="before")
|
||||
@classmethod
|
||||
def coerce_ip_address(cls, v: Any) -> Optional[str]:
|
||||
"""Converte IPv4Address/IPv6Address (tipo PostgreSQL INET) in stringa."""
|
||||
if v is None:
|
||||
return None
|
||||
return str(v)
|
||||
|
||||
|
||||
# Lista paginata
|
||||
AuditLogListResponse = PaginatedResponse[AuditLogResponse]
|
||||
@@ -116,3 +116,15 @@ class ConnectionTestResult(BaseModel):
|
||||
message: str
|
||||
latency_ms: float | None = None
|
||||
capabilities: list[str] | None = None # Solo per IMAP
|
||||
|
||||
|
||||
class MailboxSyncResponse(BaseModel):
|
||||
"""Risposta all'accodamento di un job di sincronizzazione manuale."""
|
||||
status: str
|
||||
mailbox_id: uuid.UUID
|
||||
message: str
|
||||
|
||||
|
||||
class MailboxUnreadCountsResponse(BaseModel):
|
||||
"""Conteggio messaggi non letti per casella."""
|
||||
counts: dict[str, int]
|
||||
|
||||
@@ -49,6 +49,12 @@ class MessageResponse(BaseModel):
|
||||
is_starred: bool = False
|
||||
is_archived: bool = False
|
||||
archived_at: Optional[datetime] = None
|
||||
is_trashed: bool = False
|
||||
trashed_at: Optional[datetime] = None
|
||||
is_pending_conservation: bool = False
|
||||
pending_conservation_at: Optional[datetime] = None
|
||||
is_conserved: bool = False
|
||||
conserved_at: Optional[datetime] = None
|
||||
raw_eml_path: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -84,12 +90,19 @@ class MessageUpdateRequest(BaseModel):
|
||||
is_read: Optional[bool] = None
|
||||
is_starred: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
is_trashed: Optional[bool] = None
|
||||
is_pending_conservation: Optional[bool] = None
|
||||
is_conserved: Optional[bool] = None
|
||||
|
||||
|
||||
class MessageBulkUpdateRequest(BaseModel):
|
||||
ids: list[uuid.UUID]
|
||||
is_read: Optional[bool] = None
|
||||
is_starred: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
is_trashed: Optional[bool] = None
|
||||
is_pending_conservation: Optional[bool] = None
|
||||
is_conserved: Optional[bool] = None
|
||||
|
||||
|
||||
class MessageBulkUpdateResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Schemi Pydantic per PecContact (Feature 6 – Rubrica indirizzi PEC).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class PecContactCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str | None = None
|
||||
organization: str | None = None
|
||||
notes: str | None = None
|
||||
is_favorite: bool = False
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def email_lowercase(cls, v: str) -> str:
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
class PecContactUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
organization: str | None = None
|
||||
notes: str | None = None
|
||||
is_favorite: bool | None = None
|
||||
|
||||
|
||||
class PecContactResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
email: str
|
||||
name: str | None = None
|
||||
organization: str | None = None
|
||||
notes: str | None = None
|
||||
is_favorite: bool
|
||||
auto_saved: bool
|
||||
created_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PecContactListResponse(BaseModel):
|
||||
items: list[PecContactResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PecContactImportResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: list[str] = []
|
||||
@@ -12,6 +12,7 @@ class PermissionGrantRequest(BaseModel):
|
||||
can_read: bool = True
|
||||
can_send: bool = False
|
||||
can_manage: bool = False
|
||||
can_conserve: bool = False
|
||||
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
@@ -22,6 +23,7 @@ class PermissionResponse(BaseModel):
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
can_conserve: bool
|
||||
granted_by: uuid.UUID | None
|
||||
granted_at: datetime
|
||||
|
||||
@@ -36,6 +38,7 @@ class UserMailboxPermissionResponse(BaseModel):
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
can_conserve: bool
|
||||
|
||||
|
||||
class MailboxUserPermissionResponse(BaseModel):
|
||||
@@ -47,4 +50,5 @@ class MailboxUserPermissionResponse(BaseModel):
|
||||
can_read: bool
|
||||
can_send: bool
|
||||
can_manage: bool
|
||||
can_conserve: bool
|
||||
granted_at: datetime
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Schemi Pydantic per la Dashboard e Reportistica (Fase 7).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KpiSummary(BaseModel):
|
||||
"""Contatori KPI principali del tenant."""
|
||||
|
||||
# Oggi
|
||||
received_today: int = Field(0, description="PEC ricevute oggi")
|
||||
sent_today: int = Field(0, description="PEC inviate oggi (outbound)")
|
||||
|
||||
# Ultimi 7 giorni
|
||||
received_7d: int = Field(0, description="PEC ricevute negli ultimi 7 giorni")
|
||||
sent_7d: int = Field(0, description="PEC inviate negli ultimi 7 giorni")
|
||||
|
||||
# Ultimi 30 giorni
|
||||
received_30d: int = Field(0, description="PEC ricevute negli ultimi 30 giorni")
|
||||
sent_30d: int = Field(0, description="PEC inviate negli ultimi 30 giorni")
|
||||
|
||||
# Stato
|
||||
anomalie_attive: int = Field(0, description="Messaggi outbound in stato anomaly")
|
||||
tasso_consegna: float = Field(0.0, description="Percentuale consegna (0-100)")
|
||||
caselle_in_errore: int = Field(0, description="Caselle con status=error")
|
||||
messaggi_non_letti: int = Field(0, description="Messaggi inbound non letti")
|
||||
|
||||
# Totali assoluti
|
||||
totale_messaggi: int = Field(0, description="Totale messaggi nel tenant")
|
||||
|
||||
|
||||
class DailyStat(BaseModel):
|
||||
"""Statistiche giornaliere per il grafico a barre."""
|
||||
|
||||
day: date = Field(..., description="Data (YYYY-MM-DD)")
|
||||
received: int = Field(0, description="PEC ricevute in quel giorno")
|
||||
sent: int = Field(0, description="PEC inviate in quel giorno")
|
||||
|
||||
|
||||
class OutboundStateStat(BaseModel):
|
||||
"""Conteggio messaggi outbound per stato (per il grafico a torta)."""
|
||||
|
||||
state: str
|
||||
count: int
|
||||
|
||||
|
||||
class MailboxStat(BaseModel):
|
||||
"""Statistiche per singola casella."""
|
||||
|
||||
mailbox_id: uuid.UUID
|
||||
email_address: str
|
||||
display_name: Optional[str] = None
|
||||
status: str
|
||||
received_total: int = 0
|
||||
sent_total: int = 0
|
||||
anomalie: int = 0
|
||||
non_letti: int = 0
|
||||
last_sync_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ReportSummaryResponse(BaseModel):
|
||||
"""Risposta completa dell'endpoint /reports/summary."""
|
||||
|
||||
generated_at: datetime
|
||||
period_days: int = Field(..., description="Numero di giorni del periodo selezionato")
|
||||
|
||||
kpi: KpiSummary
|
||||
daily_stats: list[DailyStat] = Field(default_factory=list)
|
||||
outbound_states: list[OutboundStateStat] = Field(default_factory=list)
|
||||
mailbox_stats: list[MailboxStat] = Field(default_factory=list)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Schemi Pydantic per RoutingRule (Feature 2 – Regole di smistamento automatico).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
# Valori validi per field nelle condizioni
|
||||
CONDITION_FIELDS = Literal[
|
||||
"from_address", "to_address", "subject", "mailbox_id", "pec_type"
|
||||
]
|
||||
# Operatori supportati
|
||||
CONDITION_OPERATORS = Literal[
|
||||
"contains", "equals", "starts_with", "ends_with", "regex", "not_contains"
|
||||
]
|
||||
# Tipi di azione
|
||||
ACTION_TYPES = Literal[
|
||||
"apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook"
|
||||
]
|
||||
|
||||
|
||||
class RoutingRuleConditionCreate(BaseModel):
|
||||
field: CONDITION_FIELDS
|
||||
operator: CONDITION_OPERATORS = "contains"
|
||||
value: str
|
||||
|
||||
@field_validator("value")
|
||||
@classmethod
|
||||
def value_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Il valore della condizione non puo' essere vuoto")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class RoutingRuleActionCreate(BaseModel):
|
||||
action_type: ACTION_TYPES
|
||||
action_value: str | None = None
|
||||
|
||||
|
||||
class RoutingRuleCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
priority: int = 100
|
||||
stop_processing: bool = True
|
||||
conditions: list[RoutingRuleConditionCreate] = []
|
||||
actions: list[RoutingRuleActionCreate] = []
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Il nome della regola non puo' essere vuoto")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("priority")
|
||||
@classmethod
|
||||
def priority_positive(cls, v: int) -> int:
|
||||
if v < 1:
|
||||
raise ValueError("La priorita' deve essere >= 1")
|
||||
return v
|
||||
|
||||
|
||||
class RoutingRuleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
priority: int | None = None
|
||||
stop_processing: bool | None = None
|
||||
conditions: list[RoutingRuleConditionCreate] | None = None
|
||||
actions: list[RoutingRuleActionCreate] | None = None
|
||||
|
||||
|
||||
class RoutingRuleConditionResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
field: str
|
||||
operator: str
|
||||
value: str
|
||||
|
||||
|
||||
class RoutingRuleActionResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
action_type: str
|
||||
action_value: str | None = None
|
||||
|
||||
|
||||
class RoutingRuleResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
priority: int
|
||||
stop_processing: bool
|
||||
conditions: list[RoutingRuleConditionResponse] = []
|
||||
actions: list[RoutingRuleActionResponse] = []
|
||||
created_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RoutingRuleListResponse(BaseModel):
|
||||
items: list[RoutingRuleResponse]
|
||||
total: int
|
||||
@@ -37,6 +37,9 @@ class SendPecRequest(BaseModel):
|
||||
reply_to_message_id: uuid.UUID | None = None
|
||||
"""UUID del messaggio a cui si risponde (per threading, opzionale)."""
|
||||
|
||||
scheduled_at: datetime | None = None
|
||||
"""Data/ora di invio differito (Feature 5). Se None, invio immediato."""
|
||||
|
||||
@field_validator("to_addresses")
|
||||
@classmethod
|
||||
def at_least_one_recipient(cls, v: list[EmailStr]) -> list[EmailStr]:
|
||||
@@ -67,6 +70,7 @@ class SendJobResponse(BaseModel):
|
||||
max_attempts: int
|
||||
next_retry_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
scheduled_at: datetime | None = None
|
||||
queued_at: datetime
|
||||
sent_at: datetime | None = None
|
||||
created_by: uuid.UUID | None = None
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Schemi Pydantic per MessageTemplate (Feature 1 – Template messaggi).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
subject: str = ""
|
||||
body_text: str | None = None
|
||||
body_html: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Il nome del template non puo' essere vuoto")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
subject: str | None = None
|
||||
body_text: str | None = None
|
||||
body_html: str | None = None
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
body_html: str | None = None
|
||||
created_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
items: list[TemplateResponse]
|
||||
total: int
|
||||
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
Schema Pydantic per TenantSettings – lettura e aggiornamento impostazioni tenant.
|
||||
Include schemi per il modulo di indicizzazione full-text.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
ArchivalMode = Literal["mock", "production"]
|
||||
@@ -68,3 +69,53 @@ class TenantSettingsUpdate(BaseModel):
|
||||
if v is not None and v not in ("mock", "production"):
|
||||
raise ValueError("archival_mode deve essere 'mock' o 'production'")
|
||||
return v
|
||||
|
||||
|
||||
# ─── Schemi indicizzazione full-text ──────────────────────────────────────────
|
||||
|
||||
class IndexingStats(BaseModel):
|
||||
"""Statistiche di copertura dell'indicizzazione per un tenant."""
|
||||
|
||||
total_messages: int
|
||||
indexed_messages: int
|
||||
unindexed_messages: int
|
||||
coverage_pct: float # percentuale messaggi con search_vector != NULL
|
||||
|
||||
attachments_total: int # allegati PDF/DOCX totali
|
||||
attachments_extracted: int # allegati con testo estratto
|
||||
attachments_pct: float # percentuale allegati con testo estratto
|
||||
|
||||
|
||||
class IndexingJobStatus(BaseModel):
|
||||
"""Stato di un job di reindex in corso o completato."""
|
||||
|
||||
status: str # idle | running | completed | failed | cancelled
|
||||
mode: Optional[str] = None # full | differential
|
||||
total: int = 0
|
||||
processed: int = 0
|
||||
progress_pct: float = 0.0
|
||||
|
||||
started_at: Optional[str] = None # ISO datetime
|
||||
finished_at: Optional[str] = None # ISO datetime
|
||||
started_by: Optional[str] = None # email utente che ha avviato il job
|
||||
elapsed_seconds: Optional[int] = None
|
||||
is_stale: bool = False # True se running da piu' di STALE_THRESHOLD_HOURS
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class StartReindexRequest(BaseModel):
|
||||
"""Body per POST /settings/indexing/reindex."""
|
||||
|
||||
mode: Literal["full", "differential"] = "differential"
|
||||
|
||||
|
||||
class StartRescanRequest(BaseModel):
|
||||
"""Body per POST /settings/indexing/rescan."""
|
||||
|
||||
force: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"False (default): estrae solo allegati con extracted_text NULL. "
|
||||
"True: ri-estrae tutti gli allegati, sovrascrivendo i testi gia' presenti."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Servizio Audit Log – registrazione e consultazione degli eventi di sistema.
|
||||
|
||||
Uso tipico nei router/servizi:
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
await log_audit(
|
||||
db=db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
user_id=current_user.id,
|
||||
action="user.created",
|
||||
resource_type="user",
|
||||
resource_id=new_user.id,
|
||||
outcome="success",
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
payload={"email": new_user.email},
|
||||
)
|
||||
"""
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.pagination import PaginatedResponse, PaginationParams
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.schemas.audit_log import AuditLogResponse
|
||||
|
||||
|
||||
# ─── Helper standalone (da chiamare ovunque senza istanziare la classe) ───────
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
action: str,
|
||||
*,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[uuid.UUID] = None,
|
||||
outcome: str = "success",
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
payload: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Inserisce un record di audit log nella sessione corrente.
|
||||
Non fa commit: il commit avviene con la transazione del chiamante.
|
||||
Non solleva eccezioni: gli errori sono loggati ma non propagati
|
||||
per evitare di bloccare l'operazione principale.
|
||||
"""
|
||||
try:
|
||||
entry = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
payload=payload or {},
|
||||
outcome=outcome,
|
||||
)
|
||||
db.add(entry)
|
||||
except Exception:
|
||||
# Mai bloccare l'operazione principale per un errore di audit
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
"Impossibile registrare evento audit: action=%s", action, exc_info=True
|
||||
)
|
||||
|
||||
|
||||
# ─── Servizio per query (usato dal router) ────────────────────────────────────
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
action: Optional[str] = None,
|
||||
user_id: Optional[uuid.UUID] = None,
|
||||
outcome: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
) -> PaginatedResponse[AuditLogResponse]:
|
||||
"""
|
||||
Restituisce la lista paginata degli eventi audit.
|
||||
|
||||
Se tenant_id e' None (super_admin), restituisce eventi di tutti i tenant.
|
||||
"""
|
||||
filters = []
|
||||
|
||||
if tenant_id is not None:
|
||||
filters.append(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if action:
|
||||
# Supporta prefisso: "auth." corrisponde a tutti gli eventi auth.*
|
||||
if action.endswith("*"):
|
||||
filters.append(AuditLog.action.like(action[:-1] + "%"))
|
||||
else:
|
||||
filters.append(AuditLog.action == action)
|
||||
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id == user_id)
|
||||
|
||||
if outcome:
|
||||
filters.append(AuditLog.outcome == outcome)
|
||||
|
||||
if date_from:
|
||||
filters.append(AuditLog.occurred_at >= date_from)
|
||||
|
||||
if date_to:
|
||||
filters.append(AuditLog.occurred_at <= date_to)
|
||||
|
||||
if resource_type:
|
||||
filters.append(AuditLog.resource_type == resource_type)
|
||||
|
||||
where_clause = and_(*filters) if filters else True # type: ignore[arg-type]
|
||||
|
||||
# Count totale
|
||||
count_q = select(func.count()).select_from(AuditLog).where(where_clause)
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
|
||||
# Dati paginati
|
||||
offset = (page - 1) * page_size
|
||||
items_q = (
|
||||
select(AuditLog)
|
||||
.where(where_clause)
|
||||
.order_by(AuditLog.occurred_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await self.db.execute(items_q)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
pages = math.ceil(total / page_size) if page_size > 0 else 0
|
||||
|
||||
return PaginatedResponse[AuditLogResponse](
|
||||
items=[AuditLogResponse.model_validate(item) for item in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
|
||||
from app.core.security import decrypt_credential, encrypt_credential
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.tenant import Tenant
|
||||
from app.services.audit_service import log_audit
|
||||
from app.schemas.mailbox import (
|
||||
ConnectionTestRequest,
|
||||
ConnectionTestResult,
|
||||
@@ -85,6 +86,15 @@ class MailboxService:
|
||||
)
|
||||
self.db.add(mailbox)
|
||||
await self.db.flush()
|
||||
await log_audit(
|
||||
self.db,
|
||||
"mailbox.created",
|
||||
tenant_id=tenant_id,
|
||||
user_id=created_by,
|
||||
resource_type="mailbox",
|
||||
resource_id=mailbox.id,
|
||||
payload={"email_address": mailbox.email_address},
|
||||
)
|
||||
return mailbox
|
||||
|
||||
async def list_mailboxes(
|
||||
@@ -175,6 +185,14 @@ class MailboxService:
|
||||
mailbox.status = "active"
|
||||
|
||||
await self.db.flush()
|
||||
await log_audit(
|
||||
self.db,
|
||||
"mailbox.updated",
|
||||
tenant_id=tenant_id,
|
||||
resource_type="mailbox",
|
||||
resource_id=mailbox_id,
|
||||
payload={"mailbox_id": str(mailbox_id)},
|
||||
)
|
||||
return mailbox
|
||||
|
||||
async def delete_mailbox(
|
||||
@@ -184,8 +202,17 @@ class MailboxService:
|
||||
) -> None:
|
||||
"""Soft-delete: imposta status=deleted."""
|
||||
mailbox = await self.get_mailbox(mailbox_id, tenant_id)
|
||||
email = mailbox.email_address
|
||||
mailbox.status = "deleted"
|
||||
await self.db.flush()
|
||||
await log_audit(
|
||||
self.db,
|
||||
"mailbox.deleted",
|
||||
tenant_id=tenant_id,
|
||||
resource_type="mailbox",
|
||||
resource_id=mailbox_id,
|
||||
payload={"email_address": email},
|
||||
)
|
||||
|
||||
# ─── Decrypt helpers (usati internamente e dal worker) ───────────────────
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
"""
|
||||
Servizio Notifiche Multi-canale – CRUD canali, regole, log.
|
||||
Servizio Notifiche Multi-canale – CRUD canali, regole, log + dispatch.
|
||||
|
||||
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
|
||||
la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
|
||||
stub usiamo Fernet (libreria cryptography), facilmente sostituibile
|
||||
con una implementazione GCM dedicata.
|
||||
Cifratura: AES-256-GCM via libreria cryptography.
|
||||
Formato config_enc: base64( nonce(12) || ciphertext+tag )
|
||||
Chiave: ENCRYPTION_KEY (hex 64 char = 32 byte) dalla config.
|
||||
|
||||
Backward compatibility: se il valore non decrittografa come GCM, viene
|
||||
tentato il fallback a base64 grezzo (configurazioni precedenti al fix).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import NotFoundError
|
||||
@@ -27,20 +31,53 @@ from app.schemas.notification import (
|
||||
NotificationRuleUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _encrypt(data: dict) -> str:
|
||||
"""Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
|
||||
# In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
|
||||
raw = json.dumps(data).encode()
|
||||
return base64.b64encode(raw).decode()
|
||||
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
|
||||
|
||||
def _encrypt(data: dict, key: bytes | None = None) -> str:
|
||||
"""
|
||||
Cifra un dict JSON con AES-256-GCM.
|
||||
|
||||
Formato output: base64( nonce(12 byte) || ciphertext+tag(16 byte) )
|
||||
"""
|
||||
if key is None:
|
||||
key = settings.encryption_key_bytes
|
||||
nonce = os.urandom(12)
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None) # include tag
|
||||
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
||||
|
||||
|
||||
def _decrypt(enc: str) -> dict:
|
||||
"""Decifra il valore restituito da _encrypt."""
|
||||
raw = base64.b64decode(enc.encode())
|
||||
return json.loads(raw.decode())
|
||||
def _decrypt(enc: str, key: bytes | None = None) -> dict:
|
||||
"""
|
||||
Decifra il valore prodotto da _encrypt.
|
||||
|
||||
Backward compatible: se il dato non e' GCM valido, prova il
|
||||
vecchio base64 grezzo (usato prima del fix di sicurezza).
|
||||
"""
|
||||
if key is None:
|
||||
key = settings.encryption_key_bytes
|
||||
try:
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
if len(raw) > 28: # 12 nonce + 16 tag minimo
|
||||
nonce = raw[:12]
|
||||
ciphertext = raw[12:]
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||
return json.loads(plaintext.decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: base64 grezzo (configurazioni create prima del fix GCM)
|
||||
try:
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -113,6 +150,7 @@ class NotificationService:
|
||||
if data.config is not None:
|
||||
channel.config = data.config
|
||||
if data.config_secret is not None:
|
||||
# Re-cifra sempre con AES-256-GCM (aggiorna anche i vecchi base64)
|
||||
channel.config_enc = _encrypt(data.config_secret)
|
||||
|
||||
await self.db.flush()
|
||||
@@ -128,18 +166,16 @@ class NotificationService:
|
||||
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> ChannelTestResult:
|
||||
"""
|
||||
Invia un messaggio di test al canale configurato.
|
||||
Invia un messaggio di test reale al canale configurato.
|
||||
|
||||
Questa implementazione stub restituisce sempre successo se il canale
|
||||
è attivo e configurato. Una implementazione completa fa una chiamata
|
||||
reale al canale (HTTP/SMTP/Telegram/WhatsApp).
|
||||
Esegue invio effettivo per Telegram, Webhook, Email SMTP e WhatsApp.
|
||||
"""
|
||||
channel = await self.get_channel(channel_id, tenant_id)
|
||||
|
||||
if not channel.is_active:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message="Il canale è disabilitato",
|
||||
message="Il canale e' disabilitato",
|
||||
)
|
||||
|
||||
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
|
||||
@@ -148,21 +184,23 @@ class NotificationService:
|
||||
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
|
||||
)
|
||||
|
||||
# Validazione configurazione minima per tipo canale
|
||||
config = channel.config or {}
|
||||
secret = {}
|
||||
if channel.config_enc:
|
||||
try:
|
||||
secret = _decrypt(channel.config_enc)
|
||||
except Exception as e:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore decifratura configurazione sensibile: {e}",
|
||||
)
|
||||
|
||||
channel_type = channel.channel_type
|
||||
|
||||
if channel_type == "webhook":
|
||||
if not config.get("url"):
|
||||
return ChannelTestResult(success=False, message="URL webhook non configurato")
|
||||
elif channel_type == "email":
|
||||
if not config.get("to_email"):
|
||||
return ChannelTestResult(success=False, message="Email destinatario non configurata")
|
||||
elif channel_type == "telegram":
|
||||
# ── Telegram ──────────────────────────────────────────────────────────
|
||||
if channel_type == "telegram":
|
||||
if not config.get("chat_id"):
|
||||
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
|
||||
# Invio reale via Bot API
|
||||
secret = _decrypt(channel.config_enc) if channel.config_enc else {}
|
||||
bot_token = secret.get("bot_token")
|
||||
if not bot_token:
|
||||
return ChannelTestResult(success=False, message="Bot token Telegram non configurato")
|
||||
@@ -176,28 +214,128 @@ class NotificationService:
|
||||
msg_id = result.get("message_id")
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Messaggio Telegram inviato con successo (message_id={msg_id}).",
|
||||
message=f"Messaggio Telegram inviato (message_id={msg_id}).",
|
||||
http_status=200,
|
||||
)
|
||||
except TelegramError as exc:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore Telegram: {exc}",
|
||||
http_status=exc.http_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore imprevisto durante il test Telegram: {exc}",
|
||||
message=f"Errore Telegram: {exc}",
|
||||
)
|
||||
elif channel_type == "whatsapp":
|
||||
if not config.get("phone_number"):
|
||||
return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
|
||||
|
||||
# ── Webhook ───────────────────────────────────────────────────────────
|
||||
elif channel_type == "webhook":
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
return ChannelTestResult(success=False, message="URL webhook non configurato")
|
||||
webhook_secret = secret.get("webhook_secret")
|
||||
try:
|
||||
from app.notifications.webhook import WebhookError, send_test_webhook
|
||||
result = await send_test_webhook(
|
||||
url=url,
|
||||
webhook_secret=webhook_secret,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=(
|
||||
f"Webhook raggiunto con successo "
|
||||
f"(HTTP {result['http_status']}, delivery={result['delivery_id']})."
|
||||
),
|
||||
http_status=result["http_status"],
|
||||
)
|
||||
except Exception as exc:
|
||||
http_status = getattr(exc, "http_status", None)
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore webhook: {exc}",
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
# ── Email SMTP ────────────────────────────────────────────────────────
|
||||
elif channel_type == "email":
|
||||
smtp_host = config.get("smtp_host")
|
||||
smtp_port = config.get("smtp_port", 465)
|
||||
from_email = config.get("from_email")
|
||||
to_email = config.get("to_email")
|
||||
smtp_user = config.get("smtp_user") or from_email
|
||||
use_tls = config.get("smtp_use_tls", True)
|
||||
use_starttls = config.get("smtp_use_starttls", False)
|
||||
from_name = config.get("from_name", "PEChub Notifiche")
|
||||
smtp_password = secret.get("smtp_password", "")
|
||||
|
||||
if not smtp_host:
|
||||
return ChannelTestResult(success=False, message="Host SMTP non configurato")
|
||||
if not from_email:
|
||||
return ChannelTestResult(success=False, message="Email mittente non configurata")
|
||||
if not to_email:
|
||||
return ChannelTestResult(success=False, message="Email destinatario non configurata")
|
||||
if not smtp_password:
|
||||
return ChannelTestResult(success=False, message="Password SMTP non configurata")
|
||||
|
||||
try:
|
||||
from app.notifications.email_smtp import EmailSMTPError, send_test_email
|
||||
await send_test_email(
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=int(smtp_port),
|
||||
smtp_user=smtp_user,
|
||||
smtp_password=smtp_password,
|
||||
from_email=from_email,
|
||||
to_email=to_email,
|
||||
channel_name=channel.name,
|
||||
from_name=from_name,
|
||||
use_tls=use_tls,
|
||||
use_starttls=use_starttls,
|
||||
)
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Email di test inviata con successo a {to_email}.",
|
||||
http_status=200,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore email: {exc}",
|
||||
)
|
||||
|
||||
# ── WhatsApp ──────────────────────────────────────────────────────────
|
||||
elif channel_type == "whatsapp":
|
||||
phone_number_id = config.get("phone_number_id")
|
||||
to_phone = config.get("to_phone")
|
||||
access_token = secret.get("access_token")
|
||||
|
||||
if not phone_number_id:
|
||||
return ChannelTestResult(success=False, message="phone_number_id non configurato")
|
||||
if not to_phone:
|
||||
return ChannelTestResult(success=False, message="Numero WhatsApp destinatario non configurato")
|
||||
if not access_token:
|
||||
return ChannelTestResult(success=False, message="Access token Meta non configurato")
|
||||
|
||||
try:
|
||||
from app.notifications.whatsapp import WhatsAppError, send_test_whatsapp
|
||||
result = await send_test_whatsapp(
|
||||
phone_number_id=phone_number_id,
|
||||
to_phone=to_phone,
|
||||
access_token=access_token,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Messaggio WhatsApp inviato (message_id={result.get('message_id')}).",
|
||||
http_status=200,
|
||||
)
|
||||
except Exception as exc:
|
||||
http_status = getattr(exc, "http_status", None)
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore WhatsApp: {exc}",
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
# ── Tipo sconosciuto ──────────────────────────────────────────────────
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
|
||||
http_status=200,
|
||||
success=False,
|
||||
message=f"Tipo canale '{channel_type}' non supportato",
|
||||
)
|
||||
|
||||
# ─── Rules ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Service per la gestione della rubrica indirizzi PEC (Feature 6).
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, NotFoundError
|
||||
from app.models.pec_contact import PecContact
|
||||
from app.schemas.pec_contact import (
|
||||
PecContactCreate,
|
||||
PecContactImportResult,
|
||||
PecContactUpdate,
|
||||
)
|
||||
|
||||
|
||||
class PecContactService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def list_contacts(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> tuple[list[PecContact], int]:
|
||||
query = select(PecContact).where(PecContact.tenant_id == tenant_id)
|
||||
if q:
|
||||
pattern = f"%{q.lower()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
PecContact.email.ilike(pattern),
|
||||
PecContact.name.ilike(pattern),
|
||||
PecContact.organization.ilike(pattern),
|
||||
)
|
||||
)
|
||||
query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size)
|
||||
items = list((await self.db.execute(query)).scalars().all())
|
||||
return items, total
|
||||
|
||||
async def get_contact(
|
||||
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
|
||||
) -> PecContact:
|
||||
result = await self.db.execute(
|
||||
select(PecContact).where(
|
||||
PecContact.id == contact_id,
|
||||
PecContact.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
contact = result.scalar_one_or_none()
|
||||
if not contact:
|
||||
raise NotFoundError(f"Contatto {contact_id} non trovato")
|
||||
return contact
|
||||
|
||||
async def create_contact(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: PecContactCreate,
|
||||
created_by: uuid.UUID | None = None,
|
||||
) -> PecContact:
|
||||
email = data.email.lower().strip()
|
||||
# Verifica unicita'
|
||||
existing = await self.db.execute(
|
||||
select(PecContact).where(
|
||||
PecContact.tenant_id == tenant_id,
|
||||
PecContact.email == email,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Contatto '{email}' gia' esistente")
|
||||
|
||||
contact = PecContact(
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=data.name,
|
||||
organization=data.organization,
|
||||
notes=data.notes,
|
||||
is_favorite=data.is_favorite,
|
||||
auto_saved=False,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.db.add(contact)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(contact)
|
||||
return contact
|
||||
|
||||
async def update_contact(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
contact_id: uuid.UUID,
|
||||
data: PecContactUpdate,
|
||||
) -> PecContact:
|
||||
contact = await self.get_contact(tenant_id, contact_id)
|
||||
if data.name is not None:
|
||||
contact.name = data.name
|
||||
if data.organization is not None:
|
||||
contact.organization = data.organization
|
||||
if data.notes is not None:
|
||||
contact.notes = data.notes
|
||||
if data.is_favorite is not None:
|
||||
contact.is_favorite = data.is_favorite
|
||||
await self.db.commit()
|
||||
await self.db.refresh(contact)
|
||||
return contact
|
||||
|
||||
async def delete_contact(
|
||||
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
|
||||
) -> None:
|
||||
contact = await self.get_contact(tenant_id, contact_id)
|
||||
await self.db.delete(contact)
|
||||
await self.db.commit()
|
||||
|
||||
async def auto_save_sender(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
email: str,
|
||||
) -> None:
|
||||
"""
|
||||
Salva automaticamente il mittente nella rubrica se non esiste ancora.
|
||||
Operazione non bloccante: gli errori vengono ignorati silenziosamente.
|
||||
|
||||
Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound.
|
||||
"""
|
||||
if not email:
|
||||
return
|
||||
email = email.lower().strip()
|
||||
try:
|
||||
# Upsert: inserisce solo se non esiste
|
||||
stmt = insert(PecContact).values(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
auto_saved=True,
|
||||
).on_conflict_do_nothing(
|
||||
constraint="uq_pec_contact_email_tenant"
|
||||
)
|
||||
await self.db.execute(stmt)
|
||||
await self.db.commit()
|
||||
except Exception:
|
||||
await self.db.rollback()
|
||||
|
||||
async def import_csv(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
csv_content: str,
|
||||
created_by: uuid.UUID | None = None,
|
||||
) -> PecContactImportResult:
|
||||
"""
|
||||
Importa contatti da un CSV con colonne: email, name, organization.
|
||||
|
||||
Aggiorna i record esistenti con name/organization se forniti.
|
||||
"""
|
||||
result = PecContactImportResult(created=0, updated=0, skipped=0)
|
||||
reader = csv.DictReader(io.StringIO(csv_content))
|
||||
|
||||
for row_num, row in enumerate(reader, start=2):
|
||||
email = row.get("email", "").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
result.skipped += 1
|
||||
result.errors.append(f"Riga {row_num}: email non valida '{email}'")
|
||||
continue
|
||||
|
||||
name = row.get("name", "").strip() or None
|
||||
organization = row.get("organization", "").strip() or None
|
||||
|
||||
try:
|
||||
existing = await self.db.execute(
|
||||
select(PecContact).where(
|
||||
PecContact.tenant_id == tenant_id,
|
||||
PecContact.email == email,
|
||||
)
|
||||
)
|
||||
contact = existing.scalar_one_or_none()
|
||||
|
||||
if contact:
|
||||
# Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali)
|
||||
updated = False
|
||||
if name and not contact.name:
|
||||
contact.name = name
|
||||
updated = True
|
||||
if organization and not contact.organization:
|
||||
contact.organization = organization
|
||||
updated = True
|
||||
if updated:
|
||||
result.updated += 1
|
||||
else:
|
||||
result.skipped += 1
|
||||
else:
|
||||
contact = PecContact(
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
organization=organization,
|
||||
auto_saved=False,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.db.add(contact)
|
||||
result.created += 1
|
||||
except Exception as e:
|
||||
result.errors.append(f"Riga {row_num} ({email}): {e}")
|
||||
result.skipped += 1
|
||||
|
||||
await self.db.commit()
|
||||
return result
|
||||
|
||||
async def search_for_autocomplete(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
q: str,
|
||||
limit: int = 10,
|
||||
) -> list[PecContact]:
|
||||
"""Ricerca veloce per autocomplete nel compose."""
|
||||
if not q or len(q) < 2:
|
||||
return []
|
||||
pattern = f"%{q.lower()}%"
|
||||
result = await self.db.execute(
|
||||
select(PecContact)
|
||||
.where(
|
||||
PecContact.tenant_id == tenant_id,
|
||||
or_(
|
||||
PecContact.email.ilike(pattern),
|
||||
PecContact.name.ilike(pattern),
|
||||
)
|
||||
)
|
||||
.order_by(PecContact.is_favorite.desc(), PecContact.email)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -27,9 +27,13 @@ class PermissionService:
|
||||
async def get_visible_mailboxes(
|
||||
self, user: User
|
||||
) -> list[uuid.UUID]:
|
||||
"""Restituisce gli UUID delle caselle visibili all'utente."""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
# Admin vede tutte le caselle del tenant
|
||||
"""Restituisce gli UUID delle caselle visibili all'utente.
|
||||
|
||||
Admin e supervisor vedono tutte le caselle del tenant.
|
||||
Operator e readonly vedono solo le caselle con can_read=True esplicito.
|
||||
"""
|
||||
if user.role in ("super_admin", "admin", "supervisor"):
|
||||
# Admin e supervisor vedono tutte le caselle del tenant
|
||||
result = await self.db.execute(
|
||||
select(Mailbox.id).where(
|
||||
Mailbox.tenant_id == user.tenant_id,
|
||||
@@ -38,7 +42,7 @@ class PermissionService:
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
# Operatori: solo caselle con can_read=True
|
||||
# Operator e readonly: solo caselle con can_read=True esplicito
|
||||
result = await self.db.execute(
|
||||
select(MailboxPermission.mailbox_id).where(
|
||||
MailboxPermission.user_id == user.id,
|
||||
@@ -50,9 +54,13 @@ class PermissionService:
|
||||
async def check_can_read(
|
||||
self, user: User, mailbox_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Verifica se l'utente può leggere i messaggi della casella."""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
# Verifica solo che la casella appartenga al tenant
|
||||
"""Verifica se l'utente puo' leggere i messaggi della casella.
|
||||
|
||||
Admin e supervisor hanno accesso implicito a tutte le caselle del tenant.
|
||||
Operator e readonly richiedono permesso esplicito can_read.
|
||||
"""
|
||||
if user.role in ("super_admin", "admin", "supervisor"):
|
||||
# Admin e supervisor: verifica solo che la casella appartenga al tenant
|
||||
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
|
||||
|
||||
perm = await self._get_permission(user.id, mailbox_id)
|
||||
@@ -62,12 +70,15 @@ class PermissionService:
|
||||
self, user: User, mailbox_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""
|
||||
Verifica se l'utente può inviare dalla casella.
|
||||
Verifica se l'utente puo' inviare dalla casella.
|
||||
|
||||
L'accesso in invio è concesso se:
|
||||
1. L'utente è admin del tenant, oppure
|
||||
L'accesso in invio e' concesso se:
|
||||
1. L'utente e' admin del tenant, oppure
|
||||
2. L'utente ha un permesso diretto can_send sulla casella, oppure
|
||||
3. L'utente è assegnato a una Virtual Box attiva che include la casella.
|
||||
3. L'utente e' assegnato a una Virtual Box attiva che include la casella.
|
||||
|
||||
Nota: il supervisor NON ha invio implicito – richiede can_send esplicito
|
||||
come operator, ma diversamente da operator vede tutte le caselle.
|
||||
"""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
|
||||
@@ -89,6 +100,22 @@ class PermissionService:
|
||||
perm = await self._get_permission(user.id, mailbox_id)
|
||||
return perm is not None and perm.can_manage
|
||||
|
||||
async def check_can_conserve(
|
||||
self, user: User, mailbox_id: uuid.UUID
|
||||
) -> bool:
|
||||
"""Verifica se l'utente puo' spostare messaggi nella cartella Conservazione.
|
||||
|
||||
Admin/super_admin: accesso implicito sempre.
|
||||
Supervisor: richiede permesso esplicito can_conserve=True.
|
||||
Operator/readonly: non autorizzati (richiedono permesso esplicito).
|
||||
"""
|
||||
if user.role in ("super_admin", "admin"):
|
||||
return await self._mailbox_belongs_to_tenant(mailbox_id, user.tenant_id)
|
||||
|
||||
# Supervisor, operator e readonly richiedono record esplicito
|
||||
perm = await self._get_permission(user.id, mailbox_id)
|
||||
return perm is not None and perm.can_conserve
|
||||
|
||||
async def require_can_read(self, user: User, mailbox_id: uuid.UUID) -> None:
|
||||
"""Solleva 403 se l'utente non può leggere."""
|
||||
if not await self.check_can_read(user, mailbox_id):
|
||||
@@ -98,6 +125,11 @@ class PermissionService:
|
||||
if not await self.check_can_send(user, mailbox_id):
|
||||
raise PermissionDeniedError("casella (invio)")
|
||||
|
||||
async def require_can_conserve(self, user: User, mailbox_id: uuid.UUID) -> None:
|
||||
"""Solleva 403 se l'utente non puo' spostare messaggi in Conservazione."""
|
||||
if not await self.check_can_conserve(user, mailbox_id):
|
||||
raise PermissionDeniedError("casella (conservazione)")
|
||||
|
||||
# ─── CRUD permessi ────────────────────────────────────────────────────────
|
||||
|
||||
async def grant_permission(
|
||||
@@ -134,6 +166,7 @@ class PermissionService:
|
||||
existing.can_read = data.can_read
|
||||
existing.can_send = data.can_send
|
||||
existing.can_manage = data.can_manage
|
||||
existing.can_conserve = data.can_conserve
|
||||
existing.granted_by = granted_by.id
|
||||
return existing
|
||||
|
||||
@@ -144,6 +177,7 @@ class PermissionService:
|
||||
can_read=data.can_read,
|
||||
can_send=data.can_send,
|
||||
can_manage=data.can_manage,
|
||||
can_conserve=data.can_conserve,
|
||||
granted_by=granted_by.id,
|
||||
)
|
||||
self.db.add(perm)
|
||||
@@ -190,6 +224,7 @@ class PermissionService:
|
||||
"can_read": perm.can_read,
|
||||
"can_send": perm.can_send,
|
||||
"can_manage": perm.can_manage,
|
||||
"can_conserve": perm.can_conserve,
|
||||
"granted_at": perm.granted_at,
|
||||
}
|
||||
for perm, user in rows
|
||||
@@ -217,6 +252,7 @@ class PermissionService:
|
||||
"can_read": perm.can_read,
|
||||
"can_send": perm.can_send,
|
||||
"can_manage": perm.can_manage,
|
||||
"can_conserve": perm.can_conserve,
|
||||
}
|
||||
for perm, mailbox in rows
|
||||
]
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
"""
|
||||
ReportService – calcola KPI, serie storiche e produce export CSV/PDF.
|
||||
|
||||
Non richiede migrazioni: lavora sulle tabelle messages e mailboxes esistenti.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from sqlalchemy import case, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.mailbox import Mailbox
|
||||
from app.models.message import Message
|
||||
from app.schemas.reports import (
|
||||
DailyStat,
|
||||
KpiSummary,
|
||||
MailboxStat,
|
||||
OutboundStateStat,
|
||||
ReportSummaryResponse,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ReportService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ─── KPI principali ──────────────────────────────────────────────────────
|
||||
|
||||
async def get_summary(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
period_days: int = 7,
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]] = None,
|
||||
) -> ReportSummaryResponse:
|
||||
"""
|
||||
Restituisce il riepilogo completo per la dashboard.
|
||||
|
||||
visible_mailbox_ids: se None l'utente e admin e vede tutto il tenant,
|
||||
altrimenti filtra sulle caselle accessibili.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
d7_start = now - timedelta(days=7)
|
||||
d30_start = now - timedelta(days=30)
|
||||
|
||||
# ── Filtro base tenant + caselle visibili ────────────────────────────
|
||||
def _base(q):
|
||||
q = q.where(Message.tenant_id == tenant_id)
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
# Nessuna casella visibile: ritorna subito valori zero
|
||||
return None
|
||||
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||
return q
|
||||
|
||||
async def _count(q) -> int:
|
||||
q = _base(q)
|
||||
if q is None:
|
||||
return 0
|
||||
r = await self.db.execute(q)
|
||||
return r.scalar_one() or 0
|
||||
|
||||
# PEC ricevute
|
||||
received_today = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "inbound",
|
||||
Message.received_at >= today_start,
|
||||
)
|
||||
)
|
||||
received_7d = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "inbound",
|
||||
Message.received_at >= d7_start,
|
||||
)
|
||||
)
|
||||
received_30d = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "inbound",
|
||||
Message.received_at >= d30_start,
|
||||
)
|
||||
)
|
||||
|
||||
# PEC inviate
|
||||
sent_today = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.sent_at >= today_start,
|
||||
)
|
||||
)
|
||||
sent_7d = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.sent_at >= d7_start,
|
||||
)
|
||||
)
|
||||
sent_30d = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.sent_at >= d30_start,
|
||||
)
|
||||
)
|
||||
|
||||
# Anomalie (outbound con state=anomaly, senza genitore)
|
||||
anomalie = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.state == "anomaly",
|
||||
)
|
||||
)
|
||||
|
||||
# Tasso consegna: delivered / (delivered + anomaly + failed)
|
||||
delivered = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.state == "delivered",
|
||||
)
|
||||
)
|
||||
failed = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.state.in_(["anomaly", "failed"]),
|
||||
)
|
||||
)
|
||||
total_terminal = delivered + failed
|
||||
tasso_consegna = round((delivered / total_terminal * 100), 1) if total_terminal > 0 else 0.0
|
||||
|
||||
# Non letti
|
||||
non_letti = await _count(
|
||||
select(func.count(Message.id)).where(
|
||||
Message.direction == "inbound",
|
||||
Message.is_read == False, # noqa: E712
|
||||
Message.is_trashed == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
|
||||
# Totale messaggi
|
||||
totale = await _count(select(func.count(Message.id)))
|
||||
|
||||
# Caselle in errore (NON filtrato per visible_mailbox_ids – e una info admin)
|
||||
caselle_errore_r = await self.db.execute(
|
||||
select(func.count(Mailbox.id)).where(
|
||||
Mailbox.tenant_id == tenant_id,
|
||||
Mailbox.status == "error",
|
||||
)
|
||||
)
|
||||
caselle_errore = caselle_errore_r.scalar_one() or 0
|
||||
|
||||
kpi = KpiSummary(
|
||||
received_today=received_today,
|
||||
sent_today=sent_today,
|
||||
received_7d=received_7d,
|
||||
sent_7d=sent_7d,
|
||||
received_30d=received_30d,
|
||||
sent_30d=sent_30d,
|
||||
anomalie_attive=anomalie,
|
||||
tasso_consegna=tasso_consegna,
|
||||
caselle_in_errore=caselle_errore,
|
||||
messaggi_non_letti=non_letti,
|
||||
totale_messaggi=totale,
|
||||
)
|
||||
|
||||
# ── Serie storica giornaliera ─────────────────────────────────────────
|
||||
daily_stats = await self._get_daily_stats(tenant_id, period_days, visible_mailbox_ids)
|
||||
|
||||
# ── Distribuzione stati outbound ──────────────────────────────────────
|
||||
outbound_states = await self._get_outbound_states(tenant_id, visible_mailbox_ids)
|
||||
|
||||
# ── Statistiche per casella ───────────────────────────────────────────
|
||||
mailbox_stats = await self._get_mailbox_stats(tenant_id, visible_mailbox_ids)
|
||||
|
||||
return ReportSummaryResponse(
|
||||
generated_at=now,
|
||||
period_days=period_days,
|
||||
kpi=kpi,
|
||||
daily_stats=daily_stats,
|
||||
outbound_states=outbound_states,
|
||||
mailbox_stats=mailbox_stats,
|
||||
)
|
||||
|
||||
# ─── Serie storica ────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_daily_stats(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
days: int,
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]],
|
||||
) -> list[DailyStat]:
|
||||
"""Conta PEC ricevute e inviate per ciascuno degli ultimi `days` giorni."""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
def _apply_filters(q):
|
||||
q = q.where(Message.tenant_id == tenant_id)
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
return None
|
||||
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||
return q
|
||||
|
||||
# Aggregazione ricevute per giorno
|
||||
q_recv = (
|
||||
select(
|
||||
func.date_trunc("day", Message.received_at).label("day"),
|
||||
func.count(Message.id).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
Message.direction == "inbound",
|
||||
Message.received_at >= since,
|
||||
)
|
||||
.group_by(text("day"))
|
||||
.order_by(text("day"))
|
||||
)
|
||||
q_recv = _apply_filters(q_recv)
|
||||
|
||||
# Aggregazione inviate per giorno
|
||||
q_sent = (
|
||||
select(
|
||||
func.date_trunc("day", Message.sent_at).label("day"),
|
||||
func.count(Message.id).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
Message.sent_at >= since,
|
||||
)
|
||||
.group_by(text("day"))
|
||||
.order_by(text("day"))
|
||||
)
|
||||
q_sent = _apply_filters(q_sent)
|
||||
|
||||
recv_map: dict[date, int] = {}
|
||||
sent_map: dict[date, int] = {}
|
||||
|
||||
if q_recv is not None:
|
||||
r = await self.db.execute(q_recv)
|
||||
for row in r.all():
|
||||
if row.day:
|
||||
d = row.day.date() if hasattr(row.day, "date") else row.day
|
||||
recv_map[d] = row.cnt
|
||||
|
||||
if q_sent is not None:
|
||||
r = await self.db.execute(q_sent)
|
||||
for row in r.all():
|
||||
if row.day:
|
||||
d = row.day.date() if hasattr(row.day, "date") else row.day
|
||||
sent_map[d] = row.cnt
|
||||
|
||||
# Costruisce la serie completa (tutti i giorni, anche quelli a zero)
|
||||
result: list[DailyStat] = []
|
||||
for i in range(days, -1, -1):
|
||||
d = (datetime.now(timezone.utc) - timedelta(days=i)).date()
|
||||
result.append(DailyStat(
|
||||
day=d,
|
||||
received=recv_map.get(d, 0),
|
||||
sent=sent_map.get(d, 0),
|
||||
))
|
||||
return result
|
||||
|
||||
# ─── Distribuzione stati outbound ────────────────────────────────────────
|
||||
|
||||
async def _get_outbound_states(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]],
|
||||
) -> list[OutboundStateStat]:
|
||||
q = (
|
||||
select(Message.state, func.count(Message.id).label("cnt"))
|
||||
.where(
|
||||
Message.tenant_id == tenant_id,
|
||||
Message.direction == "outbound",
|
||||
Message.parent_message_id.is_(None),
|
||||
)
|
||||
.group_by(Message.state)
|
||||
)
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
return []
|
||||
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||
|
||||
r = await self.db.execute(q)
|
||||
return [OutboundStateStat(state=row.state, count=row.cnt) for row in r.all()]
|
||||
|
||||
# ─── Statistiche per casella ──────────────────────────────────────────────
|
||||
|
||||
async def _get_mailbox_stats(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]],
|
||||
) -> list[MailboxStat]:
|
||||
# Carica le caselle
|
||||
mb_q = select(Mailbox).where(Mailbox.tenant_id == tenant_id)
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
return []
|
||||
mb_q = mb_q.where(Mailbox.id.in_(visible_mailbox_ids))
|
||||
mb_result = await self.db.execute(mb_q)
|
||||
mailboxes = mb_result.scalars().all()
|
||||
|
||||
if not mailboxes:
|
||||
return []
|
||||
|
||||
mailbox_ids = [m.id for m in mailboxes]
|
||||
mailbox_map = {m.id: m for m in mailboxes}
|
||||
|
||||
# Aggregazione messaggi per casella e direction
|
||||
agg_q = (
|
||||
select(
|
||||
Message.mailbox_id,
|
||||
Message.direction,
|
||||
Message.state,
|
||||
Message.is_read,
|
||||
func.count(Message.id).label("cnt"),
|
||||
)
|
||||
.where(
|
||||
Message.tenant_id == tenant_id,
|
||||
Message.mailbox_id.in_(mailbox_ids),
|
||||
Message.parent_message_id.is_(None),
|
||||
)
|
||||
.group_by(Message.mailbox_id, Message.direction, Message.state, Message.is_read)
|
||||
)
|
||||
agg_result = await self.db.execute(agg_q)
|
||||
|
||||
# Accumula per casella
|
||||
stats: dict[uuid.UUID, MailboxStat] = {}
|
||||
for mb in mailboxes:
|
||||
stats[mb.id] = MailboxStat(
|
||||
mailbox_id=mb.id,
|
||||
email_address=mb.email_address,
|
||||
display_name=mb.display_name,
|
||||
status=mb.status,
|
||||
last_sync_at=mb.last_sync_at,
|
||||
)
|
||||
|
||||
for row in agg_result.all():
|
||||
s = stats.get(row.mailbox_id)
|
||||
if not s:
|
||||
continue
|
||||
if row.direction == "inbound":
|
||||
s.received_total += row.cnt
|
||||
if not row.is_read:
|
||||
s.non_letti += row.cnt
|
||||
elif row.direction == "outbound":
|
||||
s.sent_total += row.cnt
|
||||
if row.state == "anomaly":
|
||||
s.anomalie += row.cnt
|
||||
|
||||
# Ordina per volume decrescente
|
||||
return sorted(
|
||||
stats.values(),
|
||||
key=lambda x: x.received_total + x.sent_total,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# ─── Export CSV ───────────────────────────────────────────────────────────
|
||||
|
||||
async def export_csv(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
date_from: Optional[datetime],
|
||||
date_to: Optional[datetime],
|
||||
mailbox_id: Optional[uuid.UUID],
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]],
|
||||
) -> bytes:
|
||||
"""Genera un CSV con tutti i messaggi del periodo."""
|
||||
q = (
|
||||
select(
|
||||
Message.id,
|
||||
Message.direction,
|
||||
Message.state,
|
||||
Message.pec_type,
|
||||
Message.subject,
|
||||
Message.from_address,
|
||||
Message.received_at,
|
||||
Message.sent_at,
|
||||
Message.size_bytes,
|
||||
Message.is_read,
|
||||
Message.has_attachments,
|
||||
Mailbox.email_address.label("mailbox_email"),
|
||||
)
|
||||
.join(Mailbox, Message.mailbox_id == Mailbox.id)
|
||||
.where(
|
||||
Message.tenant_id == tenant_id,
|
||||
Message.parent_message_id.is_(None),
|
||||
)
|
||||
.order_by(Message.received_at.desc().nullslast(), Message.created_at.desc())
|
||||
)
|
||||
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(CSV_HEADERS)
|
||||
return buf.getvalue().encode("utf-8-sig")
|
||||
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||
|
||||
if mailbox_id:
|
||||
q = q.where(Message.mailbox_id == mailbox_id)
|
||||
|
||||
if date_from:
|
||||
q = q.where(
|
||||
(Message.received_at >= date_from) | (Message.sent_at >= date_from)
|
||||
)
|
||||
if date_to:
|
||||
q = q.where(
|
||||
(Message.received_at <= date_to) | (Message.sent_at <= date_to)
|
||||
)
|
||||
|
||||
result = await self.db.execute(q)
|
||||
rows = result.all()
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(CSV_HEADERS)
|
||||
|
||||
for r in rows:
|
||||
ts = r.received_at or r.sent_at
|
||||
writer.writerow([
|
||||
str(r.id),
|
||||
r.mailbox_email or "",
|
||||
r.direction or "",
|
||||
r.state or "",
|
||||
r.pec_type or "",
|
||||
r.subject or "",
|
||||
r.from_address or "",
|
||||
ts.strftime("%Y-%m-%d %H:%M:%S") if ts else "",
|
||||
r.size_bytes or "",
|
||||
"Si" if r.is_read else "No",
|
||||
"Si" if r.has_attachments else "No",
|
||||
])
|
||||
|
||||
return buf.getvalue().encode("utf-8-sig")
|
||||
|
||||
# ─── Export PDF ───────────────────────────────────────────────────────────
|
||||
|
||||
async def export_pdf(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
date_from: Optional[datetime],
|
||||
date_to: Optional[datetime],
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]],
|
||||
) -> bytes:
|
||||
"""
|
||||
Genera un PDF di riepilogo con KPI e tabella caselle.
|
||||
Usa reportlab (puro Python, nessuna dipendenza di sistema).
|
||||
"""
|
||||
try:
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.platypus import (
|
||||
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
|
||||
)
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"reportlab non installato. Aggiungere 'reportlab>=4.2.0' "
|
||||
"alle dipendenze del backend."
|
||||
)
|
||||
|
||||
summary = await self.get_summary(tenant_id, 30, visible_mailbox_ids)
|
||||
now_str = summary.generated_at.strftime("%d/%m/%Y %H:%M")
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=A4,
|
||||
leftMargin=2 * cm,
|
||||
rightMargin=2 * cm,
|
||||
topMargin=2 * cm,
|
||||
bottomMargin=2 * cm,
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
"Title", parent=styles["Title"], fontSize=18, spaceAfter=6,
|
||||
)
|
||||
subtitle_style = ParagraphStyle(
|
||||
"Subtitle", parent=styles["Normal"], fontSize=10, textColor=colors.grey, spaceAfter=20,
|
||||
)
|
||||
heading_style = ParagraphStyle(
|
||||
"Heading2", parent=styles["Heading2"], fontSize=13, spaceBefore=14, spaceAfter=6,
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
# Intestazione
|
||||
story.append(Paragraph("PEChub – Report Attivita PEC", title_style))
|
||||
date_range = ""
|
||||
if date_from:
|
||||
date_range += f"Dal {date_from.strftime('%d/%m/%Y')} "
|
||||
if date_to:
|
||||
date_range += f"Al {date_to.strftime('%d/%m/%Y')} "
|
||||
story.append(Paragraph(
|
||||
f"Generato il {now_str} {date_range}",
|
||||
subtitle_style,
|
||||
))
|
||||
|
||||
# Sezione KPI
|
||||
story.append(Paragraph("Indicatori Chiave (ultimi 30 giorni)", heading_style))
|
||||
kpi = summary.kpi
|
||||
kpi_data = [
|
||||
["Indicatore", "Valore"],
|
||||
["PEC ricevute oggi", str(kpi.received_today)],
|
||||
["PEC inviate oggi", str(kpi.sent_today)],
|
||||
["PEC ricevute (7 gg)", str(kpi.received_7d)],
|
||||
["PEC inviate (7 gg)", str(kpi.sent_7d)],
|
||||
["PEC ricevute (30 gg)", str(kpi.received_30d)],
|
||||
["PEC inviate (30 gg)", str(kpi.sent_30d)],
|
||||
["Anomalie attive", str(kpi.anomalie_attive)],
|
||||
["Tasso di consegna", f"{kpi.tasso_consegna}%"],
|
||||
["Caselle in errore", str(kpi.caselle_in_errore)],
|
||||
["Messaggi non letti", str(kpi.messaggi_non_letti)],
|
||||
]
|
||||
kpi_table = Table(kpi_data, colWidths=[10 * cm, 5 * cm])
|
||||
kpi_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("ALIGN", (1, 0), (1, -1), "RIGHT"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]))
|
||||
story.append(kpi_table)
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
# Sezione caselle
|
||||
if summary.mailbox_stats:
|
||||
story.append(Paragraph("Dettaglio per Casella", heading_style))
|
||||
mb_header = ["Casella", "Stato", "Ricevute", "Inviate", "Anomalie", "Non letti"]
|
||||
mb_data = [mb_header]
|
||||
for ms in summary.mailbox_stats:
|
||||
mb_data.append([
|
||||
ms.email_address,
|
||||
ms.status,
|
||||
str(ms.received_total),
|
||||
str(ms.sent_total),
|
||||
str(ms.anomalie),
|
||||
str(ms.non_letti),
|
||||
])
|
||||
mb_table = Table(
|
||||
mb_data,
|
||||
colWidths=[6.5 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm, 2.2 * cm],
|
||||
)
|
||||
mb_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e40af")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f0f4ff")]),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]))
|
||||
story.append(mb_table)
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ─── Costanti ────────────────────────────────────────────────────────────────
|
||||
|
||||
CSV_HEADERS = [
|
||||
"ID",
|
||||
"Casella",
|
||||
"Direzione",
|
||||
"Stato",
|
||||
"Tipo PEC",
|
||||
"Oggetto",
|
||||
"Mittente",
|
||||
"Data/Ora",
|
||||
"Dimensione (byte)",
|
||||
"Letto",
|
||||
"Allegati",
|
||||
]
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Service per la gestione delle regole di smistamento automatico (Feature 2).
|
||||
|
||||
Il metodo evaluate_rules() viene chiamato dal worker dopo ogni messaggio inbound.
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models.label import Label, MessageLabel
|
||||
from app.models.message import Message
|
||||
from app.models.routing_rule import RoutingRule, RoutingRuleAction, RoutingRuleCondition
|
||||
from app.schemas.routing_rule import RoutingRuleCreate, RoutingRuleUpdate
|
||||
|
||||
|
||||
class RoutingRuleService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ─── CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_rules(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> tuple[list[RoutingRule], int]:
|
||||
query = (
|
||||
select(RoutingRule)
|
||||
.where(RoutingRule.tenant_id == tenant_id)
|
||||
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
|
||||
.order_by(RoutingRule.priority)
|
||||
)
|
||||
count_q = select(func.count()).select_from(
|
||||
select(RoutingRule).where(RoutingRule.tenant_id == tenant_id).subquery()
|
||||
)
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
items = list((await self.db.execute(query)).scalars().all())
|
||||
return items, total
|
||||
|
||||
async def get_rule(
|
||||
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
|
||||
) -> RoutingRule:
|
||||
result = await self.db.execute(
|
||||
select(RoutingRule)
|
||||
.where(RoutingRule.id == rule_id, RoutingRule.tenant_id == tenant_id)
|
||||
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise NotFoundError(f"Regola {rule_id} non trovata")
|
||||
return rule
|
||||
|
||||
async def create_rule(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: RoutingRuleCreate,
|
||||
created_by: uuid.UUID | None = None,
|
||||
) -> RoutingRule:
|
||||
rule = RoutingRule(
|
||||
tenant_id=tenant_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
is_active=data.is_active,
|
||||
priority=data.priority,
|
||||
stop_processing=data.stop_processing,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.db.add(rule)
|
||||
await self.db.flush()
|
||||
|
||||
for cond in data.conditions:
|
||||
self.db.add(RoutingRuleCondition(
|
||||
rule_id=rule.id,
|
||||
field=cond.field,
|
||||
operator=cond.operator,
|
||||
value=cond.value,
|
||||
))
|
||||
for action in data.actions:
|
||||
self.db.add(RoutingRuleAction(
|
||||
rule_id=rule.id,
|
||||
action_type=action.action_type,
|
||||
action_value=action.action_value,
|
||||
))
|
||||
|
||||
await self.db.commit()
|
||||
return await self.get_rule(tenant_id, rule.id)
|
||||
|
||||
async def update_rule(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
data: RoutingRuleUpdate,
|
||||
) -> RoutingRule:
|
||||
rule = await self.get_rule(tenant_id, rule_id)
|
||||
|
||||
if data.name is not None:
|
||||
rule.name = data.name
|
||||
if data.description is not None:
|
||||
rule.description = data.description
|
||||
if data.is_active is not None:
|
||||
rule.is_active = data.is_active
|
||||
if data.priority is not None:
|
||||
rule.priority = data.priority
|
||||
if data.stop_processing is not None:
|
||||
rule.stop_processing = data.stop_processing
|
||||
|
||||
# Se condizioni o azioni vengono aggiornate, le sostituisce completamente
|
||||
if data.conditions is not None:
|
||||
await self.db.execute(
|
||||
delete(RoutingRuleCondition).where(RoutingRuleCondition.rule_id == rule_id)
|
||||
)
|
||||
for cond in data.conditions:
|
||||
self.db.add(RoutingRuleCondition(
|
||||
rule_id=rule_id,
|
||||
field=cond.field,
|
||||
operator=cond.operator,
|
||||
value=cond.value,
|
||||
))
|
||||
|
||||
if data.actions is not None:
|
||||
await self.db.execute(
|
||||
delete(RoutingRuleAction).where(RoutingRuleAction.rule_id == rule_id)
|
||||
)
|
||||
for action in data.actions:
|
||||
self.db.add(RoutingRuleAction(
|
||||
rule_id=rule_id,
|
||||
action_type=action.action_type,
|
||||
action_value=action.action_value,
|
||||
))
|
||||
|
||||
await self.db.commit()
|
||||
return await self.get_rule(tenant_id, rule_id)
|
||||
|
||||
async def delete_rule(
|
||||
self, tenant_id: uuid.UUID, rule_id: uuid.UUID
|
||||
) -> None:
|
||||
rule = await self.get_rule(tenant_id, rule_id)
|
||||
await self.db.delete(rule)
|
||||
await self.db.commit()
|
||||
|
||||
# ─── Motore di valutazione ────────────────────────────────────────────────
|
||||
|
||||
async def evaluate_and_apply(
|
||||
self,
|
||||
message: Message,
|
||||
) -> int:
|
||||
"""
|
||||
Valuta le regole attive del tenant e applica le azioni su message.
|
||||
|
||||
Returns:
|
||||
Numero di regole che hanno prodotto match.
|
||||
"""
|
||||
# Carica regole attive ordinate per priority
|
||||
result = await self.db.execute(
|
||||
select(RoutingRule)
|
||||
.where(
|
||||
RoutingRule.tenant_id == message.tenant_id,
|
||||
RoutingRule.is_active == True, # noqa: E712
|
||||
)
|
||||
.options(selectinload(RoutingRule.conditions), selectinload(RoutingRule.actions))
|
||||
.order_by(RoutingRule.priority)
|
||||
)
|
||||
rules = list(result.scalars().all())
|
||||
|
||||
matched_count = 0
|
||||
for rule in rules:
|
||||
if await self._matches(message, rule.conditions):
|
||||
matched_count += 1
|
||||
await self._apply_actions(message, rule.actions)
|
||||
if rule.stop_processing:
|
||||
break
|
||||
|
||||
if matched_count > 0:
|
||||
await self.db.flush()
|
||||
|
||||
return matched_count
|
||||
|
||||
async def _matches(
|
||||
self,
|
||||
message: Message,
|
||||
conditions: list[RoutingRuleCondition],
|
||||
) -> bool:
|
||||
"""Restituisce True se tutte le condizioni (AND) sono soddisfatte."""
|
||||
if not conditions:
|
||||
# Una regola senza condizioni non fa mai match (comportamento sicuro)
|
||||
return False
|
||||
|
||||
for cond in conditions:
|
||||
field_value = self._get_field_value(message, cond.field)
|
||||
if not self._evaluate_condition(field_value, cond.operator, cond.value):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_field_value(self, message: Message, field: str) -> str:
|
||||
"""Estrae il valore del campo dal messaggio come stringa per il confronto."""
|
||||
if field == "from_address":
|
||||
return (message.from_address or "").lower()
|
||||
elif field == "to_address":
|
||||
return " ".join(message.to_addresses or []).lower()
|
||||
elif field == "subject":
|
||||
return (message.subject or "").lower()
|
||||
elif field == "mailbox_id":
|
||||
return str(message.mailbox_id)
|
||||
elif field == "pec_type":
|
||||
return message.pec_type or ""
|
||||
return ""
|
||||
|
||||
def _evaluate_condition(
|
||||
self, field_value: str, operator: str, value: str
|
||||
) -> bool:
|
||||
v = value.lower()
|
||||
fv = field_value.lower()
|
||||
if operator == "contains":
|
||||
return v in fv
|
||||
elif operator == "not_contains":
|
||||
return v not in fv
|
||||
elif operator == "equals":
|
||||
return fv == v
|
||||
elif operator == "starts_with":
|
||||
return fv.startswith(v)
|
||||
elif operator == "ends_with":
|
||||
return fv.endswith(v)
|
||||
elif operator == "regex":
|
||||
try:
|
||||
return bool(re.search(value, field_value, re.IGNORECASE))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
async def _apply_actions(
|
||||
self,
|
||||
message: Message,
|
||||
actions: list[RoutingRuleAction],
|
||||
) -> None:
|
||||
"""Esegue le azioni sulla regola che ha fatto match."""
|
||||
for action in actions:
|
||||
try:
|
||||
if action.action_type == "apply_label" and action.action_value:
|
||||
await self._action_apply_label(message, uuid.UUID(action.action_value))
|
||||
elif action.action_type == "mark_read":
|
||||
message.is_read = True
|
||||
elif action.action_type == "mark_starred":
|
||||
message.is_starred = True
|
||||
elif action.action_type == "notify_webhook" and action.action_value:
|
||||
await self._action_notify_webhook(message, action.action_value)
|
||||
except Exception:
|
||||
# Le azioni non devono interrompere il flusso principale
|
||||
pass
|
||||
|
||||
async def _action_apply_label(
|
||||
self, message: Message, label_id: uuid.UUID
|
||||
) -> None:
|
||||
"""Applica un'etichetta al messaggio (se non gia' presente)."""
|
||||
# Verifica che la label appartenga al tenant
|
||||
label = await self.db.execute(
|
||||
select(Label).where(
|
||||
Label.id == label_id,
|
||||
Label.tenant_id == message.tenant_id,
|
||||
)
|
||||
)
|
||||
if not label.scalar_one_or_none():
|
||||
return
|
||||
# Verifica che non sia gia' applicata
|
||||
existing = await self.db.execute(
|
||||
select(MessageLabel).where(
|
||||
MessageLabel.message_id == message.id,
|
||||
MessageLabel.label_id == label_id,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
self.db.add(MessageLabel(message_id=message.id, label_id=label_id))
|
||||
|
||||
async def _action_notify_webhook(self, message: Message, url: str) -> None:
|
||||
"""Invia una notifica webhook per il messaggio."""
|
||||
import aiohttp
|
||||
import json
|
||||
payload = {
|
||||
"event": "routing_rule_match",
|
||||
"message_id": str(message.id),
|
||||
"subject": message.subject,
|
||||
"from_address": message.from_address,
|
||||
"pec_type": message.pec_type,
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Servizio di ricerca full-text per i messaggi PEC.
|
||||
|
||||
Utilizza i vettori tsvector di PostgreSQL per ricerche veloci su:
|
||||
- oggetto (peso A)
|
||||
- mittente / destinatari (peso B)
|
||||
- corpo del messaggio (peso C)
|
||||
- testo estratto dagli allegati PDF/DOCX (peso D)
|
||||
|
||||
Se search_vector e' NULL (messaggio non ancora indicizzato dal worker),
|
||||
cade back automaticamente a ILIKE sulle colonne base.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.message import Message
|
||||
|
||||
|
||||
class SearchService:
|
||||
"""Incapsula la logica di ricerca full-text sui messaggi."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def search_messages(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
search_term: str,
|
||||
visible_mailbox_ids: Optional[list[uuid.UUID]],
|
||||
mailbox_id: Optional[uuid.UUID] = None,
|
||||
direction: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
pec_type: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
is_archived: Optional[bool] = False,
|
||||
is_trashed: Optional[bool] = False,
|
||||
is_starred: Optional[bool] = None,
|
||||
is_read: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> tuple[list[Message], int]:
|
||||
"""
|
||||
Ricerca full-text nei messaggi.
|
||||
|
||||
Logica:
|
||||
1. Messaggi con search_vector non NULL → usa @@ operator + ts_rank
|
||||
2. Messaggi con search_vector NULL → fallback ILIKE (non ancora indicizzati)
|
||||
3. Applica tutti i filtri aggiuntivi (data, stato, tipo, direzione, ecc.)
|
||||
4. Ordina per rilevanza FTS desc, poi per data desc
|
||||
"""
|
||||
q = select(Message).where(
|
||||
Message.tenant_id == tenant_id,
|
||||
Message.parent_message_id.is_(None),
|
||||
)
|
||||
|
||||
# Restrizione caselle visibili (permessi)
|
||||
if visible_mailbox_ids is not None:
|
||||
if not visible_mailbox_ids:
|
||||
return [], 0
|
||||
q = q.where(Message.mailbox_id.in_(visible_mailbox_ids))
|
||||
|
||||
# Filtri opzionali
|
||||
if mailbox_id:
|
||||
q = q.where(Message.mailbox_id == mailbox_id)
|
||||
if direction:
|
||||
q = q.where(Message.direction == direction)
|
||||
if state:
|
||||
q = q.where(Message.state == state)
|
||||
if pec_type:
|
||||
q = q.where(Message.pec_type == pec_type)
|
||||
if is_archived is not None:
|
||||
q = q.where(Message.is_archived == is_archived)
|
||||
if is_trashed is not None:
|
||||
q = q.where(Message.is_trashed == is_trashed)
|
||||
if is_starred is not None:
|
||||
q = q.where(Message.is_starred == is_starred)
|
||||
if is_read is not None:
|
||||
q = q.where(Message.is_read == is_read)
|
||||
|
||||
# Filtri data: cerca sia su received_at che su sent_at
|
||||
if date_from:
|
||||
q = q.where(
|
||||
or_(
|
||||
Message.received_at >= date_from,
|
||||
Message.sent_at >= date_from,
|
||||
)
|
||||
)
|
||||
if date_to:
|
||||
q = q.where(
|
||||
or_(
|
||||
Message.received_at <= date_to,
|
||||
Message.sent_at <= date_to,
|
||||
)
|
||||
)
|
||||
|
||||
# Full-text search con fallback ILIKE
|
||||
tsquery = func.websearch_to_tsquery("italian", search_term)
|
||||
term_like = f"%{search_term}%"
|
||||
|
||||
fts_condition = Message.search_vector.op("@@")(tsquery)
|
||||
ilike_fallback = Message.search_vector.is_(None) & or_(
|
||||
Message.subject.ilike(term_like),
|
||||
Message.from_address.ilike(term_like),
|
||||
Message.body_text.ilike(term_like),
|
||||
)
|
||||
|
||||
q = q.where(or_(fts_condition, ilike_fallback))
|
||||
|
||||
# Conteggio totale (senza paginazione)
|
||||
count_q = select(func.count()).select_from(q.subquery())
|
||||
total: int = (await self.db.execute(count_q)).scalar_one()
|
||||
|
||||
# Ordinamento per rilevanza FTS, poi data
|
||||
rank_expr = case(
|
||||
(Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery)),
|
||||
else_=0.0,
|
||||
)
|
||||
|
||||
q = (
|
||||
q.options(selectinload(Message.labels))
|
||||
.order_by(
|
||||
rank_expr.desc(),
|
||||
Message.received_at.desc().nullslast(),
|
||||
Message.created_at.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
|
||||
result = await self.db.execute(q)
|
||||
items = list(result.scalars().all())
|
||||
return items, total
|
||||
@@ -166,12 +166,16 @@ class SendService:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
has_files = bool(attachments)
|
||||
|
||||
# Invio differito: il messaggio parte in stato 'draft' se programmato
|
||||
scheduled_at = getattr(data, "scheduled_at", None)
|
||||
is_scheduled = scheduled_at is not None and scheduled_at > now
|
||||
|
||||
message = Message(
|
||||
tenant_id=current_user.tenant_id,
|
||||
mailbox_id=data.mailbox_id,
|
||||
direction="outbound",
|
||||
pec_type="posta_certificata",
|
||||
state="queued",
|
||||
state="draft" if is_scheduled else "queued",
|
||||
subject=data.subject,
|
||||
from_address=mailbox.email_address,
|
||||
to_addresses=[str(a) for a in data.to_addresses],
|
||||
@@ -211,6 +215,7 @@ class SendService:
|
||||
max_attempts=5,
|
||||
created_by=current_user.id,
|
||||
queued_at=now,
|
||||
scheduled_at=scheduled_at if is_scheduled else None,
|
||||
)
|
||||
self.db.add(job)
|
||||
await self.db.flush()
|
||||
@@ -218,7 +223,15 @@ class SendService:
|
||||
# ── Enqueue job arq ───────────────────────────────────────────────────
|
||||
try:
|
||||
arq_pool = await _get_arq_pool()
|
||||
await arq_pool.enqueue_job("send_pec", str(job.id))
|
||||
if is_scheduled and scheduled_at:
|
||||
# Invio differito: defer_until = scheduled_at
|
||||
await arq_pool.enqueue_job(
|
||||
"send_pec",
|
||||
str(job.id),
|
||||
_defer_until=scheduled_at,
|
||||
)
|
||||
else:
|
||||
await arq_pool.enqueue_job("send_pec", str(job.id))
|
||||
except Exception as e:
|
||||
from app.core.logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Service per la gestione dei template messaggi (Feature 1).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import ConflictError, NotFoundError
|
||||
from app.models.template import MessageTemplate
|
||||
from app.schemas.template import TemplateCreate, TemplateUpdate
|
||||
|
||||
|
||||
class TemplateService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def list_templates(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
q: str | None = None,
|
||||
) -> tuple[list[MessageTemplate], int]:
|
||||
query = select(MessageTemplate).where(MessageTemplate.tenant_id == tenant_id)
|
||||
if q:
|
||||
query = query.where(MessageTemplate.name.ilike(f"%{q}%"))
|
||||
query = query.order_by(MessageTemplate.name)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await self.db.execute(count_q)).scalar_one()
|
||||
items = list((await self.db.execute(query)).scalars().all())
|
||||
return items, total
|
||||
|
||||
async def get_template(
|
||||
self, tenant_id: uuid.UUID, template_id: uuid.UUID
|
||||
) -> MessageTemplate:
|
||||
result = await self.db.execute(
|
||||
select(MessageTemplate).where(
|
||||
MessageTemplate.id == template_id,
|
||||
MessageTemplate.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
template = result.scalar_one_or_none()
|
||||
if not template:
|
||||
raise NotFoundError(f"Template {template_id} non trovato")
|
||||
return template
|
||||
|
||||
async def create_template(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: TemplateCreate,
|
||||
created_by: uuid.UUID | None = None,
|
||||
) -> MessageTemplate:
|
||||
# Verifica unicita' nome
|
||||
existing = await self.db.execute(
|
||||
select(MessageTemplate).where(
|
||||
MessageTemplate.tenant_id == tenant_id,
|
||||
MessageTemplate.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Template '{data.name}' gia' esistente")
|
||||
|
||||
template = MessageTemplate(
|
||||
tenant_id=tenant_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
subject=data.subject,
|
||||
body_text=data.body_text,
|
||||
body_html=data.body_html,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.db.add(template)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(template)
|
||||
return template
|
||||
|
||||
async def update_template(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
template_id: uuid.UUID,
|
||||
data: TemplateUpdate,
|
||||
) -> MessageTemplate:
|
||||
template = await self.get_template(tenant_id, template_id)
|
||||
|
||||
if data.name is not None:
|
||||
existing = await self.db.execute(
|
||||
select(MessageTemplate).where(
|
||||
MessageTemplate.tenant_id == tenant_id,
|
||||
MessageTemplate.name == data.name,
|
||||
MessageTemplate.id != template_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ConflictError(f"Template '{data.name}' gia' esistente")
|
||||
template.name = data.name
|
||||
|
||||
if data.description is not None:
|
||||
template.description = data.description
|
||||
if data.subject is not None:
|
||||
template.subject = data.subject
|
||||
if data.body_text is not None:
|
||||
template.body_text = data.body_text
|
||||
if data.body_html is not None:
|
||||
template.body_html = data.body_html
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(template)
|
||||
return template
|
||||
|
||||
async def delete_template(
|
||||
self, tenant_id: uuid.UUID, template_id: uuid.UUID
|
||||
) -> None:
|
||||
template = await self.get_template(tenant_id, template_id)
|
||||
await self.db.delete(template)
|
||||
await self.db.commit()
|
||||
@@ -13,6 +13,7 @@ from app.core.security import hash_password
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreateRequest, UserUpdateRequest
|
||||
from app.services.audit_service import log_audit
|
||||
|
||||
|
||||
class UserService:
|
||||
@@ -61,6 +62,15 @@ class UserService:
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.flush() # ottieni l'ID
|
||||
await log_audit(
|
||||
self.db,
|
||||
"user.created",
|
||||
tenant_id=tenant_id,
|
||||
user_id=created_by.id,
|
||||
resource_type="user",
|
||||
resource_id=user.id,
|
||||
payload={"email": user.email, "role": user.role},
|
||||
)
|
||||
return user
|
||||
|
||||
async def get_user(self, user_id: uuid.UUID, tenant_id: uuid.UUID) -> User:
|
||||
@@ -110,13 +120,26 @@ class UserService:
|
||||
if user.is_super_admin and not updated_by.is_super_admin:
|
||||
raise ForbiddenError("Non puoi modificare un super_admin")
|
||||
|
||||
changes: dict = {}
|
||||
if data.full_name is not None:
|
||||
changes["full_name"] = data.full_name
|
||||
user.full_name = data.full_name
|
||||
if data.role is not None:
|
||||
changes["role"] = data.role
|
||||
user.role = data.role
|
||||
if data.is_active is not None:
|
||||
changes["is_active"] = data.is_active
|
||||
user.is_active = data.is_active
|
||||
|
||||
await log_audit(
|
||||
self.db,
|
||||
"user.updated",
|
||||
tenant_id=tenant_id,
|
||||
user_id=updated_by.id,
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
payload={"changes": changes},
|
||||
)
|
||||
return user
|
||||
|
||||
async def reset_password(
|
||||
@@ -143,3 +166,12 @@ class UserService:
|
||||
|
||||
# Soft delete (disabilita invece di eliminare)
|
||||
user.is_active = False
|
||||
await log_audit(
|
||||
self.db,
|
||||
"user.deleted",
|
||||
tenant_id=tenant_id,
|
||||
user_id=deleted_by.id,
|
||||
resource_type="user",
|
||||
resource_id=user_id,
|
||||
payload={"email": user.email},
|
||||
)
|
||||
|
||||
@@ -46,6 +46,15 @@ dependencies = [
|
||||
# Storage MinIO/S3
|
||||
"miniopy-async>=1.21.0",
|
||||
|
||||
# Estrazione testo allegati (usato anche dal job rescan nel backend)
|
||||
"pypdf>=4.0.0",
|
||||
"python-docx>=1.1.0",
|
||||
|
||||
# OCR per allegati image-only (immagini dirette e PDF scansionati)
|
||||
"pytesseract>=0.3.13",
|
||||
"pdf2image>=1.17.0",
|
||||
"Pillow>=11.0.0",
|
||||
|
||||
# IMAP async (per test connessione nel backend + mailbox service)
|
||||
"aioimaplib>=2.0.0",
|
||||
|
||||
@@ -58,6 +67,9 @@ dependencies = [
|
||||
# Utilities
|
||||
"python-multipart>=0.0.9", # upload file
|
||||
"python-dotenv>=1.0.0",
|
||||
|
||||
# Generazione PDF report (puro Python, nessuna dipendenza di sistema)
|
||||
"reportlab>=4.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+345
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "pecflow-frontend",
|
||||
"name": "pechub-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
@@ -251,6 +251,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -2797,6 +2806,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3378,6 +3450,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
@@ -3406,6 +3599,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -3445,6 +3644,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -3601,6 +3810,12 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -3861,6 +4076,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -4000,6 +4224,12 @@
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -4184,7 +4414,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4465,6 +4694,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||
@@ -4763,6 +5009,12 @@
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -4852,6 +5104,21 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -4874,6 +5141,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -4897,6 +5180,38 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -5161,6 +5476,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -5396,6 +5717,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
Generated
+347
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "pecflow-frontend",
|
||||
"name": "pechub-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pecflow-frontend",
|
||||
"name": "pechub-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
@@ -42,6 +42,7 @@
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.13.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
@@ -306,6 +307,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -3548,6 +3558,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4129,6 +4202,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
@@ -4157,6 +4351,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
@@ -4196,6 +4396,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -4352,6 +4562,12 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -4627,6 +4843,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -4766,6 +4991,12 @@
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -4950,7 +5181,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -5231,6 +5461,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||
@@ -5529,6 +5776,12 @@
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5618,6 +5871,21 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -5640,6 +5908,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -5663,6 +5947,38 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -5927,6 +6243,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6162,6 +6484,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"recharts": "^2.13.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+36
-8
@@ -11,6 +11,13 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage'
|
||||
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
|
||||
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
|
||||
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
|
||||
import { SearchPage } from '@/pages/Search/SearchPage'
|
||||
import { ReportsPage } from '@/pages/Reports/ReportsPage'
|
||||
import { AuditLogPage } from '@/pages/AuditLog/AuditLogPage'
|
||||
import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
|
||||
import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
|
||||
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
|
||||
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
|
||||
|
||||
/**
|
||||
* Routing principale dell'applicazione PEChub.
|
||||
@@ -44,16 +51,22 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/inbox" replace />} />
|
||||
|
||||
{/* Vista globale: tutte le caselle insieme */}
|
||||
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
|
||||
<Route path="/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
|
||||
<Route path="/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
|
||||
|
||||
{/* Vista per singola casella PEC */}
|
||||
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
|
||||
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
|
||||
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
|
||||
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
|
||||
<Route path="/mailbox/:mailboxId/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
|
||||
<Route path="/mailbox/:mailboxId/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
|
||||
|
||||
{/* Vista per Virtual Box assegnata */}
|
||||
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
|
||||
@@ -74,6 +87,21 @@ export default function App() {
|
||||
{/* Super Admin – Gestione Multi-Tenant */}
|
||||
<Route path="/multitenant" element={<MultiTenantPage />} />
|
||||
|
||||
{/* Ricerca avanzata full-text */}
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
|
||||
{/* Dashboard e Reportistica */}
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
|
||||
{/* Audit Log */}
|
||||
<Route path="/audit-log" element={<AuditLogPage />} />
|
||||
|
||||
{/* Nuove funzionalita' */}
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/routing-rules" element={<RoutingRulesPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/deadlines" element={<DeadlinesPage />} />
|
||||
|
||||
{/* Profilo utente */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Client API per Audit Log.
|
||||
*/
|
||||
|
||||
import apiClient from './client'
|
||||
import type { PaginatedResponse } from '@/types/api.types'
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number
|
||||
tenant_id: string | null
|
||||
user_id: string | null
|
||||
action: string
|
||||
resource_type: string | null
|
||||
resource_id: string | null
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
payload: Record<string, unknown> | null
|
||||
outcome: 'success' | 'failure'
|
||||
occurred_at: string
|
||||
}
|
||||
|
||||
export type AuditLogListResponse = PaginatedResponse<AuditLogEntry>
|
||||
|
||||
export interface AuditLogParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
action?: string
|
||||
user_id?: string
|
||||
outcome?: 'success' | 'failure'
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
resource_type?: string
|
||||
tenant_id?: string
|
||||
}
|
||||
|
||||
export const auditLogApi = {
|
||||
list: (params: AuditLogParams = {}): Promise<AuditLogListResponse> =>
|
||||
apiClient
|
||||
.get<AuditLogListResponse>('/audit-log', { params })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PecContactResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
email: string
|
||||
name: string | null
|
||||
organization: string | null
|
||||
notes: string | null
|
||||
is_favorite: boolean
|
||||
auto_saved: boolean
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PecContactCreate {
|
||||
email: string
|
||||
name?: string | null
|
||||
organization?: string | null
|
||||
notes?: string | null
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface PecContactUpdate {
|
||||
name?: string | null
|
||||
organization?: string | null
|
||||
notes?: string | null
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface ContactImportResult {
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const contactsApi = {
|
||||
list: (params?: { q?: string; page?: number; page_size?: number }) =>
|
||||
apiClient.get<{ items: PecContactResponse[]; total: number }>('/contacts', { params }).then((r) => r.data),
|
||||
|
||||
autocomplete: (q: string) =>
|
||||
apiClient.get<PecContactResponse[]>('/contacts/autocomplete', { params: { q } }).then((r) => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
apiClient.get<PecContactResponse>(`/contacts/${id}`).then((r) => r.data),
|
||||
|
||||
create: (data: PecContactCreate) =>
|
||||
apiClient.post<PecContactResponse>('/contacts', data).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: PecContactUpdate) =>
|
||||
apiClient.put<PecContactResponse>(`/contacts/${id}`, data).then((r) => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
apiClient.delete(`/contacts/${id}`).then((r) => r.data),
|
||||
|
||||
importCsv: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiClient.post<ContactImportResult>('/contacts/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then((r) => r.data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface DeadlineMessageResponse {
|
||||
id: string
|
||||
subject: string | null
|
||||
from_address: string | null
|
||||
to_addresses: string[] | null
|
||||
direction: 'inbound' | 'outbound'
|
||||
pec_type: string
|
||||
state: string
|
||||
mailbox_id: string
|
||||
deadline_at: string | null
|
||||
deadline_note: string | null
|
||||
is_overdue: boolean
|
||||
received_at: string | null
|
||||
sent_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DeadlineSetRequest {
|
||||
deadline_at: string | null
|
||||
deadline_note?: string | null
|
||||
}
|
||||
|
||||
export const deadlinesApi = {
|
||||
list: (params?: { days_ahead?: number; include_overdue?: boolean }) =>
|
||||
apiClient.get<DeadlineMessageResponse[]>('/deadlines', { params }).then((r) => r.data),
|
||||
|
||||
setDeadline: (messageId: string, data: DeadlineSetRequest) =>
|
||||
apiClient.post<DeadlineMessageResponse>(`/messages/${messageId}/deadline`, data).then((r) => r.data),
|
||||
}
|
||||
@@ -28,4 +28,16 @@ export const mailboxesApi = {
|
||||
apiClient
|
||||
.post<ConnectionTestResult>(`/mailboxes/${id}/test-connection`, { protocol })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Forza una sincronizzazione IMAP manuale (accoda job arq). */
|
||||
forceSync: (id: string) =>
|
||||
apiClient
|
||||
.post<{ status: string; mailbox_id: string; message: string }>(`/mailboxes/${id}/sync`)
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Restituisce il conteggio messaggi non letti per ciascuna casella. */
|
||||
getUnreadCounts: () =>
|
||||
apiClient
|
||||
.get<{ counts: Record<string, number> }>('/mailboxes/unread-counts')
|
||||
.then((r) => r.data.counts),
|
||||
}
|
||||
|
||||
@@ -13,16 +13,30 @@ export interface MessageFilters {
|
||||
mailbox_id?: string
|
||||
direction?: 'inbound' | 'outbound'
|
||||
state?: string
|
||||
pec_type?: string
|
||||
is_read?: boolean
|
||||
is_starred?: boolean
|
||||
is_archived?: boolean
|
||||
is_trashed?: boolean
|
||||
/** Filtra per messaggi in attesa di conservazione (cartella Da Conservare) */
|
||||
is_pending_conservation?: boolean
|
||||
/** Filtra per messaggi gia' conservati (cartella Storico) */
|
||||
is_conserved?: boolean
|
||||
search?: string
|
||||
/** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */
|
||||
date_from?: string
|
||||
/** Data massima nel formato ISO 8601 */
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
export interface MessageBulkUpdatePayload {
|
||||
ids: string[]
|
||||
is_read?: boolean
|
||||
is_starred?: boolean
|
||||
is_archived?: boolean
|
||||
is_trashed?: boolean
|
||||
is_pending_conservation?: boolean
|
||||
is_conserved?: boolean
|
||||
}
|
||||
|
||||
export interface MessageBulkUpdateResponse {
|
||||
@@ -60,7 +74,29 @@ export const messagesApi = {
|
||||
.patch<MessageResponse>(`/messages/${id}`, { is_archived: false })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Aggiorna in blocco is_starred e/o is_archived su più messaggi */
|
||||
trash: (id: string) =>
|
||||
apiClient
|
||||
.patch<MessageResponse>(`/messages/${id}`, { is_trashed: true })
|
||||
.then((r) => r.data),
|
||||
|
||||
untrash: (id: string) =>
|
||||
apiClient
|
||||
.patch<MessageResponse>(`/messages/${id}`, { is_trashed: false })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Sposta un messaggio nella cartella Da Conservare */
|
||||
conserve: (id: string) =>
|
||||
apiClient
|
||||
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: true })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Rimuove un messaggio dalla cartella Da Conservare */
|
||||
unconserve: (id: string) =>
|
||||
apiClient
|
||||
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: false })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
|
||||
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
|
||||
apiClient
|
||||
.patch<MessageBulkUpdateResponse>('/messages/bulk', payload)
|
||||
@@ -91,4 +127,47 @@ export const messagesApi = {
|
||||
|
||||
getReceipts: (id: string) =>
|
||||
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
|
||||
|
||||
// ─── Feature 3: Thread ────────────────────────────────────────────────────
|
||||
|
||||
getThread: (id: string) =>
|
||||
apiClient.get<MessageResponse[]>(`/messages/${id}/thread`).then((r) => r.data),
|
||||
|
||||
// ─── Feature 7: Preview allegati ─────────────────────────────────────────
|
||||
|
||||
getAttachmentPreviewUrl: (messageId: string, attachmentId: string) =>
|
||||
apiClient.get<{
|
||||
previewable: boolean
|
||||
content_type: string
|
||||
filename: string
|
||||
url?: string
|
||||
}>(`/messages/${messageId}/attachments/${attachmentId}/preview-url`).then((r) => r.data),
|
||||
|
||||
// ─── Feature 8: Stampa ────────────────────────────────────────────────────
|
||||
|
||||
/** Apre la vista di stampa HTML in una nuova tab. */
|
||||
openPrint: (messageId: string, token: string) => {
|
||||
const baseUrl = (window as any).__API_BASE_URL__ || '/api/v1'
|
||||
window.open(`${baseUrl}/messages/${messageId}/print?token=${token}`, '_blank')
|
||||
},
|
||||
|
||||
/**
|
||||
* Scarica il pacchetto ZIP completo della PEC (postacert.eml, daticert.xml,
|
||||
* ricevute di accettazione/consegna per le mail outbound).
|
||||
*/
|
||||
downloadPackage: async (messageId: string, subject?: string | null): Promise<void> => {
|
||||
const response = await apiClient.get(
|
||||
`/messages/${messageId}/download-package`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const blobUrl = window.URL.createObjectURL(new Blob([response.data], { type: 'application/zip' }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = blobUrl
|
||||
const safeSubject = (subject || 'pec').replace(/[/\\]/g, '_').slice(0, 50)
|
||||
anchor.setAttribute('download', `pec_${safeSubject}.zip`)
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* API client per la Dashboard e Reportistica (Fase 7).
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
|
||||
// ─── Tipi ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KpiSummary {
|
||||
received_today: number
|
||||
sent_today: number
|
||||
received_7d: number
|
||||
sent_7d: number
|
||||
received_30d: number
|
||||
sent_30d: number
|
||||
anomalie_attive: number
|
||||
tasso_consegna: number
|
||||
caselle_in_errore: number
|
||||
messaggi_non_letti: number
|
||||
totale_messaggi: number
|
||||
}
|
||||
|
||||
export interface DailyStat {
|
||||
day: string // "YYYY-MM-DD"
|
||||
received: number
|
||||
sent: number
|
||||
}
|
||||
|
||||
export interface OutboundStateStat {
|
||||
state: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface MailboxStat {
|
||||
mailbox_id: string
|
||||
email_address: string
|
||||
display_name: string | null
|
||||
status: string
|
||||
received_total: number
|
||||
sent_total: number
|
||||
anomalie: number
|
||||
non_letti: number
|
||||
last_sync_at: string | null
|
||||
}
|
||||
|
||||
export interface ReportSummaryResponse {
|
||||
generated_at: string
|
||||
period_days: number
|
||||
kpi: KpiSummary
|
||||
daily_stats: DailyStat[]
|
||||
outbound_states: OutboundStateStat[]
|
||||
mailbox_stats: MailboxStat[]
|
||||
}
|
||||
|
||||
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const reportsApi = {
|
||||
/**
|
||||
* Recupera il riepilogo KPI + grafici per la dashboard.
|
||||
* @param days Numero di giorni per la serie storica (default 7)
|
||||
*/
|
||||
getSummary: async (days = 7): Promise<ReportSummaryResponse> => {
|
||||
const res = await apiClient.get<ReportSummaryResponse>('/reports/summary', {
|
||||
params: { days },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Scarica il report in formato CSV.
|
||||
* Il browser riceverà un file da scaricare.
|
||||
*/
|
||||
exportCsv: (params?: {
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
mailbox_id?: string
|
||||
}) => {
|
||||
const url = new URL('/api/v1/reports/export', window.location.origin)
|
||||
url.searchParams.set('format', 'csv')
|
||||
if (params?.date_from) url.searchParams.set('date_from', params.date_from)
|
||||
if (params?.date_to) url.searchParams.set('date_to', params.date_to)
|
||||
if (params?.mailbox_id) url.searchParams.set('mailbox_id', params.mailbox_id)
|
||||
return url.toString()
|
||||
},
|
||||
|
||||
/**
|
||||
* Scarica il report in formato PDF.
|
||||
*/
|
||||
exportPdf: (params?: {
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}) => {
|
||||
const url = new URL('/api/v1/reports/export', window.location.origin)
|
||||
url.searchParams.set('format', 'pdf')
|
||||
if (params?.date_from) url.searchParams.set('date_from', params.date_from)
|
||||
if (params?.date_to) url.searchParams.set('date_to', params.date_to)
|
||||
return url.toString()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type'
|
||||
export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
|
||||
export type ActionType = 'apply_label' | 'assign_vbox' | 'mark_read' | 'mark_starred' | 'notify_webhook'
|
||||
|
||||
export interface RoutingRuleCondition {
|
||||
id: string
|
||||
field: ConditionField
|
||||
operator: ConditionOperator
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface RoutingRuleAction {
|
||||
id: string
|
||||
action_type: ActionType
|
||||
action_value: string | null
|
||||
}
|
||||
|
||||
export interface RoutingRuleResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
priority: number
|
||||
stop_processing: boolean
|
||||
conditions: RoutingRuleCondition[]
|
||||
actions: RoutingRuleAction[]
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoutingRuleCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
is_active?: boolean
|
||||
priority?: number
|
||||
stop_processing?: boolean
|
||||
conditions?: Array<{ field: ConditionField; operator: ConditionOperator; value: string }>
|
||||
actions?: Array<{ action_type: ActionType; action_value?: string | null }>
|
||||
}
|
||||
|
||||
export type RoutingRuleUpdate = Partial<RoutingRuleCreate>
|
||||
|
||||
export const routingRulesApi = {
|
||||
list: () =>
|
||||
apiClient.get<{ items: RoutingRuleResponse[]; total: number }>('/routing-rules').then((r) => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
apiClient.get<RoutingRuleResponse>(`/routing-rules/${id}`).then((r) => r.data),
|
||||
|
||||
create: (data: RoutingRuleCreate) =>
|
||||
apiClient.post<RoutingRuleResponse>('/routing-rules', data).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: RoutingRuleUpdate) =>
|
||||
apiClient.put<RoutingRuleResponse>(`/routing-rules/${id}`, data).then((r) => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
apiClient.delete(`/routing-rules/${id}`).then((r) => r.data),
|
||||
|
||||
toggle: (id: string) =>
|
||||
apiClient.post<RoutingRuleResponse>(`/routing-rules/${id}/toggle`).then((r) => r.data),
|
||||
}
|
||||
@@ -2,13 +2,17 @@
|
||||
* API client per le impostazioni del tenant.
|
||||
*
|
||||
* Endpoint:
|
||||
* GET /api/v1/settings → legge configurazione (admin)
|
||||
* PUT /api/v1/settings → aggiorna configurazione (admin)
|
||||
* GET /api/v1/settings -> legge configurazione (admin)
|
||||
* PUT /api/v1/settings -> aggiorna configurazione (admin)
|
||||
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
|
||||
* GET /api/v1/settings/indexing/status -> stato job reindex
|
||||
* POST /api/v1/settings/indexing/reindex -> avvia reindex
|
||||
* DELETE /api/v1/settings/indexing/reindex -> cancella reindex
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
|
||||
// ─── Tipi ──────────────────────────────────────────────────────────────────
|
||||
// ─── Tipi impostazioni generali ────────────────────────────────────────────
|
||||
|
||||
export type ArchivalMode = 'mock' | 'production'
|
||||
|
||||
@@ -37,7 +41,38 @@ export interface TenantSettingsUpdate {
|
||||
archival_notes?: string
|
||||
}
|
||||
|
||||
// ─── Client ────────────────────────────────────────────────────────────────
|
||||
// ─── Tipi indicizzazione full-text ─────────────────────────────────────────
|
||||
|
||||
export interface IndexingStats {
|
||||
total_messages: number
|
||||
indexed_messages: number
|
||||
unindexed_messages: number
|
||||
coverage_pct: number // 0-100
|
||||
|
||||
attachments_total: number
|
||||
attachments_extracted: number
|
||||
attachments_pct: number // 0-100
|
||||
}
|
||||
|
||||
export type ReindexMode = 'full' | 'differential'
|
||||
export type JobStatus = 'idle' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
export interface IndexingJobStatus {
|
||||
status: JobStatus
|
||||
mode: ReindexMode | null
|
||||
total: number
|
||||
processed: number
|
||||
progress_pct: number
|
||||
|
||||
started_at: string | null // ISO datetime
|
||||
finished_at: string | null // ISO datetime
|
||||
started_by: string | null // email
|
||||
elapsed_seconds: number | null
|
||||
is_stale: boolean // running da piu' di 2 ore
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// ─── Client impostazioni generali ──────────────────────────────────────────
|
||||
|
||||
export const settingsApi = {
|
||||
/**
|
||||
@@ -57,4 +92,97 @@ export const settingsApi = {
|
||||
const { data } = await apiClient.put<TenantSettingsResponse>('/settings', payload)
|
||||
return data
|
||||
},
|
||||
|
||||
// ── Indicizzazione full-text ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restituisce le statistiche di copertura dell'indicizzazione.
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
getIndexingStats: async (tenantId?: string): Promise<IndexingStats> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.get<IndexingStats>('/settings/indexing/stats', { params })
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Restituisce lo stato del job di reindex in corso (o idle se nessuno).
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
getIndexingStatus: async (tenantId?: string): Promise<IndexingJobStatus> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.get<IndexingJobStatus>('/settings/indexing/status', { params })
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Avvia un job di reindex in background.
|
||||
* @param mode - 'differential' (solo NULL) o 'full' (tutti i messaggi)
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
startReindex: async (mode: ReindexMode, tenantId?: string): Promise<IndexingJobStatus> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.post<IndexingJobStatus>(
|
||||
'/settings/indexing/reindex',
|
||||
{ mode },
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancella il job di reindex in corso.
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
cancelReindex: async (tenantId?: string): Promise<IndexingJobStatus> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.delete<IndexingJobStatus>(
|
||||
'/settings/indexing/reindex',
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
// ── Scansione allegati ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restituisce lo stato del job di scansione allegati (idle se nessuno).
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
getRescanStatus: async (tenantId?: string): Promise<IndexingJobStatus> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.get<IndexingJobStatus>(
|
||||
'/settings/indexing/rescan-status',
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Avvia un job di scansione allegati in background.
|
||||
* @param force - false: solo allegati senza testo estratto; true: tutti
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
startRescan: async (force: boolean = false, tenantId?: string): Promise<IndexingJobStatus> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.post<IndexingJobStatus>(
|
||||
'/settings/indexing/rescan',
|
||||
{ force },
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancella il job di scansione allegati in corso.
|
||||
* @param tenantId - (solo super_admin) UUID del tenant target
|
||||
*/
|
||||
cancelRescan: async (tenantId?: string): Promise<IndexingJobStatus> => {
|
||||
const params = tenantId ? { tenant_id: tenantId } : undefined
|
||||
const { data } = await apiClient.delete<IndexingJobStatus>(
|
||||
'/settings/indexing/rescan',
|
||||
{ params }
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface TemplateResponse {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
description: string | null
|
||||
subject: string
|
||||
body_text: string | null
|
||||
body_html: string | null
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TemplateCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
subject?: string
|
||||
body_text?: string | null
|
||||
body_html?: string | null
|
||||
}
|
||||
|
||||
export interface TemplateUpdate {
|
||||
name?: string
|
||||
description?: string | null
|
||||
subject?: string
|
||||
body_text?: string | null
|
||||
body_html?: string | null
|
||||
}
|
||||
|
||||
export const templatesApi = {
|
||||
list: (q?: string) =>
|
||||
apiClient.get<{ items: TemplateResponse[]; total: number }>('/templates', {
|
||||
params: { q },
|
||||
}).then((r) => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
apiClient.get<TemplateResponse>(`/templates/${id}`).then((r) => r.data),
|
||||
|
||||
create: (data: TemplateCreate) =>
|
||||
apiClient.post<TemplateResponse>('/templates', data).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: TemplateUpdate) =>
|
||||
apiClient.put<TemplateResponse>(`/templates/${id}`, data).then((r) => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
apiClient.delete(`/templates/${id}`).then((r) => r.data),
|
||||
}
|
||||
@@ -49,6 +49,15 @@ import {
|
||||
Star,
|
||||
Archive,
|
||||
Building2,
|
||||
Trash2,
|
||||
Search,
|
||||
BarChart2,
|
||||
ClipboardList,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
Settings2,
|
||||
BookUser,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
@@ -72,7 +81,7 @@ export function Sidebar() {
|
||||
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
|
||||
const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set())
|
||||
|
||||
const { user, isAdmin, isSuperAdmin, logout } = useAuth()
|
||||
const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth()
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount)
|
||||
|
||||
// Le caselle PEC vengono caricate qui e condivise via React Query cache
|
||||
@@ -83,6 +92,14 @@ export function Sidebar() {
|
||||
})
|
||||
const mailboxes = mailboxesData?.items ?? []
|
||||
|
||||
// Conteggio messaggi non letti per singola casella
|
||||
const { data: unreadCounts = {} } = useQuery({
|
||||
queryKey: ['mailboxes', 'unread-counts'],
|
||||
queryFn: () => mailboxesApi.getUnreadCounts(),
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 120 * 1000,
|
||||
})
|
||||
|
||||
// Virtual Box assegnate all'utente corrente
|
||||
const { data: myVboxes = [] } = useQuery({
|
||||
queryKey: ['virtual-boxes', 'my'],
|
||||
@@ -266,6 +283,72 @@ export function Sidebar() {
|
||||
<Archive className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Archiviati</span>}
|
||||
</NavLink>
|
||||
|
||||
{/* Cestino globale */}
|
||||
<NavLink
|
||||
to="/trash"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Cestino (tutte le caselle)' : undefined}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Cestino</span>}
|
||||
</NavLink>
|
||||
|
||||
{/* ── Sezione Conservazione (admin e supervisor) ── */}
|
||||
{(isAdmin || isSupervisor) && (
|
||||
<>
|
||||
{!collapsed && (
|
||||
<div className="pt-1 pb-0.5 px-1">
|
||||
<p className="text-[10px] font-semibold text-teal-500/70 uppercase tracking-wider">
|
||||
Conservazione
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<NavLink
|
||||
to="/conservation-pending"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-teal-700 text-white'
|
||||
: 'text-teal-300 hover:bg-teal-900/40 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Da Conservare' : undefined}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Da Conservare</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/conservation-archived"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-teal-700 text-white'
|
||||
: 'text-teal-300 hover:bg-teal-900/40 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Storico Conservazione' : undefined}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
{!collapsed && <span>Storico</span>}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,6 +373,7 @@ export function Sidebar() {
|
||||
collapsed={collapsed}
|
||||
isExpanded={isMailboxExpanded(mailbox.id)}
|
||||
onToggle={() => toggleMailbox(mailbox.id)}
|
||||
unreadCount={unreadCounts[mailbox.id] ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -323,6 +407,77 @@ export function Sidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Strumenti operativi ── */}
|
||||
<div>
|
||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||
<div className="px-2 space-y-0.5">
|
||||
<NavLink
|
||||
to="/deadlines"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Scadenzario' : undefined}
|
||||
>
|
||||
<Calendar className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Scadenzario</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Rubrica PEC' : undefined}
|
||||
>
|
||||
<BookUser className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Rubrica PEC</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/search"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Ricerca avanzata' : undefined}
|
||||
>
|
||||
<Search className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Ricerca</span>}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/reports"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Dashboard / Report' : undefined}
|
||||
>
|
||||
<BarChart2 className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Dashboard</span>}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Nuova PEC ── */}
|
||||
<div>
|
||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||
@@ -346,6 +501,40 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sezione Supervisione – visibile solo ai supervisor ── */}
|
||||
{isSupervisor && (
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||
<p className="px-4 mb-1.5 text-xs font-semibold text-cyan-400 uppercase tracking-wider">
|
||||
Supervisione
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
|
||||
|
||||
<div className="space-y-0.5 px-2">
|
||||
<NavLink
|
||||
to="/mailboxes"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-cyan-700 text-white'
|
||||
: 'text-cyan-300 hover:bg-cyan-900/40 hover:text-white',
|
||||
collapsed && 'justify-center px-2',
|
||||
)
|
||||
}
|
||||
title={collapsed ? 'Caselle PEC' : undefined}
|
||||
>
|
||||
<MailCheck className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span>Caselle PEC</span>}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Sezione Amministrazione ── */}
|
||||
{isAdmin && (
|
||||
<div>
|
||||
@@ -365,7 +554,10 @@ export function Sidebar() {
|
||||
{ to: '/users', label: 'Utenti', icon: Users },
|
||||
{ to: '/permissions', label: 'Permessi', icon: Shield },
|
||||
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
|
||||
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
|
||||
{ to: '/templates', label: 'Template messaggi', icon: FileText },
|
||||
{ to: '/notifications', label: 'Notifiche', icon: Bell },
|
||||
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
|
||||
] as const).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
@@ -475,6 +667,7 @@ interface MailboxNavItemProps {
|
||||
collapsed: boolean
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
unreadCount?: number
|
||||
}
|
||||
|
||||
/** Colore del pallino di stato casella */
|
||||
@@ -493,10 +686,12 @@ function statusDot(status: MailboxResponse['status']): string {
|
||||
}
|
||||
}
|
||||
|
||||
function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNavItemProps) {
|
||||
function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount = 0 }: MailboxNavItemProps) {
|
||||
const displayName = mailbox.display_name || mailbox.email_address
|
||||
const initial = displayName[0]?.toUpperCase() ?? '?'
|
||||
const dotClass = statusDot(mailbox.status)
|
||||
const { isAdmin, isSupervisor } = useAuth()
|
||||
const canSeeConservation = isAdmin || isSupervisor
|
||||
|
||||
/* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */
|
||||
if (collapsed) {
|
||||
@@ -511,7 +706,7 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||
)
|
||||
}
|
||||
title={displayName}
|
||||
title={`${displayName}${unreadCount > 0 ? ` (${unreadCount} non letti)` : ''}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="h-6 w-6 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold">
|
||||
@@ -523,6 +718,11 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
|
||||
dotClass,
|
||||
)}
|
||||
/>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 h-3.5 min-w-[14px] px-0.5 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</NavLink>
|
||||
)
|
||||
@@ -554,6 +754,13 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
{/* Badge non letti per casella */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[16px] px-1 rounded-full bg-blue-500 text-white text-[10px] font-bold flex-shrink-0">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Chevron espandi/comprimi */}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
@@ -625,6 +832,55 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav
|
||||
<Archive className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>Archiviati</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to={`/mailbox/${mailbox.id}/trash`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>Cestino</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Conservazione (solo admin/supervisor) */}
|
||||
{canSeeConservation && (
|
||||
<>
|
||||
<NavLink
|
||||
to={`/mailbox/${mailbox.id}/conservation-pending`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-teal-700 text-white'
|
||||
: 'text-teal-400 hover:bg-teal-900/40 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>Da Conservare</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={`/mailbox/${mailbox.id}/conservation-archived`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-teal-700 text-white'
|
||||
: 'text-teal-400 hover:bg-teal-900/40 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 opacity-60" />
|
||||
<span>Storico</span>
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, ChevronRight, Mail } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Mail, ExternalLink } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { MessageResponse } from '@/types/api.types'
|
||||
@@ -12,9 +13,11 @@ interface ReceiptTreeProps {
|
||||
/**
|
||||
* Visualizza la gerarchia delle ricevute PEC collegate a un messaggio.
|
||||
* Mostra in ordine cronologico: accettazione → consegna (o anomalia).
|
||||
* Le ricevute sono cliccabili e navigano al dettaglio del messaggio ricevuta.
|
||||
*/
|
||||
export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (receipts.length === 0) {
|
||||
if (message.direction === 'outbound') {
|
||||
@@ -66,6 +69,7 @@ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) {
|
||||
date={receipt.received_at || receipt.created_at}
|
||||
type={receipt.pec_type}
|
||||
messageId={receipt.id}
|
||||
onClick={() => navigate(`/messages/${receipt.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -81,25 +85,41 @@ interface ReceiptNodeProps {
|
||||
type?: MessageResponse['pec_type']
|
||||
messageId?: string
|
||||
isRoot?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
|
||||
return (
|
||||
function ReceiptNode({ label, date, state, type, isRoot, onClick }: ReceiptNodeProps) {
|
||||
const isClickable = !!onClick
|
||||
|
||||
const content = (
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Indicatore timeline */}
|
||||
<div className="mt-1 flex flex-col items-center">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full border-2 ${
|
||||
isRoot ? 'border-primary bg-primary/20' : 'border-muted-foreground bg-muted'
|
||||
isRoot
|
||||
? 'border-primary bg-primary/20'
|
||||
: isClickable
|
||||
? 'border-blue-500 bg-blue-100'
|
||||
: 'border-muted-foreground bg-muted'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{label}</span>
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${
|
||||
isClickable ? 'text-blue-600 group-hover:underline' : ''
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{state && !isRoot && <PecStateBadge state={state} />}
|
||||
{type && type !== 'posta_certificata' && <PecTypeBadge type={type} />}
|
||||
{isClickable && (
|
||||
<ExternalLink className="h-3.5 w-3.5 text-blue-400 group-hover:text-blue-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{date && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{formatDate(date)}</p>
|
||||
@@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full text-left group rounded-md px-2 py-1 -mx-2 hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
title="Apri dettaglio ricevuta"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{content}</div>
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useAuthStore } from '@/store/auth.store'
|
||||
|
||||
/**
|
||||
* Hook helper per accedere all'utente corrente e ai permessi.
|
||||
*
|
||||
* Gerarchia ruoli:
|
||||
* super_admin > admin > supervisor > operator > readonly
|
||||
*
|
||||
* Supervisor:
|
||||
* - Lettura implicita su tutte le caselle del tenant
|
||||
* - Invio solo se ha permesso esplicito can_send sulla casella
|
||||
* - Non puo' gestire caselle, utenti, permessi o impostazioni
|
||||
*/
|
||||
export function useAuth() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
@@ -11,6 +19,8 @@ export function useAuth() {
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin'
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const isSupervisor = user?.role === 'supervisor'
|
||||
const isSupervisorOrAdmin = isAdmin || isSupervisor
|
||||
const canSend = user?.role !== 'readonly'
|
||||
const canManage = isAdmin
|
||||
|
||||
@@ -20,6 +30,8 @@ export function useAuth() {
|
||||
isLoading,
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
isSupervisor,
|
||||
isSupervisorOrAdmin,
|
||||
canSend,
|
||||
canManage,
|
||||
logout,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Pagina Audit Log – visualizzazione eventi di sistema.
|
||||
*
|
||||
* Accessibile solo ad admin e super_admin.
|
||||
* Mostra una tabella paginata con filtri per data, azione ed esito.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import { it } from 'date-fns/locale'
|
||||
import { ShieldCheck, AlertCircle, Search, RotateCcw } from 'lucide-react'
|
||||
import { auditLogApi } from '@/api/audit_log.api'
|
||||
import type { AuditLogParams } from '@/api/audit_log.api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Badge esito ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OutcomeBadge({ outcome }: { outcome: string }) {
|
||||
const isSuccess = outcome === 'success'
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
isSuccess
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800',
|
||||
)}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
)}
|
||||
{isSuccess ? 'Successo' : 'Fallito'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Etichetta azione leggibile ───────────────────────────────────────────────
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'auth.login': 'Login',
|
||||
'auth.password_changed': 'Cambio password',
|
||||
'user.created': 'Utente creato',
|
||||
'user.updated': 'Utente modificato',
|
||||
'user.deleted': 'Utente eliminato',
|
||||
'mailbox.created': 'Casella creata',
|
||||
'mailbox.updated': 'Casella modificata',
|
||||
'mailbox.deleted': 'Casella eliminata',
|
||||
'message.sent': 'PEC inviata',
|
||||
}
|
||||
return map[action] ?? action
|
||||
}
|
||||
|
||||
// ─── Componente principale ────────────────────────────────────────────────────
|
||||
|
||||
export function AuditLogPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
// Filtri
|
||||
const [filterAction, setFilterAction] = useState('')
|
||||
const [filterOutcome, setFilterOutcome] = useState<'' | 'success' | 'failure'>('')
|
||||
const [filterDateFrom, setFilterDateFrom] = useState('')
|
||||
const [filterDateTo, setFilterDateTo] = useState('')
|
||||
|
||||
// Parametri query attivi (applicati al click su "Cerca")
|
||||
const [activeParams, setActiveParams] = useState<AuditLogParams>({})
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['audit-log', page, activeParams],
|
||||
queryFn: () =>
|
||||
auditLogApi.list({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
...activeParams,
|
||||
}),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
const params: AuditLogParams = {}
|
||||
if (filterAction) params.action = filterAction
|
||||
if (filterOutcome) params.outcome = filterOutcome
|
||||
if (filterDateFrom) params.date_from = new Date(filterDateFrom).toISOString()
|
||||
if (filterDateTo) params.date_to = new Date(filterDateTo + 'T23:59:59').toISOString()
|
||||
setActiveParams(params)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterAction('')
|
||||
setFilterOutcome('')
|
||||
setFilterDateFrom('')
|
||||
setFilterDateTo('')
|
||||
setActiveParams({})
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const pages = data?.pages ?? 1
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Intestazione */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Registro cronologico degli eventi di sistema e delle operazioni degli utenti.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filtri */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Azione */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Azione</label>
|
||||
<select
|
||||
value={filterAction}
|
||||
onChange={(e) => setFilterAction(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Tutte le azioni</option>
|
||||
<option value="auth.login">Login</option>
|
||||
<option value="auth.password_changed">Cambio password</option>
|
||||
<option value="user.created">Utente creato</option>
|
||||
<option value="user.updated">Utente modificato</option>
|
||||
<option value="user.deleted">Utente eliminato</option>
|
||||
<option value="mailbox.created">Casella creata</option>
|
||||
<option value="mailbox.updated">Casella modificata</option>
|
||||
<option value="mailbox.deleted">Casella eliminata</option>
|
||||
<option value="message.sent">PEC inviata</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Esito */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Esito</label>
|
||||
<select
|
||||
value={filterOutcome}
|
||||
onChange={(e) => setFilterOutcome(e.target.value as '' | 'success' | 'failure')}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Tutti</option>
|
||||
<option value="success">Successo</option>
|
||||
<option value="failure">Fallito</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Data da */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Dal</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDateFrom}
|
||||
onChange={(e) => setFilterDateFrom(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data a */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Al</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDateTo}
|
||||
onChange={(e) => setFilterDateTo(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottoni */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
Cerca
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reimposta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabella */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
{/* Header tabella con count */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{isLoading ? 'Caricamento...' : `${total.toLocaleString('it-IT')} eventi trovati`}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<span className="text-sm text-gray-400">
|
||||
Pagina {page} di {pages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stato errore */}
|
||||
{isError && (
|
||||
<div className="p-8 text-center text-red-600">
|
||||
<AlertCircle className="h-8 w-8 mx-auto mb-2" />
|
||||
<p className="text-sm">Errore nel caricamento dei dati.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stato vuoto */}
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<ShieldCheck className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">Nessun evento trovato.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dati */}
|
||||
{items.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Data / Ora
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Azione
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Esito
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Risorsa
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Utente
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{items.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
{/* Data/ora */}
|
||||
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
|
||||
{format(new Date(entry.occurred_at), 'dd/MM/yyyy HH:mm:ss', { locale: it })}
|
||||
</td>
|
||||
|
||||
{/* Azione */}
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900">
|
||||
{actionLabel(entry.action)}
|
||||
</span>
|
||||
<span className="ml-1 text-gray-400 font-mono text-xs">
|
||||
({entry.action})
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Esito */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<OutcomeBadge outcome={entry.outcome} />
|
||||
</td>
|
||||
|
||||
{/* Risorsa */}
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
|
||||
{entry.resource_type ? (
|
||||
<span>
|
||||
{entry.resource_type}
|
||||
{entry.resource_id && (
|
||||
<span className="ml-1 text-gray-400 font-mono text-xs">
|
||||
{entry.resource_id.split('-')[0]}...
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* IP */}
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
|
||||
{entry.ip_address ?? <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
|
||||
{/* Utente (UUID abbreviato) */}
|
||||
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
|
||||
{entry.user_id ? (
|
||||
<span title={entry.user_id}>
|
||||
{entry.user_id.split('-')[0]}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginazione */}
|
||||
{pages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Precedente
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-md disabled:opacity-40 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Successiva
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -50,24 +50,46 @@ export function ComposePage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const replyTo = location.state?.replyTo as MessageResponse | undefined
|
||||
const forwardOf = location.state?.forwardOf as MessageResponse | undefined
|
||||
const [showCc, setShowCc] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Corpo HTML (gestito da TipTap)
|
||||
const [bodyHtml, setBodyHtml] = useState<string>(() => {
|
||||
if (!replyTo) return ''
|
||||
const date = new Date(
|
||||
replyTo.received_at || replyTo.created_at
|
||||
).toLocaleDateString('it-IT')
|
||||
return [
|
||||
'<p></p>',
|
||||
'<p></p>',
|
||||
'<hr>',
|
||||
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
|
||||
`<p>Da: ${replyTo.from_address || ''}</p>`,
|
||||
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
|
||||
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
|
||||
].join('')
|
||||
if (replyTo) {
|
||||
const date = new Date(
|
||||
replyTo.received_at || replyTo.created_at
|
||||
).toLocaleDateString('it-IT')
|
||||
return [
|
||||
'<p></p>',
|
||||
'<p></p>',
|
||||
'<hr>',
|
||||
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
|
||||
`<p>Da: ${replyTo.from_address || ''}</p>`,
|
||||
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
|
||||
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
|
||||
].join('')
|
||||
}
|
||||
if (forwardOf) {
|
||||
const date = new Date(
|
||||
forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at
|
||||
).toLocaleDateString('it-IT')
|
||||
const bodyContent = forwardOf.body_text
|
||||
? `<pre style="white-space:pre-wrap;font-family:inherit">${forwardOf.body_text}</pre>`
|
||||
: ''
|
||||
return [
|
||||
'<p></p>',
|
||||
'<p></p>',
|
||||
'<hr>',
|
||||
`<p><strong>Messaggio inoltrato</strong></p>`,
|
||||
`<p>Da: ${forwardOf.from_address || ''}</p>`,
|
||||
`<p>A: ${forwardOf.to_addresses?.join(', ') || ''}</p>`,
|
||||
`<p>Data: ${date}</p>`,
|
||||
`<p>Oggetto: ${forwardOf.subject || ''}</p>`,
|
||||
bodyContent,
|
||||
].join('')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// Allegati
|
||||
@@ -80,12 +102,16 @@ export function ComposePage() {
|
||||
formState: { errors },
|
||||
} = useForm<ComposeFormValues>({
|
||||
defaultValues: {
|
||||
mailbox_id: replyTo?.mailbox_id || '',
|
||||
mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
|
||||
to_addresses: replyTo
|
||||
? [{ value: replyTo.from_address || '' }]
|
||||
: [{ value: '' }],
|
||||
cc_addresses: [],
|
||||
subject: replyTo ? `Re: ${replyTo.subject || ''}` : '',
|
||||
subject: replyTo
|
||||
? `Re: ${replyTo.subject || ''}`
|
||||
: forwardOf
|
||||
? `Fwd: ${forwardOf.subject || ''}`
|
||||
: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -214,13 +240,18 @@ export function ComposePage() {
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{replyTo ? 'Rispondi a PEC' : 'Nuova PEC'}
|
||||
{replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'}
|
||||
</h1>
|
||||
{replyTo && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
In risposta a: {replyTo.subject}
|
||||
</p>
|
||||
)}
|
||||
{forwardOf && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inoltro di: {forwardOf.subject}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,6 +270,17 @@ export function ComposePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Avviso allegati messaggio inoltrato */}
|
||||
{forwardOf && forwardOf.has_attachments && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
|
||||
<Paperclip className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-amber-700">
|
||||
Il messaggio originale contiene allegati. Per includerli nell'inoltro,
|
||||
scaricali dal messaggio originale e aggiungili qui manualmente.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Casella mittente */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mailbox_id">Casella mittente *</Label>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, Star, Trash2, Pencil, Upload, BookUser, Building2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
|
||||
import { contactsApi, type PecContactResponse, type PecContactCreate, type PecContactUpdate } from '@/api/contacts.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
export function ContactsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [q, setQ] = useState('')
|
||||
const [page] = useState(1)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState<PecContactResponse | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Form state
|
||||
const [formEmail, setFormEmail] = useState('')
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formOrg, setFormOrg] = useState('')
|
||||
const [formNotes, setFormNotes] = useState('')
|
||||
const [formFavorite, setFormFavorite] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contacts', q, page],
|
||||
queryFn: () => contactsApi.list({ q: q || undefined, page, page_size: 50 }),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: PecContactCreate) => contactsApi.create(d),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
toast.success('Contatto aggiunto')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: PecContactUpdate }) =>
|
||||
contactsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
toast.success('Contatto aggiornato')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => contactsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
toast.success('Contatto eliminato')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => contactsApi.importCsv(file),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
toast.success(`Importati: ${res.created} nuovi, ${res.updated} aggiornati, ${res.skipped} saltati`)
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const toggleFavMutation = useMutation({
|
||||
mutationFn: ({ id, fav }: { id: string; fav: boolean }) =>
|
||||
contactsApi.update(id, { is_favorite: fav }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setFormEmail('')
|
||||
setFormName('')
|
||||
setFormOrg('')
|
||||
setFormNotes('')
|
||||
setFormFavorite(false)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (c: PecContactResponse) => {
|
||||
setEditing(c)
|
||||
setFormEmail(c.email)
|
||||
setFormName(c.name ?? '')
|
||||
setFormOrg(c.organization ?? '')
|
||||
setFormNotes(c.notes ?? '')
|
||||
setFormFavorite(c.is_favorite)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formEmail.trim()) return toast.error('L\'email e\' obbligatoria')
|
||||
const payload = {
|
||||
email: formEmail.trim(),
|
||||
name: formName.trim() || null,
|
||||
organization: formOrg.trim() || null,
|
||||
notes: formNotes.trim() || null,
|
||||
is_favorite: formFavorite,
|
||||
}
|
||||
if (editing) {
|
||||
updateMutation.mutate({ id: editing.id, data: payload })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
importMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Rubrica PEC</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total} contatti nella rubrica
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()} isLoading={importMutation.isPending}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Importa CSV
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileChange} />
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuovo contatto
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cerca per email, nome o organizzazione..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BookUser className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">Nessun contatto trovato</p>
|
||||
<p className="text-sm mt-1">
|
||||
Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Email PEC</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Nome</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Organizzazione</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Tipo</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Aggiornato</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs">{c.email}</td>
|
||||
<td className="px-4 py-3">{c.name ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{c.organization ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
{c.organization}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${c.auto_saved ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{c.auto_saved ? 'Automatico' : 'Manuale'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.updated_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => toggleFavMutation.mutate({ id: c.id, fav: !c.is_favorite })}
|
||||
title={c.is_favorite ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${c.is_favorite ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(c)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Modifica"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Eliminare il contatto ${c.email}?`)) deleteMutation.mutate(c.id)
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Elimina"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Modifica contatto' : 'Nuovo contatto'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Indirizzo PEC *</Label>
|
||||
<Input value={formEmail} onChange={(e) => setFormEmail(e.target.value)} placeholder="indirizzo@pec.it" disabled={!!editing} type="email" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nome</Label>
|
||||
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Mario Rossi" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Organizzazione</Label>
|
||||
<Input value={formOrg} onChange={(e) => setFormOrg(e.target.value)} placeholder="Comune di Roma" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Note</Label>
|
||||
<Input value={formNotes} onChange={(e) => setFormNotes(e.target.value)} placeholder="Note aggiuntive..." />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={formFavorite} onChange={(e) => setFormFavorite(e.target.checked)} className="rounded" />
|
||||
<span className="text-sm">Aggiungi ai preferiti</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={closeForm}>Annulla</Button>
|
||||
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
|
||||
{editing ? 'Salva' : 'Aggiungi'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, AlertTriangle, Clock, CheckCircle2, ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { deadlinesApi, type DeadlineMessageResponse } from '@/api/deadlines.api'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
function groupDeadlines(items: DeadlineMessageResponse[]) {
|
||||
const now = new Date()
|
||||
const todayEnd = new Date(now)
|
||||
todayEnd.setHours(23, 59, 59, 999)
|
||||
const weekEnd = new Date(now)
|
||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
const monthEnd = new Date(now)
|
||||
monthEnd.setDate(monthEnd.getDate() + 30)
|
||||
|
||||
const overdue: DeadlineMessageResponse[] = []
|
||||
const today: DeadlineMessageResponse[] = []
|
||||
const thisWeek: DeadlineMessageResponse[] = []
|
||||
const later: DeadlineMessageResponse[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.deadline_at) continue
|
||||
const d = new Date(item.deadline_at)
|
||||
if (d < now) {
|
||||
overdue.push(item)
|
||||
} else if (d <= todayEnd) {
|
||||
today.push(item)
|
||||
} else if (d <= weekEnd) {
|
||||
thisWeek.push(item)
|
||||
} else {
|
||||
later.push(item)
|
||||
}
|
||||
}
|
||||
return { overdue, today, thisWeek, later }
|
||||
}
|
||||
|
||||
function DeadlineItem({ item }: { item: DeadlineMessageResponse }) {
|
||||
const navigate = useNavigate()
|
||||
const deadlineDate = item.deadline_at ? new Date(item.deadline_at) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''}`}
|
||||
onClick={() => navigate(`/messages/${item.id}`)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{item.is_overdue ? (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Clock className="h-5 w-5 text-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{item.subject || '(nessun oggetto)'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`}
|
||||
</p>
|
||||
{item.deadline_note && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5">{item.deadline_note}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}>
|
||||
{deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'}
|
||||
</p>
|
||||
{item.is_overdue && (
|
||||
<p className="text-xs text-destructive">Scaduto</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeadlineGroup({ title, items, icon: Icon, color }: {
|
||||
title: string
|
||||
items: DeadlineMessageResponse[]
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
color: string
|
||||
}) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className={`flex items-center gap-2 ${color}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<h3 className="font-semibold text-sm">{title} ({items.length})</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<DeadlineItem key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeadlinesPage() {
|
||||
const [daysAhead, setDaysAhead] = useState(30)
|
||||
const [includeOverdue, setIncludeOverdue] = useState(true)
|
||||
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ['deadlines', daysAhead, includeOverdue],
|
||||
queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }),
|
||||
})
|
||||
|
||||
const groups = groupDeadlines(data)
|
||||
const total = data.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
Scadenzario
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total} messaggi con scadenze
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeOverdue}
|
||||
onChange={(e) => setIncludeOverdue(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Includi scaduti
|
||||
</label>
|
||||
<select
|
||||
value={daysAhead}
|
||||
onChange={(e) => setDaysAhead(Number(e.target.value))}
|
||||
className="text-sm border rounded-md px-3 py-1.5 bg-background"
|
||||
>
|
||||
<option value={7}>Prossimi 7 giorni</option>
|
||||
<option value={30}>Prossimi 30 giorni</option>
|
||||
<option value={90}>Prossimi 90 giorni</option>
|
||||
<option value={365}>Prossimo anno</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
|
||||
<p className="text-lg font-medium">Nessuna scadenza trovata</p>
|
||||
<p className="text-sm mt-1">
|
||||
Le scadenze si impostano dal dettaglio di ogni messaggio.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<DeadlineGroup
|
||||
title="Scaduti"
|
||||
items={groups.overdue}
|
||||
icon={AlertTriangle}
|
||||
color="text-destructive"
|
||||
/>
|
||||
<DeadlineGroup
|
||||
title="Oggi"
|
||||
items={groups.today}
|
||||
icon={Clock}
|
||||
color="text-amber-600"
|
||||
/>
|
||||
<DeadlineGroup
|
||||
title="Questa settimana"
|
||||
items={groups.thisWeek}
|
||||
icon={Calendar}
|
||||
color="text-blue-600"
|
||||
/>
|
||||
<DeadlineGroup
|
||||
title="Successivamente"
|
||||
items={groups.later}
|
||||
icon={Calendar}
|
||||
color="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* InboxPage – visualizza la posta in arrivo, inviata, preferiti o archiviata.
|
||||
* InboxPage – visualizza la posta in arrivo, inviata, preferiti, archiviata o cestino.
|
||||
*
|
||||
* Può operare in quattro modalità (viewMode):
|
||||
* - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false)
|
||||
* - 'sent' → Posta Inviata (solo outbound, is_archived=false)
|
||||
* - 'starred' → Preferiti (tutte le direzioni, is_starred=true)
|
||||
* - 'archived' → Archiviati (tutte le direzioni, is_archived=true)
|
||||
* Modalita' (viewMode):
|
||||
* - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false, is_trashed=false)
|
||||
* - 'sent' → Posta Inviata (solo outbound, is_archived=false, is_trashed=false)
|
||||
* - 'starred' → Preferiti (is_starred=true, is_trashed=false)
|
||||
* - 'archived' → Archiviati (is_archived=true, is_trashed=false)
|
||||
* - 'trash' → Cestino (is_trashed=true)
|
||||
*
|
||||
* Funzionalità:
|
||||
* Funzionalita':
|
||||
* - Selezione singola e multipla tramite checkbox
|
||||
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag)
|
||||
* - Pulsanti azione rapida su hover di ogni riga (stella, archivia)
|
||||
* - Badge tag colorati per ogni messaggio
|
||||
* - Barra azioni bulk (stella, archivia, cestino, segna da leggere, tag)
|
||||
* - Pulsanti azione rapida su hover di ogni riga
|
||||
* - "Segna come da leggere" e "Sposta nel cestino" solo fuori dalle Virtual Box
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@@ -32,6 +33,14 @@ import {
|
||||
Square,
|
||||
X,
|
||||
Tag,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
MailX,
|
||||
SlidersHorizontal,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
} from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -40,6 +49,7 @@ import { Input } from '@/components/ui/Input'
|
||||
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||
import { TagBadgeList } from '@/components/TagManager/TagBadge'
|
||||
import { TagSelector } from '@/components/TagManager/TagSelector'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { messagesApi } from '@/api/messages.api'
|
||||
import { labelsApi } from '@/api/labels.api'
|
||||
import { mailboxesApi } from '@/api/mailboxes.api'
|
||||
@@ -51,10 +61,9 @@ import { getErrorMessage } from '@/api/client'
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived'
|
||||
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' | 'conservation_pending' | 'conservation_archived'
|
||||
|
||||
interface InboxPageProps {
|
||||
/** Modalità vista */
|
||||
viewMode: InboxViewMode
|
||||
}
|
||||
|
||||
@@ -64,10 +73,15 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// mailboxId è presente solo nei percorsi /mailbox/:mailboxId/...
|
||||
// vboxId è presente solo nei percorsi /virtual-box/:vboxId/...
|
||||
const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>()
|
||||
|
||||
// true = stiamo navigando in una casella reale (non virtual box)
|
||||
const isMailboxMode = !vboxId
|
||||
|
||||
// ── Ruolo utente per visibilita' pulsante Conservazione ─────────────────────
|
||||
const { isAdmin, isSupervisor } = useAuth()
|
||||
const canConserve = isAdmin || isSupervisor
|
||||
|
||||
// ── Stato filtri locale ──────────────────────────────────────────────────────
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
@@ -76,23 +90,35 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
// ── Filtri avanzati ──────────────────────────────────────────────────────────
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [pecTypeFilter, setPecTypeFilter] = useState('')
|
||||
const [pecStateFilter, setPecStateFilter] = useState('')
|
||||
|
||||
const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter, pecStateFilter].filter(Boolean).length
|
||||
|
||||
// ── Stato selezione ─────────────────────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// ── Stato dialog tag bulk ────────────────────────────────────────────────────
|
||||
const [showTagSelector, setShowTagSelector] = useState(false)
|
||||
|
||||
// Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
|
||||
useEffect(() => {
|
||||
setSearchInput('')
|
||||
setDebouncedSearch('')
|
||||
setIsReadFilter(undefined)
|
||||
setIsStarredFilter(undefined)
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setPecTypeFilter('')
|
||||
setPecStateFilter('')
|
||||
setShowAdvancedFilters(false)
|
||||
setPage(1)
|
||||
setSelectedIds(new Set())
|
||||
}, [mailboxId, vboxId, viewMode])
|
||||
|
||||
// Debounce della ricerca
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchInput)
|
||||
@@ -112,7 +138,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
? mailboxesData?.items.find((m) => m.id === mailboxId)
|
||||
: undefined
|
||||
|
||||
// ── Virtual Box corrente (per breadcrumb) ────────────────────────────────────
|
||||
const { data: myVboxes = [] } = useQuery({
|
||||
queryKey: ['virtual-boxes', 'my'],
|
||||
queryFn: () => virtualBoxesApi.myVirtualBoxes(),
|
||||
@@ -124,41 +149,69 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
? myVboxes.find((v) => v.id === vboxId)
|
||||
: undefined
|
||||
|
||||
// ── Reset pagina per filtri avanzati ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [dateFrom, dateTo, pecTypeFilter])
|
||||
|
||||
// ── Query messaggi ───────────────────────────────────────────────────────────
|
||||
const queryFilters = (() => {
|
||||
const base = {
|
||||
const advancedBase = {
|
||||
vbox_id: vboxId,
|
||||
mailbox_id: mailboxId,
|
||||
search: debouncedSearch || undefined,
|
||||
pec_type: pecTypeFilter || undefined,
|
||||
state: pecStateFilter || undefined,
|
||||
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
|
||||
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
switch (viewMode) {
|
||||
case 'inbox':
|
||||
return {
|
||||
...base,
|
||||
...advancedBase,
|
||||
direction: 'inbound' as const,
|
||||
is_read: isReadFilter,
|
||||
is_starred: isStarredFilter,
|
||||
is_archived: false,
|
||||
is_trashed: false,
|
||||
}
|
||||
case 'sent':
|
||||
return {
|
||||
...base,
|
||||
...advancedBase,
|
||||
direction: 'outbound' as const,
|
||||
is_starred: isStarredFilter,
|
||||
is_archived: false,
|
||||
is_trashed: false,
|
||||
}
|
||||
case 'starred':
|
||||
return {
|
||||
...base,
|
||||
...advancedBase,
|
||||
is_starred: true,
|
||||
is_archived: false,
|
||||
is_trashed: false,
|
||||
}
|
||||
case 'archived':
|
||||
return {
|
||||
...base,
|
||||
...advancedBase,
|
||||
is_archived: true,
|
||||
is_trashed: false,
|
||||
}
|
||||
case 'trash':
|
||||
return {
|
||||
...advancedBase,
|
||||
is_trashed: true,
|
||||
}
|
||||
case 'conservation_pending':
|
||||
return {
|
||||
...advancedBase,
|
||||
is_pending_conservation: true,
|
||||
}
|
||||
case 'conservation_archived':
|
||||
return {
|
||||
...advancedBase,
|
||||
is_conserved: true,
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -178,7 +231,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
const total = messagesData?.total || 0
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
// ── Invalida query messaggi dopo operazioni ──────────────────────────────────
|
||||
const invalidateMessages = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
}, [queryClient])
|
||||
@@ -200,6 +252,25 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
},
|
||||
})
|
||||
|
||||
// ── Segna come DA leggere (unread) ───────────────────────────────────────────
|
||||
const markUnreadMutation = useMutation({
|
||||
mutationFn: (id: string) => messagesApi.markUnread(id),
|
||||
onSuccess: (updatedMsg) => {
|
||||
queryClient.setQueryData(
|
||||
['messages', queryFilters],
|
||||
(old: { items: MessageResponse[]; total: number } | undefined) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
|
||||
}
|
||||
},
|
||||
)
|
||||
toast.success('Messaggio segnato come da leggere')
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// ── Toggle stella singolo ────────────────────────────────────────────────────
|
||||
const toggleStarMutation = useMutation({
|
||||
mutationFn: ({ id, starred }: { id: string; starred: boolean }) =>
|
||||
@@ -209,7 +280,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
['messages', queryFilters],
|
||||
(old: { items: MessageResponse[]; total: number } | undefined) => {
|
||||
if (!old) return old
|
||||
// In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti
|
||||
if (viewMode === 'starred' && !updatedMsg.is_starred) {
|
||||
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
|
||||
}
|
||||
@@ -226,7 +296,6 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
mutationFn: ({ id, archived }: { id: string; archived: boolean }) =>
|
||||
archived ? messagesApi.archive(id) : messagesApi.unarchive(id),
|
||||
onSuccess: (updatedMsg, { archived }) => {
|
||||
// Rimuove il messaggio dalla lista corrente (ha cambiato "stanza")
|
||||
queryClient.setQueryData(
|
||||
['messages', queryFilters],
|
||||
(old: { items: MessageResponse[]; total: number } | undefined) => {
|
||||
@@ -240,6 +309,42 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// ── Cestino/Ripristina singolo ────────────────────────────────────────────────
|
||||
const trashMutation = useMutation({
|
||||
mutationFn: ({ id, trashed }: { id: string; trashed: boolean }) =>
|
||||
trashed ? messagesApi.trash(id) : messagesApi.untrash(id),
|
||||
onSuccess: (updatedMsg, { trashed }) => {
|
||||
queryClient.setQueryData(
|
||||
['messages', queryFilters],
|
||||
(old: { items: MessageResponse[]; total: number } | undefined) => {
|
||||
if (!old) return old
|
||||
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
|
||||
},
|
||||
)
|
||||
toast.success(trashed ? 'Messaggio spostato nel cestino' : 'Messaggio ripristinato dal cestino')
|
||||
invalidateMessages()
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// ── Conservazione/Rimozione singolo ───────────────────────────────────────────
|
||||
const conserveMutation = useMutation({
|
||||
mutationFn: ({ id, conserve }: { id: string; conserve: boolean }) =>
|
||||
conserve ? messagesApi.conserve(id) : messagesApi.unconserve(id),
|
||||
onSuccess: (updatedMsg, { conserve }) => {
|
||||
queryClient.setQueryData(
|
||||
['messages', queryFilters],
|
||||
(old: { items: MessageResponse[]; total: number } | undefined) => {
|
||||
if (!old) return old
|
||||
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
|
||||
},
|
||||
)
|
||||
toast.success(conserve ? 'Messaggio inviato in Da Conservare' : 'Messaggio rimosso da Da Conservare')
|
||||
invalidateMessages()
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// ── Azioni bulk ──────────────────────────────────────────────────────────────
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: messagesApi.bulkUpdate,
|
||||
@@ -247,10 +352,15 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
invalidateMessages()
|
||||
setSelectedIds(new Set())
|
||||
const n = result.updated
|
||||
if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`)
|
||||
if (payload.is_read === false) toast.success(`${n} ${n === 1 ? 'messaggio segnato' : 'messaggi segnati'} come da leggere`)
|
||||
else if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`)
|
||||
else if (payload.is_starred === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} dai preferiti`)
|
||||
else if (payload.is_archived === true) toast.success(`${n} ${n === 1 ? 'messaggio archiviato' : 'messaggi archiviati'}`)
|
||||
else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`)
|
||||
else if (payload.is_trashed === true) toast.success(`${n} ${n === 1 ? 'messaggio spostato nel cestino' : 'messaggi spostati nel cestino'}`)
|
||||
else if (payload.is_trashed === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato dal cestino' : 'messaggi ripristinati dal cestino'}`)
|
||||
else if (payload.is_pending_conservation === true) toast.success(`${n} ${n === 1 ? 'messaggio inviato' : 'messaggi inviati'} in Da Conservare`)
|
||||
else if (payload.is_pending_conservation === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} da Da Conservare`)
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
@@ -280,6 +390,8 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleBulkMarkUnread = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_read: false })
|
||||
const handleBulkStar = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
|
||||
const handleBulkUnstar = () =>
|
||||
@@ -288,6 +400,14 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true })
|
||||
const handleBulkUnarchive = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false })
|
||||
const handleBulkTrash = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true })
|
||||
const handleBulkUntrash = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false })
|
||||
const handleBulkConserve = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: true })
|
||||
const handleBulkUnconserve = () =>
|
||||
bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: false })
|
||||
|
||||
// ── Selezione ────────────────────────────────────────────────────────────────
|
||||
const handleToggleSelect = (id: string, e: React.MouseEvent) => {
|
||||
@@ -330,21 +450,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
// ── Label e icone folder ────────────────────────────────────────────────────
|
||||
const isInbound = viewMode === 'inbox'
|
||||
const folderLabel =
|
||||
viewMode === 'inbox'
|
||||
? 'Posta in Arrivo'
|
||||
: viewMode === 'sent'
|
||||
? 'Posta Inviata'
|
||||
: viewMode === 'starred'
|
||||
? 'Preferiti'
|
||||
: 'Archiviati'
|
||||
viewMode === 'inbox' ? 'Posta in Arrivo'
|
||||
: viewMode === 'sent' ? 'Posta Inviata'
|
||||
: viewMode === 'starred' ? 'Preferiti'
|
||||
: viewMode === 'archived' ? 'Archiviati'
|
||||
: viewMode === 'trash' ? 'Cestino'
|
||||
: viewMode === 'conservation_pending' ? 'Da Conservare'
|
||||
: 'Storico Conservazione'
|
||||
const FolderIcon =
|
||||
viewMode === 'inbox'
|
||||
? Inbox
|
||||
: viewMode === 'sent'
|
||||
? Send
|
||||
: viewMode === 'starred'
|
||||
? Star
|
||||
: Archive
|
||||
viewMode === 'inbox' ? Inbox
|
||||
: viewMode === 'sent' ? Send
|
||||
: viewMode === 'starred' ? Star
|
||||
: viewMode === 'archived' ? Archive
|
||||
: viewMode === 'trash' ? Trash2
|
||||
: ShieldCheck
|
||||
|
||||
const selectedCount = selectedIds.size
|
||||
const allSelected = messages.length > 0 && selectedCount === messages.length
|
||||
@@ -396,10 +515,12 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Aggiorna
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => navigate('/compose')}>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Nuova PEC
|
||||
</Button>
|
||||
{viewMode !== 'trash' && (
|
||||
<Button size="sm" onClick={() => navigate('/compose')}>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Nuova PEC
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -440,13 +561,107 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
Preferiti
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Pulsante filtri avanzati */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-9 text-xs', activeAdvancedFiltersCount > 0 && 'border-primary text-primary')}
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5 mr-1" />
|
||||
Filtri
|
||||
{activeAdvancedFiltersCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 w-4 rounded-full bg-primary text-white text-[10px] font-bold">
|
||||
{activeAdvancedFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
{showAdvancedFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Pannello filtri avanzati ── */}
|
||||
{showAdvancedFilters && (
|
||||
<div className="mt-3 bg-muted/40 rounded-lg border p-3 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{/* Data da */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data da</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data a */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data a</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tipo PEC */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Tipo PEC</label>
|
||||
<select
|
||||
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={pecTypeFilter}
|
||||
onChange={(e) => setPecTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">Tutti i tipi</option>
|
||||
<option value="posta_certificata">Posta certificata</option>
|
||||
<option value="accettazione">Accettazione</option>
|
||||
<option value="avvenuta_consegna">Avvenuta consegna</option>
|
||||
<option value="mancata_consegna">Mancata consegna</option>
|
||||
<option value="non_accettazione">Non accettazione</option>
|
||||
<option value="errore_consegna">Errore consegna</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stato PEC */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Stato PEC</label>
|
||||
<select
|
||||
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={pecStateFilter}
|
||||
onChange={(e) => setPecStateFilter(e.target.value)}
|
||||
>
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="received">Ricevuto</option>
|
||||
<option value="sent">Inviato</option>
|
||||
<option value="accepted">Accettato</option>
|
||||
<option value="delivered">Consegnato</option>
|
||||
<option value="anomaly">Anomalia</option>
|
||||
<option value="failed">Fallito</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reset filtri */}
|
||||
{activeAdvancedFiltersCount > 0 && (
|
||||
<div className="flex items-end col-span-2 md:col-span-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs text-muted-foreground w-full"
|
||||
onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter(''); setPecStateFilter('') }}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Azzera filtri
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */}
|
||||
{/* ── Barra azioni bulk ── */}
|
||||
{someSelected && (
|
||||
<div className="border-b bg-blue-50 dark:bg-blue-950/30 px-6 py-2.5 flex items-center gap-3 flex-wrap">
|
||||
{/* Contatore + deseleziona */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
@@ -462,8 +677,22 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
|
||||
<div className="h-4 w-px bg-blue-200 dark:bg-blue-700" />
|
||||
|
||||
{/* Azioni: variano in base alla vista */}
|
||||
{viewMode !== 'starred' && (
|
||||
{/* Segna come da leggere (solo in modalita' casella, non vbox) */}
|
||||
{isMailboxMode && viewMode !== 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
|
||||
onClick={handleBulkMarkUnread}
|
||||
isLoading={bulkMutation.isPending}
|
||||
>
|
||||
<MailX className="h-3.5 w-3.5 mr-1" />
|
||||
Segna da leggere
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Stella */}
|
||||
{viewMode !== 'starred' && viewMode !== 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -502,7 +731,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{viewMode !== 'archived' && (
|
||||
{viewMode !== 'archived' && viewMode !== 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -528,16 +757,73 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Assegna tag bulk */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
|
||||
onClick={() => setShowTagSelector(true)}
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
|
||||
Tag
|
||||
</Button>
|
||||
{/* Cestino / Ripristina cestino (solo modalita' casella) */}
|
||||
{isMailboxMode && viewMode !== 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-red-200 hover:bg-red-50 dark:border-red-800 text-red-600"
|
||||
onClick={handleBulkTrash}
|
||||
isLoading={bulkMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Cestino
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMailboxMode && viewMode === 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
|
||||
onClick={handleBulkUntrash}
|
||||
isLoading={bulkMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
Ripristina dal cestino
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Tag */}
|
||||
{viewMode !== 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
|
||||
onClick={() => setShowTagSelector(true)}
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 mr-1 text-primary" />
|
||||
Tag
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */}
|
||||
{canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-teal-200 hover:bg-teal-50 dark:border-teal-800 text-teal-700"
|
||||
onClick={handleBulkConserve}
|
||||
isLoading={bulkMutation.isPending}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5 mr-1" />
|
||||
Invia a Conservazione
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Rimuovi da Da Conservare */}
|
||||
{canConserve && viewMode === 'conservation_pending' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs border-orange-200 hover:bg-orange-50 dark:border-orange-800 text-orange-700"
|
||||
onClick={handleBulkUnconserve}
|
||||
isLoading={bulkMutation.isPending}
|
||||
>
|
||||
<ShieldX className="h-3.5 w-3.5 mr-1" />
|
||||
Rimuovi da Da Conservare
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -558,17 +844,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
{debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined
|
||||
? 'Prova a modificare i filtri di ricerca'
|
||||
: viewMode === 'inbox'
|
||||
? 'La posta in arrivo è vuota'
|
||||
? 'La posta in arrivo e vuota'
|
||||
: viewMode === 'sent'
|
||||
? 'Nessun messaggio inviato'
|
||||
: viewMode === 'starred'
|
||||
? 'Nessun messaggio nei preferiti'
|
||||
: 'Nessun messaggio archiviato'}
|
||||
: viewMode === 'archived'
|
||||
? 'Nessun messaggio archiviato'
|
||||
: 'Il cestino e vuoto'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{/* Riga "seleziona tutto" */}
|
||||
{messages.length > 0 && (
|
||||
<div className="flex items-center gap-3 px-6 py-2 bg-muted/20 border-b">
|
||||
<button
|
||||
@@ -594,6 +881,8 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
key={message.id}
|
||||
message={message}
|
||||
viewMode={viewMode}
|
||||
isMailboxMode={isMailboxMode}
|
||||
canConserve={canConserve}
|
||||
isSelected={selectedIds.has(message.id)}
|
||||
onSelect={(e) => handleToggleSelect(message.id, e)}
|
||||
onClick={() => handleMessageClick(message)}
|
||||
@@ -605,6 +894,18 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
e.stopPropagation()
|
||||
archiveMutation.mutate({ id: message.id, archived: !message.is_archived })
|
||||
}}
|
||||
onMarkUnread={(e) => {
|
||||
e.stopPropagation()
|
||||
markUnreadMutation.mutate(message.id)
|
||||
}}
|
||||
onToggleTrash={(e) => {
|
||||
e.stopPropagation()
|
||||
trashMutation.mutate({ id: message.id, trashed: !message.is_trashed })
|
||||
}}
|
||||
onToggleConserve={(e) => {
|
||||
e.stopPropagation()
|
||||
conserveMutation.mutate({ id: message.id, conserve: !message.is_pending_conservation })
|
||||
}}
|
||||
mailboxName={
|
||||
!mailboxId
|
||||
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
|
||||
@@ -663,23 +964,32 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
||||
interface MessageRowProps {
|
||||
message: MessageResponse
|
||||
viewMode: InboxViewMode
|
||||
isMailboxMode: boolean
|
||||
isSelected: boolean
|
||||
canConserve: boolean
|
||||
onSelect: (e: React.MouseEvent) => void
|
||||
onClick: () => void
|
||||
onToggleStar: (e: React.MouseEvent) => void
|
||||
onToggleArchive: (e: React.MouseEvent) => void
|
||||
/** Presente solo nella vista globale – mostra la casella di appartenenza */
|
||||
onMarkUnread: (e: React.MouseEvent) => void
|
||||
onToggleTrash: (e: React.MouseEvent) => void
|
||||
onToggleConserve: (e: React.MouseEvent) => void
|
||||
mailboxName?: string
|
||||
}
|
||||
|
||||
function MessageRow({
|
||||
message,
|
||||
viewMode,
|
||||
isMailboxMode,
|
||||
isSelected,
|
||||
canConserve,
|
||||
onSelect,
|
||||
onClick,
|
||||
onToggleStar,
|
||||
onToggleArchive,
|
||||
onMarkUnread,
|
||||
onToggleTrash,
|
||||
onToggleConserve,
|
||||
mailboxName,
|
||||
}: MessageRowProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
@@ -770,7 +1080,6 @@ function MessageRow({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tag badges */}
|
||||
{message.labels && message.labels.length > 0 && (
|
||||
<div className="mt-1" onClick={(e) => e.stopPropagation()}>
|
||||
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
|
||||
@@ -784,9 +1093,9 @@ function MessageRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */}
|
||||
{/* ── Azioni rapide (visibili su hover) ── */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Pulsante stella (rapido, su hover o se stellata) */}
|
||||
{/* Stella */}
|
||||
<button
|
||||
onClick={onToggleStar}
|
||||
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
|
||||
@@ -807,21 +1116,82 @@ function MessageRow({
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Pulsante archivia/ripristina (rapido, su hover) */}
|
||||
<button
|
||||
onClick={onToggleArchive}
|
||||
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-all',
|
||||
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{viewMode === 'archived' ? (
|
||||
<ArchiveX className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */}
|
||||
{isMailboxMode && message.is_read && viewMode !== 'trash' && (
|
||||
<button
|
||||
onClick={onMarkUnread}
|
||||
title="Segna come da leggere"
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-all',
|
||||
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<MailX className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Archivia/Ripristina (non nel cestino) */}
|
||||
{viewMode !== 'trash' && (
|
||||
<button
|
||||
onClick={onToggleArchive}
|
||||
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-all',
|
||||
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{viewMode === 'archived' ? (
|
||||
<ArchiveX className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cestino / Ripristina dal cestino (solo in modalita' casella) */}
|
||||
{isMailboxMode && (
|
||||
<button
|
||||
onClick={onToggleTrash}
|
||||
title={viewMode === 'trash' ? 'Ripristina dal cestino' : 'Sposta nel cestino'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-all',
|
||||
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{viewMode === 'trash' ? (
|
||||
<RotateCcw className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Invia a Conservazione / Rimuovi da Da Conservare */}
|
||||
{canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && (
|
||||
<button
|
||||
onClick={onToggleConserve}
|
||||
title={viewMode === 'conservation_pending' ? 'Rimuovi da Da Conservare' : 'Invia a Conservazione'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-all',
|
||||
message.is_pending_conservation
|
||||
? 'opacity-100'
|
||||
: hovered
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
{viewMode === 'conservation_pending' ? (
|
||||
<ShieldX className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<ShieldCheck
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
message.is_pending_conservation ? 'text-teal-600' : 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Indicatore allegati */}
|
||||
{message.has_attachments && (
|
||||
|
||||
@@ -27,6 +27,7 @@ import { mailboxesApi } from '@/api/mailboxes.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { formatDate, MAILBOX_STATUS_LABELS } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import type { MailboxCreateRequest, MailboxResponse } from '@/types/api.types'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
@@ -45,10 +46,12 @@ const STATUS_ICONS = {
|
||||
|
||||
export function MailboxesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { isAdmin } = useAuth()
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [editingMailbox, setEditingMailbox] = useState<MailboxResponse | null>(null)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null)
|
||||
|
||||
const { data: mailboxesData, isLoading } = useQuery({
|
||||
queryKey: ['mailboxes'],
|
||||
@@ -78,6 +81,23 @@ export function MailboxesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleForceSync = async (mailbox: MailboxResponse) => {
|
||||
setSyncingId(mailbox.id)
|
||||
try {
|
||||
const result = await mailboxesApi.forceSync(mailbox.id)
|
||||
if (result.status === 'enqueued') {
|
||||
toast.success(result.message)
|
||||
queryClient.invalidateQueries({ queryKey: ['mailboxes'] })
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(getErrorMessage(e))
|
||||
} finally {
|
||||
setSyncingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (mailbox: MailboxResponse) => {
|
||||
if (!confirm(`Eliminare la casella ${mailbox.email_address}? L'operazione è irreversibile.`)) return
|
||||
deleteMutation.mutate(mailbox.id)
|
||||
@@ -94,10 +114,12 @@ export function MailboxesPage() {
|
||||
<h1 className="text-xl font-semibold">Caselle PEC</h1>
|
||||
<span className="text-sm text-muted-foreground">({mailboxes.length})</span>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Aggiungi casella
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Aggiungi casella
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenuto */}
|
||||
@@ -111,12 +133,14 @@ export function MailboxesPage() {
|
||||
<MailCheck className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground font-medium">Nessuna casella PEC configurata</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1">
|
||||
Aggiungi una casella per iniziare a gestire le PEC
|
||||
{isAdmin ? 'Aggiungi una casella per iniziare a gestire le PEC' : 'Nessuna casella disponibile'}
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Aggiungi casella
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Aggiungi casella
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
@@ -168,38 +192,53 @@ export function MailboxesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Test connessione */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTest(mailbox)}
|
||||
isLoading={testingId === mailbox.id}
|
||||
title="Testa connessione IMAP"
|
||||
>
|
||||
<TestTube className="h-4 w-4 mr-1" />
|
||||
Test
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Test connessione */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTest(mailbox)}
|
||||
isLoading={testingId === mailbox.id}
|
||||
title="Testa connessione IMAP"
|
||||
>
|
||||
<TestTube className="h-4 w-4 mr-1" />
|
||||
Test
|
||||
</Button>
|
||||
|
||||
{/* Modifica */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingMailbox(mailbox)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Forza sincronizzazione */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleForceSync(mailbox)}
|
||||
isLoading={syncingId === mailbox.id}
|
||||
title="Forza sincronizzazione IMAP immediata"
|
||||
disabled={mailbox.status === 'deleted'}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Sync
|
||||
</Button>
|
||||
|
||||
{/* Elimina */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(mailbox)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Modifica */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingMailbox(mailbox)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Elimina */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(mailbox)}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -8,29 +8,158 @@ import {
|
||||
ArchiveX,
|
||||
Download,
|
||||
Reply,
|
||||
Forward,
|
||||
Paperclip,
|
||||
Mail,
|
||||
Send,
|
||||
Tag,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
MailX,
|
||||
PackageOpen,
|
||||
Printer,
|
||||
Calendar,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
|
||||
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
|
||||
import { TagBadge } from '@/components/TagManager/TagBadge'
|
||||
import { TagSelector } from '@/components/TagManager/TagSelector'
|
||||
import { messagesApi } from '@/api/messages.api'
|
||||
import { labelsApi } from '@/api/labels.api'
|
||||
import { deadlinesApi } from '@/api/deadlines.api'
|
||||
import { formatDate, formatBytes } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import apiClient from '@/api/client'
|
||||
|
||||
// ─── Thread section (Feature 3) ──────────────────────────────────────────────
|
||||
|
||||
function ThreadSection({ messageId, currentId, navigate }: { messageId: string; currentId: string; navigate: (to: string) => void }) {
|
||||
const { data: thread = [] } = useQuery({
|
||||
queryKey: ['thread', messageId],
|
||||
queryFn: () => messagesApi.getThread(messageId),
|
||||
})
|
||||
|
||||
// Mostra solo se ci sono piu' di 1 messaggio nel thread
|
||||
if (thread.length <= 1) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Conversazione ({thread.length} messaggi)
|
||||
</h3>
|
||||
<div className="space-y-1.5 rounded-lg border bg-background p-3">
|
||||
{thread.map((msg) => {
|
||||
const isCurrent = msg.id === currentId
|
||||
return (
|
||||
<button
|
||||
key={msg.id}
|
||||
type="button"
|
||||
onClick={() => !isCurrent && navigate(`/messages/${msg.id}`)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
|
||||
isCurrent
|
||||
? 'bg-primary/10 border border-primary/30 cursor-default'
|
||||
: 'hover:bg-muted/60 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${msg.direction === 'inbound' ? 'bg-blue-500' : 'bg-green-500'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm truncate ${isCurrent ? 'font-semibold' : 'font-medium'}`}>
|
||||
{msg.subject || '(nessun oggetto)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{msg.direction === 'inbound' ? msg.from_address : `A: ${(msg.to_addresses || []).join(', ')}`}
|
||||
{' · '}
|
||||
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{isCurrent && <span className="text-xs text-primary flex-shrink-0">Questo</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Attachment preview modal ─────────────────────────────────────────────────
|
||||
|
||||
interface AttachmentPreviewProps {
|
||||
messageId: string
|
||||
attachmentId: string
|
||||
filename: string
|
||||
contentType: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['attachment-preview', messageId, attachmentId],
|
||||
queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div className="relative bg-background rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<span className="font-medium truncate">{filename}</span>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2 min-h-[400px] flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
)}
|
||||
{!isLoading && data && (
|
||||
<>
|
||||
{data.previewable && data.url ? (
|
||||
<>
|
||||
{contentType.startsWith('image/') ? (
|
||||
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
|
||||
) : contentType === 'application/pdf' ? (
|
||||
<iframe src={data.url} className="w-full h-[70vh]" title={filename} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>Anteprima non disponibile per questo tipo di file.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina principale ────────────────────────────────────────────────────────
|
||||
|
||||
export function MessageDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Dialog tag
|
||||
const [showTagSelector, setShowTagSelector] = useState(false)
|
||||
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
|
||||
const [isPrinting, setIsPrinting] = useState(false)
|
||||
|
||||
// Feature 4: Deadline
|
||||
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
|
||||
const [deadlineDate, setDeadlineDate] = useState('')
|
||||
const [deadlineNote, setDeadlineNote] = useState('')
|
||||
|
||||
// Feature 7: Preview allegati
|
||||
const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
|
||||
|
||||
// Carica messaggio
|
||||
const {
|
||||
@@ -62,7 +191,6 @@ export function MessageDetailPage() {
|
||||
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(['message', id], updated)
|
||||
// Invalida le query messaggi per aggiornare le viste Preferiti
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
|
||||
},
|
||||
@@ -80,15 +208,6 @@ export function MessageDetailPage() {
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// Download allegato autenticato
|
||||
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
|
||||
try {
|
||||
await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Ripristina dall'archivio
|
||||
const unarchiveMutation = useMutation({
|
||||
mutationFn: () => messagesApi.unarchive(id!),
|
||||
@@ -100,17 +219,73 @@ export function MessageDetailPage() {
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// Segna come da leggere
|
||||
const markUnreadMutation = useMutation({
|
||||
mutationFn: () => messagesApi.markUnread(id!),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(['message', id], updated)
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
toast.success('Messaggio segnato come da leggere')
|
||||
navigate(-1)
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// Sposta nel cestino
|
||||
const trashMutation = useMutation({
|
||||
mutationFn: () => messagesApi.trash(id!),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(['message', id], updated)
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
toast.success('Messaggio spostato nel cestino')
|
||||
navigate(-1)
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// Ripristina dal cestino
|
||||
const untrashMutation = useMutation({
|
||||
mutationFn: () => messagesApi.untrash(id!),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(['message', id], updated)
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
toast.success('Messaggio ripristinato dal cestino')
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// Download allegato autenticato
|
||||
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
|
||||
try {
|
||||
await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
// Download pacchetto completo ZIP
|
||||
const handleDownloadPackage = async () => {
|
||||
if (!message) return
|
||||
setIsDownloadingPackage(true)
|
||||
try {
|
||||
await messagesApi.downloadPackage(message.id, message.subject)
|
||||
toast.success('Pacchetto scaricato')
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
} finally {
|
||||
setIsDownloadingPackage(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Imposta tag del messaggio
|
||||
const setLabelsMutation = useMutation({
|
||||
mutationFn: (labelIds: string[]) =>
|
||||
labelsApi.setMessageLabels(id!, { label_ids: labelIds }),
|
||||
onSuccess: (updatedLabels) => {
|
||||
// Aggiorna la cache del messaggio con i nuovi label
|
||||
queryClient.setQueryData(['message', id], (old: typeof message) => {
|
||||
if (!old) return old
|
||||
return { ...old, labels: updatedLabels }
|
||||
})
|
||||
// Invalida la lista messaggi per aggiornare i badge nella inbox
|
||||
queryClient.invalidateQueries({ queryKey: ['messages'] })
|
||||
setShowTagSelector(false)
|
||||
toast.success('Tag aggiornati')
|
||||
@@ -118,7 +293,7 @@ export function MessageDetailPage() {
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
})
|
||||
|
||||
// Rimuove singolo tag (click su × nel badge)
|
||||
// Rimuove singolo tag
|
||||
const removeLabelMutation = useMutation({
|
||||
mutationFn: (labelId: string) =>
|
||||
labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }),
|
||||
@@ -197,8 +372,21 @@ export function MessageDetailPage() {
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Archivia (se non ancora archiviato) */}
|
||||
{!message.is_archived && (
|
||||
{/* Segna come da leggere (solo se gia' letto) */}
|
||||
{message.is_read && !message.is_trashed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => markUnreadMutation.mutate()}
|
||||
title="Segna come da leggere"
|
||||
isLoading={markUnreadMutation.isPending}
|
||||
>
|
||||
<MailX className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Archivia (se non ancora archiviato e non nel cestino) */}
|
||||
{!message.is_archived && !message.is_trashed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -211,7 +399,7 @@ export function MessageDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Ripristina dall'archivio (se archiviato) */}
|
||||
{message.is_archived && (
|
||||
{message.is_archived && !message.is_trashed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -224,8 +412,35 @@ export function MessageDetailPage() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Rispondi (solo per messaggi inbound PEC certificata) */}
|
||||
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
|
||||
{/* Sposta nel cestino (se non gia' nel cestino) */}
|
||||
{!message.is_trashed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => trashMutation.mutate()}
|
||||
title="Sposta nel cestino"
|
||||
isLoading={trashMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Ripristina dal cestino (se nel cestino) */}
|
||||
{message.is_trashed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => untrashMutation.mutate()}
|
||||
title="Ripristina dal cestino"
|
||||
isLoading={untrashMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Ripristina
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */}
|
||||
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -239,11 +454,109 @@ export function MessageDetailPage() {
|
||||
Rispondi
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
|
||||
{message.pec_type === 'posta_certificata' && !message.is_trashed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
navigate('/compose', {
|
||||
state: { forwardOf: message },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Forward className="h-4 w-4 mr-1" />
|
||||
Inoltra
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Scarica pacchetto completo ZIP (sempre visibile) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPackage}
|
||||
disabled={isDownloadingPackage}
|
||||
title="Scarica pacchetto completo (ZIP con tutti i file originali)"
|
||||
>
|
||||
{isDownloadingPackage ? (
|
||||
<div className="h-4 w-4 mr-1 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Scarica
|
||||
</Button>
|
||||
|
||||
{/* Stampa (Feature 8) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isPrinting}
|
||||
onClick={async () => {
|
||||
if (!message) return
|
||||
setIsPrinting(true)
|
||||
try {
|
||||
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
|
||||
const html = await response.data.text()
|
||||
const w = window.open('', '_blank')
|
||||
if (w) {
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
setTimeout(() => w.print(), 500)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Errore apertura stampa')
|
||||
} finally {
|
||||
setIsPrinting(false)
|
||||
}
|
||||
}}
|
||||
title="Stampa / Salva come PDF"
|
||||
>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
Stampa
|
||||
</Button>
|
||||
|
||||
{/* Scadenza (Feature 4) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const dl = (message as any).deadline_at
|
||||
setDeadlineDate(dl ? dl.substring(0, 16) : '')
|
||||
setDeadlineNote((message as any).deadline_note ?? '')
|
||||
setShowDeadlineForm(true)
|
||||
}}
|
||||
title="Imposta scadenza"
|
||||
className={(message as any).deadline_at ? 'border-amber-400 text-amber-600' : ''}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
{(message as any).deadline_at ? 'Scadenza' : 'Scadenza'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner "Nel Cestino" */}
|
||||
{message.is_trashed && (
|
||||
<div className="bg-red-50 border-b border-red-200 px-6 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-red-700">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Questo messaggio si trova nel cestino.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs border-red-300 hover:bg-red-100 text-red-700"
|
||||
onClick={() => untrashMutation.mutate()}
|
||||
isLoading={untrashMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
Ripristina
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner "Archiviato" */}
|
||||
{message.is_archived && (
|
||||
{message.is_archived && !message.is_trashed && (
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700">
|
||||
<Archive className="h-4 w-4" />
|
||||
@@ -379,25 +692,39 @@ export function MessageDetailPage() {
|
||||
Allegati ({attachments.length})
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{attachments.map((att) => (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => handleDownloadAttachment(att)}
|
||||
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group text-left w-full"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Paperclip className="h-5 w-5 text-primary" />
|
||||
{attachments.map((att) => {
|
||||
const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
|
||||
return (
|
||||
<div key={att.id} className="flex items-center gap-2 rounded-lg border bg-background p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownloadAttachment(att)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left hover:opacity-80"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Paperclip className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{att.filename}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Download className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</button>
|
||||
{isPreviewable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewAtt({ id: att.id, filename: att.filename, contentType: att.content_type || '' })}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary flex-shrink-0"
|
||||
title="Anteprima"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{att.filename}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -415,13 +742,48 @@ export function MessageDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scadenza (Feature 4) */}
|
||||
{(message as any).deadline_at && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
<strong>Scadenza:</strong> {formatDate((message as any).deadline_at)}
|
||||
</span>
|
||||
{(message as any).deadline_note && (
|
||||
<span className="text-amber-600 italic">— {(message as any).deadline_note}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeadlineDate('')
|
||||
setDeadlineNote('')
|
||||
deadlinesApi.setDeadline(message.id, { deadline_at: null }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['message', id] })
|
||||
toast.success('Scadenza rimossa')
|
||||
})
|
||||
}}
|
||||
className="text-xs text-amber-600 hover:text-amber-800"
|
||||
>
|
||||
Rimuovi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread (Feature 3) */}
|
||||
{message.pec_type === 'posta_certificata' && (
|
||||
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
|
||||
)}
|
||||
|
||||
{/* Messaggio originale per ricevute */}
|
||||
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-blue-700">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>
|
||||
Questo è un messaggio automatico di tipo{' '}
|
||||
Questo e un messaggio automatico di tipo{' '}
|
||||
<strong>
|
||||
<PecTypeBadge type={message.pec_type} />
|
||||
</strong>
|
||||
@@ -432,6 +794,63 @@ export function MessageDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modali */}
|
||||
|
||||
{/* Deadline form (Feature 4) */}
|
||||
{showDeadlineForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-xl shadow-2xl p-6 w-96 space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-amber-500" />
|
||||
Imposta scadenza
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>Data e ora scadenza</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={deadlineDate}
|
||||
onChange={e => setDeadlineDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nota (opzionale)</Label>
|
||||
<Input
|
||||
value={deadlineNote}
|
||||
onChange={e => setDeadlineNote(e.target.value)}
|
||||
placeholder="Es. Termine per impugnare, entro 30 giorni..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" onClick={() => setShowDeadlineForm(false)}>Annulla</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await deadlinesApi.setDeadline(message.id, {
|
||||
deadline_at: deadlineDate ? new Date(deadlineDate).toISOString() : null,
|
||||
deadline_note: deadlineNote || null,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['message', id] })
|
||||
toast.success(deadlineDate ? 'Scadenza impostata' : 'Scadenza rimossa')
|
||||
setShowDeadlineForm(false)
|
||||
}}
|
||||
>
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment preview (Feature 7) */}
|
||||
{previewAtt && (
|
||||
<AttachmentPreviewModal
|
||||
messageId={message.id}
|
||||
attachmentId={previewAtt.id}
|
||||
filename={previewAtt.filename}
|
||||
contentType={previewAtt.contentType}
|
||||
onClose={() => setPreviewAtt(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dialog gestione tag */}
|
||||
{showTagSelector && (
|
||||
<TagSelector
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* ReportsPage – Dashboard e Reportistica (Fase 7).
|
||||
*
|
||||
* Visualizza:
|
||||
* - 6 KPI cards (ricevute oggi, inviate oggi, anomalie, tasso consegna,
|
||||
* caselle in errore, messaggi non letti)
|
||||
* - Selettore periodo (7 / 30 giorni)
|
||||
* - Grafico a barre: PEC ricevute vs inviate per giorno
|
||||
* - Grafico a torta: distribuzione stati messaggi outbound
|
||||
* - Tabella: dettaglio per casella
|
||||
* - Pulsanti export CSV e PDF
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Inbox,
|
||||
Send,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ServerCrash,
|
||||
MailOpen,
|
||||
Download,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
BarChart2,
|
||||
} from 'lucide-react'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { it } from 'date-fns/locale'
|
||||
import { reportsApi } from '@/api/reports.api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Costanti ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ label: '7 giorni', value: 7 },
|
||||
{ label: '30 giorni', value: 30 },
|
||||
{ label: '90 giorni', value: 90 },
|
||||
]
|
||||
|
||||
const STATE_COLORS: Record<string, string> = {
|
||||
delivered: '#22c55e',
|
||||
accepted: '#3b82f6',
|
||||
sent: '#8b5cf6',
|
||||
anomaly: '#ef4444',
|
||||
failed: '#dc2626',
|
||||
queued: '#f59e0b',
|
||||
draft: '#6b7280',
|
||||
unknown: '#9ca3af',
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
delivered: 'Consegnata',
|
||||
accepted: 'Accettata',
|
||||
sent: 'Inviata',
|
||||
anomaly: 'Anomalia',
|
||||
failed: 'Fallita',
|
||||
queued: 'In coda',
|
||||
draft: 'Bozza',
|
||||
unknown: 'Sconosciuto',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-800',
|
||||
paused: 'bg-yellow-100 text-yellow-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
deleted: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
// ─── Componente KPI card ──────────────────────────────────────────────────────
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
alert?: boolean
|
||||
}
|
||||
|
||||
function KpiCard({ title, value, subtitle, icon, color, alert }: KpiCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-xl border p-5 flex items-start gap-4',
|
||||
alert && 'border-red-300 bg-red-50',
|
||||
)}
|
||||
>
|
||||
<div className={cn('p-2.5 rounded-lg flex-shrink-0', color)}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-500 font-medium truncate">{title}</p>
|
||||
<p className={cn('text-2xl font-bold mt-0.5', alert ? 'text-red-700' : 'text-gray-900')}>
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tooltip grafico barre ────────────────────────────────────────────────────
|
||||
|
||||
function CustomBarTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
|
||||
<p className="font-semibold text-gray-700 mb-1">{label}</p>
|
||||
{payload.map((p: any) => (
|
||||
<p key={p.dataKey} style={{ color: p.fill }}>
|
||||
{p.name}: <strong>{p.value}</strong>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tooltip grafico torta ────────────────────────────────────────────────────
|
||||
|
||||
function CustomPieTooltip({ active, payload }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
const item = payload[0]
|
||||
return (
|
||||
<div className="bg-white border rounded-lg shadow-lg p-3 text-sm">
|
||||
<p className="font-semibold" style={{ color: item.payload.fill }}>
|
||||
{STATE_LABELS[item.name] ?? item.name}
|
||||
</p>
|
||||
<p className="text-gray-700">Totale: <strong>{item.value}</strong></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pagina principale ────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportsPage() {
|
||||
const [days, setDays] = useState(7)
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
queryKey: ['reports-summary', days],
|
||||
queryFn: () => reportsApi.getSummary(days),
|
||||
staleTime: 2 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
// ── Formatta le date per l'asse X del grafico ────────────────────────────
|
||||
const chartData = (data?.daily_stats ?? []).map((s) => ({
|
||||
giorno: format(parseISO(s.day), 'dd/MM', { locale: it }),
|
||||
Ricevute: s.received,
|
||||
Inviate: s.sent,
|
||||
}))
|
||||
|
||||
const pieData = (data?.outbound_states ?? [])
|
||||
.filter((s) => s.count > 0)
|
||||
.map((s) => ({
|
||||
name: s.state,
|
||||
value: s.count,
|
||||
fill: STATE_COLORS[s.state] ?? '#9ca3af',
|
||||
}))
|
||||
|
||||
const kpi = data?.kpi
|
||||
const generatedAt = data?.generated_at
|
||||
? format(parseISO(data.generated_at), "dd/MM/yyyy HH:mm", { locale: it })
|
||||
: null
|
||||
|
||||
// ── URL export con token auth (il browser apre con i cookie di sessione) ──
|
||||
const handleExport = (format: 'csv' | 'pdf') => {
|
||||
const url = format === 'csv'
|
||||
? reportsApi.exportCsv()
|
||||
: reportsApi.exportPdf()
|
||||
|
||||
// Apre il link in una nuova scheda; il token JWT viene inviato
|
||||
// automaticamente grazie all'interceptor axios, ma per i download
|
||||
// diretti usiamo fetch con il token dallo store.
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Errore download')
|
||||
return res.blob()
|
||||
})
|
||||
.then((blob) => {
|
||||
const ext = format === 'csv' ? 'csv' : 'pdf'
|
||||
const ts = new Date().toISOString().slice(0, 16).replace('T', '_').replace(':', '')
|
||||
const filename = `pechub_report_${ts}.${ext}`
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
})
|
||||
.catch(() => alert('Errore durante il download del report'))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto bg-gray-50">
|
||||
{/* ── Intestazione ── */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b px-6 py-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<BarChart2 className="h-6 w-6 text-blue-600 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold text-gray-900">Dashboard</h1>
|
||||
{generatedAt && (
|
||||
<p className="text-xs text-gray-400">Aggiornato il {generatedAt}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Selettore periodo */}
|
||||
<div className="flex rounded-lg border bg-white overflow-hidden text-sm">
|
||||
{PERIOD_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setDays(opt.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 font-medium transition-colors',
|
||||
days === opt.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Aggiorna */}
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="p-2 rounded-lg border bg-white text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
title="Aggiorna dati"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isFetching && 'animate-spin')} />
|
||||
</button>
|
||||
|
||||
{/* Export CSV */}
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
CSV
|
||||
</button>
|
||||
|
||||
{/* Export PDF */}
|
||||
<button
|
||||
onClick={() => handleExport('pdf')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Contenuto ── */}
|
||||
<div className="flex-1 p-6 space-y-6 max-w-7xl mx-auto w-full">
|
||||
|
||||
{/* ── Loading / Errore ── */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mr-2" />
|
||||
Caricamento dati...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center text-red-700">
|
||||
Errore nel caricamento dei dati. Riprova.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* ── Riga KPI ── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<KpiCard
|
||||
title="Ricevute oggi"
|
||||
value={kpi!.received_today}
|
||||
subtitle={`${kpi!.received_7d} negli ultimi 7gg`}
|
||||
icon={<Inbox className="h-5 w-5 text-blue-600" />}
|
||||
color="bg-blue-50"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Inviate oggi"
|
||||
value={kpi!.sent_today}
|
||||
subtitle={`${kpi!.sent_7d} negli ultimi 7gg`}
|
||||
icon={<Send className="h-5 w-5 text-indigo-600" />}
|
||||
color="bg-indigo-50"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Anomalie attive"
|
||||
value={kpi!.anomalie_attive}
|
||||
icon={<AlertTriangle className="h-5 w-5 text-orange-600" />}
|
||||
color="bg-orange-50"
|
||||
alert={kpi!.anomalie_attive > 0}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Tasso consegna"
|
||||
value={`${kpi!.tasso_consegna}%`}
|
||||
icon={<CheckCircle2 className="h-5 w-5 text-green-600" />}
|
||||
color="bg-green-50"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Caselle in errore"
|
||||
value={kpi!.caselle_in_errore}
|
||||
icon={<ServerCrash className="h-5 w-5 text-red-600" />}
|
||||
color="bg-red-50"
|
||||
alert={kpi!.caselle_in_errore > 0}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Non letti"
|
||||
value={kpi!.messaggi_non_letti}
|
||||
icon={<MailOpen className="h-5 w-5 text-purple-600" />}
|
||||
color="bg-purple-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Grafici ── */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
{/* Grafico a barre (2/3) */}
|
||||
<div className="xl:col-span-2 bg-white rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">
|
||||
Attivita PEC – ultimi {days} giorni
|
||||
</h2>
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-52 flex items-center justify-center text-gray-400 text-sm">
|
||||
Nessun dato nel periodo selezionato
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="giorno"
|
||||
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: '#6b7280' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip content={<CustomBarTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Ricevute"
|
||||
fill="#3b82f6"
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Inviate"
|
||||
fill="#8b5cf6"
|
||||
radius={[3, 3, 0, 0]}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grafico a torta (1/3) */}
|
||||
<div className="bg-white rounded-xl border p-5">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">
|
||||
Stato messaggi outbound
|
||||
</h2>
|
||||
{pieData.length === 0 ? (
|
||||
<div className="h-52 flex items-center justify-center text-gray-400 text-sm">
|
||||
Nessun messaggio outbound
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={72}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomPieTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Legenda manuale */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{pieData.map((entry) => (
|
||||
<div key={entry.name} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="text-gray-600">
|
||||
{STATE_LABELS[entry.name] ?? entry.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-800">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tabella caselle ── */}
|
||||
{data.mailbox_stats.length > 0 && (
|
||||
<div className="bg-white rounded-xl border">
|
||||
<div className="px-5 py-4 border-b">
|
||||
<h2 className="text-base font-semibold text-gray-800">
|
||||
Dettaglio per casella
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="text-left px-5 py-3 font-semibold text-gray-600">Casella</th>
|
||||
<th className="text-center px-4 py-3 font-semibold text-gray-600">Stato</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Ricevute</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Inviate</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Anomalie</th>
|
||||
<th className="text-right px-4 py-3 font-semibold text-gray-600">Non letti</th>
|
||||
<th className="text-right px-5 py-3 font-semibold text-gray-600">Ultima sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.mailbox_stats.map((mb) => (
|
||||
<tr key={mb.mailbox_id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-medium text-gray-900 truncate max-w-xs">
|
||||
{mb.display_name || mb.email_address}
|
||||
</div>
|
||||
{mb.display_name && (
|
||||
<div className="text-xs text-gray-400">{mb.email_address}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
STATUS_BADGE[mb.status] ?? 'bg-gray-100 text-gray-600',
|
||||
)}
|
||||
>
|
||||
{mb.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-gray-900">
|
||||
{mb.received_total}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-gray-900">
|
||||
{mb.sent_total}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{mb.anomalie > 0 ? (
|
||||
<span className="font-semibold text-red-600">{mb.anomalie}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{mb.non_letti > 0 ? (
|
||||
<span className="font-semibold text-blue-600">{mb.non_letti}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-gray-500 text-xs">
|
||||
{mb.last_sync_at
|
||||
? format(parseISO(mb.last_sync_at), 'dd/MM HH:mm', { locale: it })
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-xs text-gray-400 text-center pb-2">
|
||||
Totale messaggi nel sistema: {kpi!.totale_messaggi.toLocaleString('it-IT')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2, CheckCircle, XCircle, Settings2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
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 { getErrorMessage } from '@/api/client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const FIELD_LABELS: Record<ConditionField, string> = {
|
||||
from_address: 'Mittente',
|
||||
to_address: 'Destinatario',
|
||||
subject: 'Oggetto',
|
||||
mailbox_id: 'ID Casella',
|
||||
pec_type: 'Tipo PEC',
|
||||
}
|
||||
|
||||
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
|
||||
contains: 'contiene',
|
||||
not_contains: 'non contiene',
|
||||
equals: 'uguale a',
|
||||
starts_with: 'inizia per',
|
||||
ends_with: 'finisce per',
|
||||
regex: 'regex',
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<ActionType, string> = {
|
||||
apply_label: 'Applica etichetta',
|
||||
assign_vbox: 'Assegna Virtual Box',
|
||||
mark_read: 'Segna come letto',
|
||||
mark_starred: 'Aggiungi ai preferiti',
|
||||
notify_webhook: 'Notifica webhook',
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
field: ConditionField
|
||||
operator: ConditionOperator
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Action {
|
||||
action_type: ActionType
|
||||
action_value: string
|
||||
}
|
||||
|
||||
export function RoutingRulesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState<RoutingRuleResponse | null>(null)
|
||||
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set())
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formDescription, setFormDescription] = useState('')
|
||||
const [formPriority, setFormPriority] = useState('100')
|
||||
const [formStopProcessing, setFormStopProcessing] = useState(true)
|
||||
const [formConditions, setFormConditions] = useState<Condition[]>([
|
||||
{ field: 'from_address', operator: 'contains', value: '' }
|
||||
])
|
||||
const [formActions, setFormActions] = useState<Action[]>([
|
||||
{ action_type: 'mark_read', action_value: '' }
|
||||
])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['routing-rules'],
|
||||
queryFn: () => routingRulesApi.list(),
|
||||
})
|
||||
|
||||
const { data: labelsData } = useQuery({
|
||||
queryKey: ['labels'],
|
||||
queryFn: () => labelsApi.list(),
|
||||
})
|
||||
const labels = labelsData ?? []
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
|
||||
toast.success('Regola creata')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: RoutingRuleCreate }) =>
|
||||
routingRulesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
|
||||
toast.success('Regola aggiornata')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => routingRulesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
|
||||
toast.success('Regola eliminata')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (id: string) => routingRulesApi.toggle(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-rules'] }),
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setFormName('')
|
||||
setFormDescription('')
|
||||
setFormPriority('100')
|
||||
setFormStopProcessing(true)
|
||||
setFormConditions([{ field: 'from_address', operator: 'contains', value: '' }])
|
||||
setFormActions([{ action_type: 'mark_read', action_value: '' }])
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (r: RoutingRuleResponse) => {
|
||||
setEditing(r)
|
||||
setFormName(r.name)
|
||||
setFormDescription(r.description ?? '')
|
||||
setFormPriority(String(r.priority))
|
||||
setFormStopProcessing(r.stop_processing)
|
||||
setFormConditions(r.conditions.map(c => ({ field: c.field as ConditionField, operator: c.operator as ConditionOperator, value: c.value })))
|
||||
setFormActions(r.actions.map(a => ({ action_type: a.action_type as ActionType, action_value: a.action_value ?? '' })))
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
const payload: RoutingRuleCreate = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
priority: parseInt(formPriority) || 100,
|
||||
stop_processing: formStopProcessing,
|
||||
is_active: true,
|
||||
conditions: formConditions.map(c => ({ field: c.field, operator: c.operator, value: c.value.trim() })),
|
||||
actions: formActions.map(a => ({ action_type: a.action_type, action_value: a.action_value.trim() || null })),
|
||||
}
|
||||
if (editing) {
|
||||
updateMutation.mutate({ id: editing.id, data: payload })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedRules(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-primary" />
|
||||
Regole di smistamento
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Applica automaticamente etichette e azioni ai messaggi in arrivo
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuova regola
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Settings2 className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">Nessuna regola configurata</p>
|
||||
<p className="text-sm mt-1">Le regole vengono valutate in ordine di priorita' su ogni messaggio inbound.</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((rule) => (
|
||||
<div key={rule.id} className={cn('rounded-lg border bg-card', !rule.is_active && 'opacity-60')}>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{rule.name}</span>
|
||||
<span className="text-xs text-muted-foreground">P:{rule.priority}</span>
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full', rule.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600')}>
|
||||
{rule.is_active ? 'Attiva' : 'Inattiva'}
|
||||
</span>
|
||||
{rule.stop_processing && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">Stop</span>
|
||||
)}
|
||||
</div>
|
||||
{rule.description && <p className="text-xs text-muted-foreground mt-0.5">{rule.description}</p>}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{rule.conditions.length} condizioni / {rule.actions.length} azioni
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(rule)} title="Modifica">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => toggleMutation.mutate(rule.id)}
|
||||
title={rule.is_active ? 'Disattiva' : 'Attiva'}
|
||||
>
|
||||
{rule.is_active
|
||||
? <XCircle className="h-4 w-4 text-amber-500" />
|
||||
: <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8"
|
||||
onClick={() => { if (confirm(`Eliminare la regola "${rule.name}"?`)) deleteMutation.mutate(rule.id) }}
|
||||
title="Elimina"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toggleExpand(rule.id)}>
|
||||
{expandedRules.has(rule.id) ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedRules.has(rule.id) && (
|
||||
<div className="border-t px-4 py-3 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Condizioni (AND)</p>
|
||||
{rule.conditions.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs bg-muted/40 rounded px-3 py-1.5 mb-1">
|
||||
<span className="font-medium">{FIELD_LABELS[c.field as ConditionField] ?? c.field}</span>
|
||||
<span className="text-muted-foreground">{OPERATOR_LABELS[c.operator as ConditionOperator] ?? c.operator}</span>
|
||||
<span className="font-mono bg-background border rounded px-1.5 py-0.5">{c.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Azioni</p>
|
||||
{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>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Modifica regola' : 'Nuova regola di smistamento'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label>Nome *</Label>
|
||||
<Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="Es. Multe comune Roma" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Priorita' (1=alta, 999=bassa)</Label>
|
||||
<Input type="number" value={formPriority} onChange={e => setFormPriority(e.target.value)} min={1} max={999} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Descrizione</Label>
|
||||
<Input value={formDescription} onChange={e => setFormDescription(e.target.value)} placeholder="Descrizione opzionale" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={formStopProcessing} onChange={e => setFormStopProcessing(e.target.checked)} className="rounded" />
|
||||
<span className="text-sm">Interrompi elaborazione dopo questa regola (stop processing)</span>
|
||||
</label>
|
||||
|
||||
{/* Condizioni */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Condizioni (tutte devono essere soddisfatte - AND)</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setFormConditions(prev => [...prev, { field: 'from_address', operator: 'contains', value: '' }])}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
|
||||
</Button>
|
||||
</div>
|
||||
{formConditions.map((cond, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-muted/20">
|
||||
<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))}
|
||||
>
|
||||
{(Object.entries(FIELD_LABELS) as [ConditionField, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||
value={cond.operator}
|
||||
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, operator: e.target.value as ConditionOperator } : c))}
|
||||
>
|
||||
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</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">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Azioni */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Azioni da eseguire</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setFormActions(prev => [...prev, { action_type: 'mark_read', action_value: '' }])}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
|
||||
</Button>
|
||||
</div>
|
||||
{formActions.map((action, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-blue-50">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
|
||||
value={action.action_type}
|
||||
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_type: e.target.value as ActionType, action_value: '' } : a))}
|
||||
>
|
||||
{(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 => <option key={l.id} value={l.id}>{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://..."
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={closeForm}>Annulla</Button>
|
||||
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
|
||||
{editing ? 'Salva modifiche' : 'Crea regola'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* SearchPage – ricerca full-text avanzata tra tutti i messaggi PEC.
|
||||
*
|
||||
* Funzionalita':
|
||||
* - Barra di ricerca full-text (usa FTS su search_vector PostgreSQL)
|
||||
* - Pannello filtri avanzati collassabile:
|
||||
* - Data da / Data a
|
||||
* - Direzione (in arrivo / inviata)
|
||||
* - Stato PEC
|
||||
* - Tipo PEC
|
||||
* - Casella specifica
|
||||
* - Lista risultati ordinata per rilevanza
|
||||
* - Paginazione
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
X,
|
||||
Inbox,
|
||||
Send,
|
||||
MailOpen,
|
||||
Mail,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||
import { TagBadgeList } from '@/components/TagManager/TagBadge'
|
||||
import { messagesApi } from '@/api/messages.api'
|
||||
import { mailboxesApi } from '@/api/mailboxes.api'
|
||||
import { formatRelative, truncate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MessageResponse } from '@/types/api.types'
|
||||
|
||||
// ─── Costanti ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const PEC_STATES = [
|
||||
{ value: '', label: 'Tutti gli stati' },
|
||||
{ value: 'received', label: 'Ricevuta' },
|
||||
{ value: 'sent', label: 'Inviata' },
|
||||
{ value: 'accepted', label: 'Accettata' },
|
||||
{ value: 'delivered', label: 'Consegnata' },
|
||||
{ value: 'anomaly', label: 'Anomalia' },
|
||||
{ value: 'failed', label: 'Fallita' },
|
||||
]
|
||||
|
||||
const PEC_TYPES = [
|
||||
{ value: '', label: 'Tutti i tipi' },
|
||||
{ value: 'posta_certificata', label: 'Posta certificata' },
|
||||
{ value: 'accettazione', label: 'Accettazione' },
|
||||
{ value: 'avvenuta_consegna', label: 'Avvenuta consegna' },
|
||||
{ value: 'mancata_consegna', label: 'Mancata consegna' },
|
||||
{ value: 'non_accettazione', label: 'Non accettazione' },
|
||||
{ value: 'presa_in_carico', label: 'Presa in carico' },
|
||||
{ value: 'errore_consegna', label: 'Errore consegna' },
|
||||
{ value: 'preavviso_mancata_consegna', label: 'Preavviso mancata consegna' },
|
||||
{ value: 'rilevazione_virus', label: 'Rilevazione virus' },
|
||||
]
|
||||
|
||||
const DIRECTIONS = [
|
||||
{ value: '', label: 'Tutte le direzioni' },
|
||||
{ value: 'inbound', label: 'In arrivo' },
|
||||
{ value: 'outbound', label: 'Inviata' },
|
||||
]
|
||||
|
||||
// ─── Componente principale ────────────────────────────────────────────────────
|
||||
|
||||
export function SearchPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// Legge i parametri dall'URL (permette di condividere/bookmark la ricerca)
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '')
|
||||
const [committedSearch, setCommittedSearch] = useState(searchParams.get('q') || '')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Filtri avanzati
|
||||
const [dateFrom, setDateFrom] = useState(searchParams.get('date_from') || '')
|
||||
const [dateTo, setDateTo] = useState(searchParams.get('date_to') || '')
|
||||
const [direction, setDirection] = useState(searchParams.get('direction') || '')
|
||||
const [state, setState] = useState(searchParams.get('state') || '')
|
||||
const [pecType, setPecType] = useState(searchParams.get('pec_type') || '')
|
||||
const [mailboxId, setMailboxId] = useState(searchParams.get('mailbox_id') || '')
|
||||
|
||||
// Caselle disponibili per il filtro
|
||||
const { data: mailboxesData } = useQuery({
|
||||
queryKey: ['mailboxes'],
|
||||
queryFn: () => mailboxesApi.list(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const mailboxes = mailboxesData?.items ?? []
|
||||
|
||||
// Numero di filtri attivi
|
||||
const activeFiltersCount = [dateFrom, dateTo, direction, state, pecType, mailboxId].filter(Boolean).length
|
||||
|
||||
// Sincronizza l'URL quando cambiano i filtri
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {}
|
||||
if (committedSearch) params.q = committedSearch
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
if (direction) params.direction = direction
|
||||
if (state) params.state = state
|
||||
if (pecType) params.pec_type = pecType
|
||||
if (mailboxId) params.mailbox_id = mailboxId
|
||||
setSearchParams(params, { replace: true })
|
||||
}, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId, setSearchParams])
|
||||
|
||||
// Reset pagina quando cambiano i filtri
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId])
|
||||
|
||||
// Costruisce i filtri per l'API
|
||||
const filters = {
|
||||
search: committedSearch || undefined,
|
||||
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
|
||||
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
|
||||
direction: direction as 'inbound' | 'outbound' | undefined || undefined,
|
||||
state: state || undefined,
|
||||
pec_type: pecType || undefined,
|
||||
mailbox_id: mailboxId || undefined,
|
||||
is_archived: undefined as boolean | undefined,
|
||||
is_trashed: false,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
|
||||
// Query messaggi
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ['search', filters],
|
||||
queryFn: () => messagesApi.list(filters),
|
||||
enabled: !!(committedSearch || dateFrom || dateTo || direction || state || pecType || mailboxId),
|
||||
})
|
||||
|
||||
const messages = messagesData?.items || []
|
||||
const total = messagesData?.total || 0
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
const hasQuery = !!(committedSearch || activeFiltersCount > 0)
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setCommittedSearch(searchInput.trim())
|
||||
setPage(1)
|
||||
}, [searchInput])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') handleSearch()
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSearchInput('')
|
||||
setCommittedSearch('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setDirection('')
|
||||
setState('')
|
||||
setPecType('')
|
||||
setMailboxId('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleMessageClick = (message: MessageResponse) => {
|
||||
navigate(`/messages/${message.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* ── Header ricerca ── */}
|
||||
<div className="border-b bg-background px-6 py-4">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
Ricerca avanzata
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Barra di ricerca principale */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder='Cerca in tutti i messaggi… (es. "fattura gennaio" -spam)'
|
||||
className="pl-9 h-10 text-sm"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(''); setCommittedSearch('') }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSearch} className="h-10 px-5">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Cerca
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn('h-10', activeFiltersCount > 0 && 'border-primary text-primary')}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Filtri
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary text-white text-xs font-bold">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
{showFilters ? <ChevronUp className="h-3.5 w-3.5 ml-2" /> : <ChevronDown className="h-3.5 w-3.5 ml-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pannello filtri avanzati */}
|
||||
{showFilters && (
|
||||
<div className="bg-muted/40 rounded-lg border p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{/* Data da */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Data da
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data a */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Data a
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Direzione */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Direzione
|
||||
</label>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
>
|
||||
{DIRECTIONS.map((d) => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stato PEC */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Stato PEC
|
||||
</label>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
>
|
||||
{PEC_STATES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tipo PEC */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Tipo PEC
|
||||
</label>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={pecType}
|
||||
onChange={(e) => setPecType(e.target.value)}
|
||||
>
|
||||
{PEC_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Casella */}
|
||||
{mailboxes.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Casella PEC
|
||||
</label>
|
||||
<select
|
||||
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={mailboxId}
|
||||
onChange={(e) => setMailboxId(e.target.value)}
|
||||
>
|
||||
<option value="">Tutte le caselle</option>
|
||||
{mailboxes.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.display_name || m.email_address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pulsante reset filtri */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={handleClearAll} className="text-xs text-muted-foreground">
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Azzera tutti i filtri
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Risultati ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!hasQuery ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground font-medium">Inizia a cercare</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1 max-w-xs">
|
||||
Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading || isFetching ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">Ricerca in corso…</p>
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-muted-foreground font-medium">Nessun risultato</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1">
|
||||
Prova a modificare i termini di ricerca o i filtri
|
||||
</p>
|
||||
{hasQuery && (
|
||||
<Button variant="ghost" size="sm" className="mt-3" onClick={handleClearAll}>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Azzera ricerca
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Intestazione risultati */}
|
||||
<div className="px-6 py-2.5 bg-muted/20 border-b flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{total}</span>{' '}
|
||||
{total === 1 ? 'risultato trovato' : 'risultati trovati'}
|
||||
{committedSearch && (
|
||||
<> per <span className="font-medium text-foreground italic">"{committedSearch}"</span></>
|
||||
)}
|
||||
</p>
|
||||
{(isFetching) && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista risultati */}
|
||||
<div className="divide-y">
|
||||
{messages.map((message) => (
|
||||
<SearchResultRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
searchTerm={committedSearch}
|
||||
mailboxName={
|
||||
mailboxes.find((m) => m.id === message.mailbox_id)?.email_address
|
||||
}
|
||||
onClick={() => handleMessageClick(message)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Paginazione ── */}
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pagina {page} di {totalPages} ({total} risultati)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Riga singolo risultato ───────────────────────────────────────────────────
|
||||
|
||||
interface SearchResultRowProps {
|
||||
message: MessageResponse
|
||||
searchTerm: string
|
||||
mailboxName?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchResultRowProps) {
|
||||
const isUnread = !message.is_read && message.direction === 'inbound'
|
||||
|
||||
// Evidenzia il termine cercato nel testo
|
||||
const highlight = (text: string | null | undefined, term: string): React.ReactNode => {
|
||||
if (!text || !term) return text || ''
|
||||
const idx = text.toLowerCase().indexOf(term.toLowerCase())
|
||||
if (idx === -1) return truncate(text, 150)
|
||||
const start = Math.max(0, idx - 60)
|
||||
const end = Math.min(text.length, idx + term.length + 90)
|
||||
const snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')
|
||||
const snipIdx = snippet.toLowerCase().indexOf(term.toLowerCase())
|
||||
if (snipIdx === -1) return snippet
|
||||
return (
|
||||
<>
|
||||
{snippet.slice(0, snipIdx)}
|
||||
<mark className="bg-yellow-200 dark:bg-yellow-800/60 rounded px-0.5 font-medium">
|
||||
{snippet.slice(snipIdx, snipIdx + term.length)}
|
||||
</mark>
|
||||
{snippet.slice(snipIdx + term.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-6 py-3.5 cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Icona direzione */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{message.direction === 'inbound' ? (
|
||||
isUnread ? (
|
||||
<Mail className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<MailOpen className="h-5 w-5 text-muted-foreground" />
|
||||
)
|
||||
) : (
|
||||
<Send className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenuto */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Riga 1: mittente + badge casella + stato + data */}
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn(
|
||||
'text-sm truncate',
|
||||
isUnread ? 'font-semibold' : 'font-medium',
|
||||
)}>
|
||||
{message.direction === 'inbound'
|
||||
? (message.from_address || 'Mittente sconosciuto')
|
||||
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
|
||||
</span>
|
||||
{mailboxName && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
{mailboxName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<PecStateBadge state={message.state} />
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatRelative(message.received_at || message.sent_at || message.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Riga 2: oggetto */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
{isUnread && <span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0" />}
|
||||
<p className={cn(
|
||||
'text-sm',
|
||||
isUnread ? 'font-medium text-foreground' : 'text-foreground',
|
||||
)}>
|
||||
{searchTerm
|
||||
? highlight(message.subject || '(nessun oggetto)', searchTerm)
|
||||
: truncate(message.subject || '(nessun oggetto)', 100)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Riga 3: snippet corpo */}
|
||||
{message.body_text && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tag */}
|
||||
{message.labels && message.labels.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indicatore allegati */}
|
||||
{message.has_attachments && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 mt-1"></span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Pencil, Trash2, FileText, Search } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
|
||||
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
|
||||
import { templatesApi, type TemplateResponse, type TemplateCreate } from '@/api/templates.api'
|
||||
import { getErrorMessage } from '@/api/client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
export function TemplatesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { isAdmin } = useAuth()
|
||||
const [q, setQ] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editing, setEditing] = useState<TemplateResponse | null>(null)
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formDescription, setFormDescription] = useState('')
|
||||
const [formSubject, setFormSubject] = useState('')
|
||||
const [formBody, setFormBody] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['templates', q],
|
||||
queryFn: () => templatesApi.list(q || undefined),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: TemplateCreate) => templatesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||
toast.success('Template creato')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: TemplateCreate }) =>
|
||||
templatesApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||
toast.success('Template aggiornato')
|
||||
closeForm()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => templatesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] })
|
||||
toast.success('Template eliminato')
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setFormName('')
|
||||
setFormDescription('')
|
||||
setFormSubject('')
|
||||
setFormBody('')
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (t: TemplateResponse) => {
|
||||
setEditing(t)
|
||||
setFormName(t.name)
|
||||
setFormDescription(t.description ?? '')
|
||||
setFormSubject(t.subject)
|
||||
setFormBody(t.body_html ?? t.body_text ?? '')
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
|
||||
const payload: TemplateCreate = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
subject: formSubject.trim(),
|
||||
body_html: formBody || null,
|
||||
body_text: null,
|
||||
}
|
||||
if (editing) {
|
||||
updateMutation.mutate({ id: editing.id, data: payload })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Template messaggi</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Template riutilizzabili per la composizione PEC
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuovo template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
|
||||
{/* Ricerca */}
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Cerca per nome..."
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">Nessun template trovato</p>
|
||||
<p className="text-sm mt-1">
|
||||
{isAdmin ? 'Crea il tuo primo template con il pulsante in alto.' : 'Nessun template disponibile.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className="rounded-lg border bg-card p-4 space-y-2 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold truncate">{t.name}</h3>
|
||||
{t.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(t)} className="h-8 w-8">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(`Eliminare il template "${t.name}"?`)) {
|
||||
deleteMutation.mutate(t.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{t.subject && (
|
||||
<p className="text-sm font-medium text-foreground/80 truncate">
|
||||
Oggetto: {t.subject}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aggiornato: {formatDate(t.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog form */}
|
||||
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Modifica template' : 'Nuovo template'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nome *</Label>
|
||||
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Es. Risposta a ricorso" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Descrizione (opzionale)</Label>
|
||||
<Input value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Breve descrizione del template" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Oggetto predefinito</Label>
|
||||
<Input value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Oggetto del messaggio PEC" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Corpo del messaggio</Label>
|
||||
<div className="min-h-[200px] border rounded-md overflow-hidden">
|
||||
<RichTextEditor value={formBody} onChange={setFormBody} placeholder="Testo del template..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={closeForm}>Annulla</Button>
|
||||
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
|
||||
{editing ? 'Salva modifiche' : 'Crea template'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -260,6 +260,12 @@ export interface MessageResponse {
|
||||
is_starred: boolean
|
||||
is_archived: boolean
|
||||
archived_at: string | null
|
||||
is_trashed: boolean
|
||||
trashed_at: string | null
|
||||
is_pending_conservation: boolean
|
||||
pending_conservation_at: string | null
|
||||
is_conserved: boolean
|
||||
conserved_at: string | null
|
||||
raw_eml_path: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -340,6 +346,7 @@ export interface PermissionGrantRequest {
|
||||
can_read?: boolean
|
||||
can_send?: boolean
|
||||
can_manage?: boolean
|
||||
can_conserve?: boolean
|
||||
}
|
||||
|
||||
export interface MailboxUserPermissionResponse {
|
||||
@@ -350,6 +357,7 @@ export interface MailboxUserPermissionResponse {
|
||||
can_read: boolean
|
||||
can_send: boolean
|
||||
can_manage: boolean
|
||||
can_conserve: boolean
|
||||
granted_at: string
|
||||
}
|
||||
|
||||
@@ -360,6 +368,7 @@ export interface UserMailboxPermissionResponse {
|
||||
can_read: boolean
|
||||
can_send: boolean
|
||||
can_manage: boolean
|
||||
can_conserve: boolean
|
||||
}
|
||||
|
||||
// ─── Virtual Box ──────────────────────────────────────────────────────────────
|
||||
@@ -576,6 +585,14 @@ export interface WsEvent {
|
||||
|
||||
// ─── API Pagination ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
|
||||
@@ -5,6 +5,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-ita \
|
||||
tesseract-ocr-eng \
|
||||
poppler-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /worker
|
||||
|
||||
+55
-5
@@ -35,6 +35,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.jobs.dispatch_notification import evaluate_and_enqueue_notifications
|
||||
from app.jobs.index_message import index_message
|
||||
from app.models import Attachment, Mailbox, Message
|
||||
from app.parsers.eml_parser import parse_eml
|
||||
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
|
||||
@@ -539,11 +541,15 @@ async def _save_message(
|
||||
logger.debug(f"[{mailbox.email_address}] UID {uid} in {imap_folder!r} già in DB, skip")
|
||||
return False
|
||||
|
||||
# ── Parsing completo EML ──────────────────────────────────────────────────
|
||||
parsed = parse_eml(raw_eml)
|
||||
pec_class = classify_pec_message(
|
||||
parsed.raw_message or email.message_from_bytes(raw_eml)
|
||||
)
|
||||
# ── Classificazione PEC da header (veloce, senza body) ───────────────────
|
||||
# La classificazione avviene PRIMA del parsing completo perche' il parser
|
||||
# deve sapere se il messaggio e' una ricevuta per evitare di sovrascrivere
|
||||
# il body_text (testo della ricevuta) con il contenuto di postacert.eml.
|
||||
quick_msg = email.message_from_bytes(raw_eml)
|
||||
pec_class = classify_pec_message(quick_msg)
|
||||
|
||||
# ── Parsing completo EML (con is_receipt per proteggere il body) ──────────
|
||||
parsed = parse_eml(raw_eml, is_receipt=pec_class.is_receipt)
|
||||
received_at = datetime.now(UTC)
|
||||
|
||||
# ── State machine: trova e aggiorna messaggio outbound ────────────────────
|
||||
@@ -635,6 +641,50 @@ async def _save_message(
|
||||
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
|
||||
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
|
||||
)
|
||||
|
||||
# ── Indicizzazione full-text (non bloccante, non interrompe la sync) ─────
|
||||
# Chiamata dopo il flush degli allegati: index_message puo' leggere
|
||||
# sia il messaggio che gli allegati dalla sessione corrente.
|
||||
await index_message(message.id, db)
|
||||
|
||||
# ── Valutazione e accodamento notifiche (non bloccante) ───────────────────
|
||||
# Solo per messaggi inbound: le ricevute PEC e la posta in arrivo
|
||||
# possono triggerare regole di notifica configurate dal tenant.
|
||||
# I messaggi outbound (Sent) non generano notifiche automatiche.
|
||||
if direction == "inbound":
|
||||
await evaluate_and_enqueue_notifications(
|
||||
message=message,
|
||||
mailbox=mailbox,
|
||||
db=db,
|
||||
redis_client=redis_client,
|
||||
)
|
||||
|
||||
# ── Regole di smistamento automatico (Feature 2) ──────────────────────────
|
||||
# Solo per messaggi inbound posta_certificata (non ricevute di sistema).
|
||||
if direction == "inbound" and pec_class.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) ──────────────────────────
|
||||
# Per messaggi inbound di tipo posta_certificata, salva automaticamente
|
||||
# il mittente nella rubrica pec_contacts del tenant (upsert idempotente).
|
||||
if direction == "inbound" and pec_class.pec_type == "posta_certificata" and message.from_address:
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
await db.execute(
|
||||
_text("""
|
||||
INSERT INTO pec_contacts (id, tenant_id, email, auto_saved, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tenant_id, :email, true, now(), now())
|
||||
ON CONFLICT (tenant_id, email) DO NOTHING
|
||||
"""),
|
||||
{"tenant_id": str(mailbox.tenant_id), "email": message.from_address.lower().strip()},
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.debug(f"[{mailbox.email_address}] Auto-save contatto fallito (non critico): {e}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Job arq: apply_routing_rules – applica le regole di smistamento automatico.
|
||||
|
||||
Viene accodato da sync.py dopo il salvataggio di ogni messaggio inbound.
|
||||
|
||||
Logica:
|
||||
1. Carica le regole attive del tenant ordinate per priority
|
||||
2. Per ogni regola valuta le condizioni (AND)
|
||||
3. Se match: esegue le azioni (apply_label, mark_read, mark_starred, notify_webhook)
|
||||
4. Se stop_processing=True, interrompe la catena
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid as uuid_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Job principale ───────────────────────────────────────────────────────────
|
||||
|
||||
async def apply_routing_rules(ctx: dict[str, Any], message_id: str) -> dict:
|
||||
"""
|
||||
Valuta le regole di smistamento automatico per un messaggio.
|
||||
|
||||
Args:
|
||||
ctx: contesto arq
|
||||
message_id: UUID del messaggio da processare
|
||||
|
||||
Returns:
|
||||
dict con: matched_rules, actions_applied
|
||||
"""
|
||||
msg_uuid = uuid_module.UUID(message_id)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Carica il messaggio
|
||||
msg = await db.get(Message, msg_uuid)
|
||||
if not msg:
|
||||
logger.warning(f"[routing_rules] Messaggio {message_id} non trovato")
|
||||
return {"status": "skipped", "reason": "message_not_found"}
|
||||
|
||||
# Solo messaggi inbound di tipo posta_certificata
|
||||
if msg.direction != "inbound" or msg.pec_type != "posta_certificata":
|
||||
return {"status": "skipped", "reason": "not_inbound_pec"}
|
||||
|
||||
# Carica regole attive del tenant ordinate per priority ASC
|
||||
rules_result = await db.execute(
|
||||
text("""
|
||||
SELECT r.id, r.name, r.priority, r.stop_processing,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object('field', c.field, 'operator', c.operator, 'value', c.value)
|
||||
ORDER BY c.id
|
||||
) FILTER (WHERE c.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS conditions,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object('action_type', a.action_type, 'action_value', a.action_value)
|
||||
ORDER BY a.id
|
||||
) FILTER (WHERE a.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) AS actions
|
||||
FROM routing_rules r
|
||||
LEFT JOIN routing_rule_conditions c ON c.rule_id = r.id
|
||||
LEFT JOIN routing_rule_actions a ON a.rule_id = r.id
|
||||
WHERE r.tenant_id = :tenant_id
|
||||
AND r.is_active = true
|
||||
GROUP BY r.id, r.name, r.priority, r.stop_processing
|
||||
ORDER BY r.priority ASC
|
||||
"""),
|
||||
{"tenant_id": str(msg.tenant_id)},
|
||||
)
|
||||
rules = rules_result.mappings().all()
|
||||
|
||||
matched_count = 0
|
||||
actions_applied: list[str] = []
|
||||
|
||||
for rule in rules:
|
||||
conditions = rule["conditions"]
|
||||
if not conditions:
|
||||
continue
|
||||
|
||||
# Valuta condizioni (AND)
|
||||
if not _evaluate_conditions(msg, conditions):
|
||||
continue
|
||||
|
||||
matched_count += 1
|
||||
logger.info(
|
||||
f"[routing_rules] Regola '{rule['name']}' (priority={rule['priority']}) "
|
||||
f"match per messaggio {message_id}"
|
||||
)
|
||||
|
||||
# Esegui azioni
|
||||
for action in rule["actions"]:
|
||||
applied = await _apply_action(db, msg, action["action_type"], action["action_value"])
|
||||
if applied:
|
||||
actions_applied.append(action["action_type"])
|
||||
|
||||
if rule["stop_processing"]:
|
||||
break
|
||||
|
||||
if matched_count > 0:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"message_id": message_id,
|
||||
"matched_rules": matched_count,
|
||||
"actions_applied": actions_applied,
|
||||
}
|
||||
|
||||
|
||||
# ─── Valutazione condizioni ────────────────────────────────────────────────────
|
||||
|
||||
def _get_field_value(msg: Message, field: str) -> str:
|
||||
if field == "from_address":
|
||||
return (msg.from_address or "").lower()
|
||||
elif field == "to_address":
|
||||
return " ".join(msg.to_addresses or []).lower()
|
||||
elif field == "subject":
|
||||
return (msg.subject or "").lower()
|
||||
elif field == "mailbox_id":
|
||||
return str(msg.mailbox_id)
|
||||
elif field == "pec_type":
|
||||
return msg.pec_type or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _evaluate_condition(field_value: str, operator: str, value: str) -> bool:
|
||||
v = value.lower()
|
||||
fv = field_value.lower()
|
||||
if operator == "contains":
|
||||
return v in fv
|
||||
elif operator == "not_contains":
|
||||
return v not in fv
|
||||
elif operator == "equals":
|
||||
return fv == v
|
||||
elif operator == "starts_with":
|
||||
return fv.startswith(v)
|
||||
elif operator == "ends_with":
|
||||
return fv.endswith(v)
|
||||
elif operator == "regex":
|
||||
try:
|
||||
return bool(re.search(value, field_value, re.IGNORECASE))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _evaluate_conditions(msg: Message, conditions: list[dict]) -> bool:
|
||||
"""Valuta AND tra tutte le condizioni."""
|
||||
for cond in conditions:
|
||||
field_val = _get_field_value(msg, cond["field"])
|
||||
if not _evaluate_condition(field_val, cond["operator"], cond["value"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ─── Esecuzione azioni ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _apply_action(
|
||||
db: AsyncSession,
|
||||
msg: Message,
|
||||
action_type: str,
|
||||
action_value: str | None,
|
||||
) -> bool:
|
||||
"""Esegue una singola azione. Restituisce True se applicata."""
|
||||
try:
|
||||
if action_type == "apply_label" and action_value:
|
||||
return await _action_apply_label(db, msg, uuid_module.UUID(action_value))
|
||||
elif action_type == "mark_read":
|
||||
if not msg.is_read:
|
||||
msg.is_read = True
|
||||
return True
|
||||
elif action_type == "mark_starred":
|
||||
if not msg.is_starred:
|
||||
msg.is_starred = True
|
||||
return True
|
||||
elif action_type == "notify_webhook" and action_value:
|
||||
await _action_notify_webhook(msg, action_value)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[routing_rules] Errore azione {action_type}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _action_apply_label(
|
||||
db: AsyncSession, msg: Message, label_id: uuid_module.UUID
|
||||
) -> bool:
|
||||
"""Applica un'etichetta al messaggio se non gia' applicata."""
|
||||
# Verifica che la label esista e appartenga al tenant
|
||||
label_check = await db.execute(
|
||||
text("SELECT id FROM labels WHERE id = :lid AND tenant_id = :tid"),
|
||||
{"lid": str(label_id), "tid": str(msg.tenant_id)},
|
||||
)
|
||||
if not label_check.fetchone():
|
||||
return False
|
||||
|
||||
# Inserisci con ON CONFLICT DO NOTHING per idempotenza
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO message_labels (message_id, label_id)
|
||||
VALUES (:msg_id, :label_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""),
|
||||
{"msg_id": str(msg.id), "label_id": str(label_id)},
|
||||
)
|
||||
logger.debug(f"[routing_rules] Etichetta {label_id} applicata a {msg.id}")
|
||||
return True
|
||||
|
||||
|
||||
async def _action_notify_webhook(msg: Message, url: str) -> None:
|
||||
"""Invia notifica webhook per il messaggio."""
|
||||
import aiohttp
|
||||
payload = {
|
||||
"event": "routing_rule_match",
|
||||
"message_id": str(msg.id),
|
||||
"subject": msg.subject,
|
||||
"from_address": msg.from_address,
|
||||
"pec_type": msg.pec_type,
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=5))
|
||||
except Exception as e:
|
||||
logger.warning(f"[routing_rules] Webhook {url} fallito: {e}")
|
||||
@@ -0,0 +1,666 @@
|
||||
"""
|
||||
Job arq: dispatch_notification – invio effettivo delle notifiche multi-canale.
|
||||
|
||||
Flusso completo:
|
||||
1. sync.py salva un nuovo messaggio inbound
|
||||
2. sync.py chiama evaluate_and_enqueue_notifications() (questa funzione)
|
||||
3. evaluate_and_enqueue_notifications():
|
||||
- legge le NotificationRule attive del tenant con event_type="new_message"
|
||||
- applica i filtri opzionali (mailbox_id, direction, pec_type)
|
||||
- per ogni regola che matcha: crea NotificationLog(status=pending)
|
||||
- enqueue del job arq dispatch_notification con defer 5s
|
||||
(per dare tempo al DB di committare prima che il job legga)
|
||||
4. dispatch_notification(ctx, notification_log_id):
|
||||
- legge NotificationLog + NotificationChannel dal DB
|
||||
- controlla circuit breaker
|
||||
- decifra config_enc con AES-256-GCM
|
||||
- chiama il sender appropriato (telegram/webhook/email/whatsapp)
|
||||
- aggiorna status: sent / failed
|
||||
- in caso di fallimento: re-enqueue con backoff esponenziale
|
||||
- aggiorna circuit breaker dopo fallimenti consecutivi
|
||||
|
||||
Circuit breaker:
|
||||
- 5+ fallimenti consecutivi → apre per 1 ora
|
||||
- reset automatico al primo successo
|
||||
|
||||
Retry backoff (max_attempts default=3):
|
||||
Tentativo 1 fallisce → attendi 5 min → tentativo 2
|
||||
Tentativo 2 fallisce → attendi 30 min → tentativo 3
|
||||
Tentativo 3 fallisce → FAILED definitivo
|
||||
|
||||
Cifratura config_enc:
|
||||
AES-256-GCM – stesso schema di notification_service.py nel backend.
|
||||
Backward compatible: fallback a base64 grezzo per configurazioni pre-fix.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models import (
|
||||
Mailbox,
|
||||
Message,
|
||||
NotificationChannel,
|
||||
NotificationLog,
|
||||
NotificationRule,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# ─── Backoff retry (secondi) per tentativo N fallito (0-based) ───────────────
|
||||
_RETRY_DELAYS = [
|
||||
5 * 60, # dopo tentativo 1: 5 minuti
|
||||
30 * 60, # dopo tentativo 2: 30 minuti
|
||||
120 * 60, # dopo tentativo 3: 2 ore (non raggiunto: max_attempts=3)
|
||||
]
|
||||
|
||||
# ─── Circuit breaker ──────────────────────────────────────────────────────────
|
||||
_CIRCUIT_FAILURE_THRESHOLD = 5 # fallimenti consecutivi per aprire il circuito
|
||||
_CIRCUIT_OPEN_DURATION = timedelta(hours=1)
|
||||
|
||||
|
||||
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
|
||||
|
||||
def _decrypt_config(enc: str) -> dict:
|
||||
"""
|
||||
Decifra config_enc AES-256-GCM.
|
||||
|
||||
Backward compatible: se non e' GCM valido, prova fallback base64 grezzo.
|
||||
"""
|
||||
key = settings.encryption_key_bytes
|
||||
try:
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
if len(raw) > 28: # 12 nonce + 16 tag minimo
|
||||
nonce = raw[:12]
|
||||
ciphertext = raw[12:]
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||
return json.loads(plaintext.decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: base64 grezzo (configurazioni pre-fix sicurezza)
|
||||
try:
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ─── Valutazione filtri regola ────────────────────────────────────────────────
|
||||
|
||||
def _matches_filter(
|
||||
filter_data: dict | None,
|
||||
message: Message,
|
||||
mailbox: Mailbox,
|
||||
) -> bool:
|
||||
"""
|
||||
Valuta se un messaggio soddisfa i filtri opzionali di una regola.
|
||||
|
||||
Filtri supportati:
|
||||
mailbox_id: str | list[str] – UUID casella
|
||||
direction: str – "inbound" | "outbound"
|
||||
pec_type: str | list[str] – tipo messaggio PEC
|
||||
"""
|
||||
if not filter_data:
|
||||
return True
|
||||
|
||||
# Filtro per mailbox
|
||||
if "mailbox_id" in filter_data:
|
||||
allowed = filter_data["mailbox_id"]
|
||||
if isinstance(allowed, list):
|
||||
if str(message.mailbox_id) not in [str(a) for a in allowed]:
|
||||
return False
|
||||
else:
|
||||
if str(message.mailbox_id) != str(allowed):
|
||||
return False
|
||||
|
||||
# Filtro per direzione
|
||||
if "direction" in filter_data:
|
||||
if message.direction != filter_data["direction"]:
|
||||
return False
|
||||
|
||||
# Filtro per tipo PEC
|
||||
if "pec_type" in filter_data:
|
||||
allowed = filter_data["pec_type"]
|
||||
if isinstance(allowed, list):
|
||||
if message.pec_type not in allowed:
|
||||
return False
|
||||
else:
|
||||
if message.pec_type != allowed:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ─── Costruzione testo notifica ───────────────────────────────────────────────
|
||||
|
||||
def _build_notification_text(
|
||||
event_type: str,
|
||||
payload: dict,
|
||||
channel_type: str,
|
||||
channel_name: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Costruisce il testo della notifica in base all'evento e al tipo canale.
|
||||
"""
|
||||
if event_type == "new_message":
|
||||
subject = payload.get("subject") or "(senza oggetto)"
|
||||
from_addr = payload.get("from_address") or "mittente sconosciuto"
|
||||
mailbox_email = payload.get("mailbox_email") or payload.get("mailbox_id", "")
|
||||
pec_type = payload.get("pec_type", "posta_certificata")
|
||||
received_at = payload.get("received_at", "")
|
||||
|
||||
# Traduzione tipo PEC
|
||||
pec_type_labels = {
|
||||
"posta_certificata": "PEC",
|
||||
"accettazione": "Ricevuta di Accettazione",
|
||||
"non_accettazione": "Ricevuta di Non Accettazione",
|
||||
"presa_in_carico": "Ricevuta di Presa in Carico",
|
||||
"avvenuta_consegna": "Ricevuta di Avvenuta Consegna",
|
||||
"mancata_consegna": "Ricevuta di Mancata Consegna",
|
||||
"errore_consegna": "Ricevuta di Errore Consegna",
|
||||
"preavviso_mancata_consegna": "Preavviso Mancata Consegna",
|
||||
"rilevazione_virus": "Rilevazione Virus",
|
||||
"unknown": "Messaggio",
|
||||
}
|
||||
tipo_label = pec_type_labels.get(pec_type, pec_type)
|
||||
|
||||
if channel_type == "telegram":
|
||||
return (
|
||||
f"<b>Nuovo messaggio PEC</b>\n\n"
|
||||
f"<b>Tipo:</b> {tipo_label}\n"
|
||||
f"<b>Da:</b> {from_addr}\n"
|
||||
f"<b>Casella:</b> {mailbox_email}\n"
|
||||
f"<b>Oggetto:</b> {subject}"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"Nuovo messaggio PEC\n"
|
||||
f"Tipo: {tipo_label}\n"
|
||||
f"Da: {from_addr}\n"
|
||||
f"Casella: {mailbox_email}\n"
|
||||
f"Oggetto: {subject}"
|
||||
)
|
||||
|
||||
elif event_type == "state_changed":
|
||||
message_id = payload.get("message_id", "")
|
||||
old_state = payload.get("old_state", "")
|
||||
new_state = payload.get("new_state", "")
|
||||
subject = payload.get("subject", "")
|
||||
return (
|
||||
f"Cambio stato PEC\n"
|
||||
f"Oggetto: {subject}\n"
|
||||
f"Stato: {old_state} -> {new_state}"
|
||||
)
|
||||
|
||||
else:
|
||||
return f"Evento PEChub: {event_type}\n{json.dumps(payload, ensure_ascii=False, default=str)}"
|
||||
|
||||
|
||||
def _build_notification_payload(event_type: str, payload: dict) -> dict:
|
||||
"""Costruisce il payload JSON per il webhook."""
|
||||
return {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": payload,
|
||||
}
|
||||
|
||||
|
||||
def _build_email_subject(event_type: str, payload: dict) -> str:
|
||||
"""Costruisce l'oggetto dell'email di notifica."""
|
||||
if event_type == "new_message":
|
||||
subject = payload.get("subject") or "(senza oggetto)"
|
||||
return f"[PEChub] Nuovo messaggio PEC: {subject}"
|
||||
elif event_type == "state_changed":
|
||||
return f"[PEChub] Cambio stato PEC: {payload.get('new_state', '')}"
|
||||
return f"[PEChub] Evento: {event_type}"
|
||||
|
||||
|
||||
# ─── Evaluate & Enqueue ───────────────────────────────────────────────────────
|
||||
|
||||
async def evaluate_and_enqueue_notifications(
|
||||
message: Message,
|
||||
mailbox: Mailbox,
|
||||
db: Any, # AsyncSession – evito import circolare
|
||||
redis_client: Any, # ArqRedis
|
||||
) -> None:
|
||||
"""
|
||||
Valuta le regole di notifica per un messaggio appena salvato e accoda i job.
|
||||
|
||||
Chiamata da sync.py dopo _save_message e index_message.
|
||||
Non solleva eccezioni: gli errori vengono loggati ma non propagati per
|
||||
non interrompere il flusso di sincronizzazione IMAP.
|
||||
|
||||
Args:
|
||||
message: messaggio appena salvato nel DB (flush, non commit)
|
||||
mailbox: casella di appartenenza
|
||||
db: sessione DB (open, con flush del messaggio)
|
||||
redis_client: ArqRedis per enqueue_job
|
||||
"""
|
||||
try:
|
||||
await _do_evaluate_and_enqueue(message, mailbox, db, redis_client)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"Errore evaluate_and_enqueue_notifications per messaggio "
|
||||
f"{message.id}: {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _do_evaluate_and_enqueue(
|
||||
message: Message,
|
||||
mailbox: Mailbox,
|
||||
db: Any,
|
||||
redis_client: Any,
|
||||
) -> None:
|
||||
"""Logica interna – puo' sollevare eccezioni."""
|
||||
|
||||
# Carica regole attive per questo tenant con event_type = "new_message"
|
||||
rules_result = await db.execute(
|
||||
select(NotificationRule)
|
||||
.options(selectinload(NotificationRule.channel))
|
||||
.where(
|
||||
NotificationRule.tenant_id == message.tenant_id,
|
||||
NotificationRule.is_active == True, # noqa: E712
|
||||
NotificationRule.event_type == "new_message",
|
||||
)
|
||||
)
|
||||
rules: list[NotificationRule] = list(rules_result.scalars().all())
|
||||
|
||||
if not rules:
|
||||
return
|
||||
|
||||
enqueued_count = 0
|
||||
|
||||
for rule in rules:
|
||||
channel = rule.channel
|
||||
if not channel or not channel.is_active:
|
||||
continue
|
||||
|
||||
# Controlla circuit breaker
|
||||
if (
|
||||
channel.circuit_open_until
|
||||
and channel.circuit_open_until > datetime.now(UTC)
|
||||
):
|
||||
logger.debug(
|
||||
f"[notify] Canale {channel.name!r} circuit aperto fino a "
|
||||
f"{channel.circuit_open_until}, skip regola {rule.name!r}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Applica filtri
|
||||
if not _matches_filter(rule.filter, message, mailbox):
|
||||
continue
|
||||
|
||||
# Costruisce il payload dell'evento
|
||||
event_payload = {
|
||||
"message_id": str(message.id),
|
||||
"mailbox_id": str(mailbox.id),
|
||||
"mailbox_email": mailbox.email_address,
|
||||
"subject": message.subject or "",
|
||||
"from_address": message.from_address or "",
|
||||
"to_addresses": list(message.to_addresses or []),
|
||||
"pec_type": message.pec_type,
|
||||
"direction": message.direction,
|
||||
"received_at": (
|
||||
message.received_at.isoformat()
|
||||
if message.received_at
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
# Crea NotificationLog
|
||||
log = NotificationLog(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=message.tenant_id,
|
||||
channel_id=rule.channel_id,
|
||||
rule_id=rule.id,
|
||||
event_type="new_message",
|
||||
event_payload=event_payload,
|
||||
status="pending",
|
||||
attempt_count=0,
|
||||
max_attempts=3,
|
||||
)
|
||||
db.add(log)
|
||||
await db.flush() # ottieni log.id
|
||||
|
||||
# Enqueue arq job con defer 5s per attendere il commit DB
|
||||
try:
|
||||
await redis_client.enqueue_job(
|
||||
"dispatch_notification",
|
||||
str(log.id),
|
||||
_defer_by=timedelta(seconds=5),
|
||||
)
|
||||
enqueued_count += 1
|
||||
logger.info(
|
||||
f"[notify] Enqueued dispatch_notification per regola "
|
||||
f"{rule.name!r} -> canale {channel.name!r} "
|
||||
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:
|
||||
logger.info(
|
||||
f"[notify] Messaggio {message.id}: "
|
||||
f"{enqueued_count} notifiche accodate"
|
||||
)
|
||||
|
||||
|
||||
# ─── Job arq principale ───────────────────────────────────────────────────────
|
||||
|
||||
async def dispatch_notification(
|
||||
ctx: dict[str, Any],
|
||||
notification_log_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Job arq: legge un NotificationLog e invia la notifica al canale configurato.
|
||||
|
||||
Args:
|
||||
ctx: contesto arq (ctx["redis"] = ArqRedis)
|
||||
notification_log_id: UUID del NotificationLog da processare
|
||||
|
||||
Returns:
|
||||
dict con status e dettagli
|
||||
"""
|
||||
redis_client = ctx.get("redis")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
log_uuid = uuid.UUID(notification_log_id)
|
||||
|
||||
# ── Carica log + canale ───────────────────────────────────────────────
|
||||
log_result = await db.execute(
|
||||
select(NotificationLog)
|
||||
.options(selectinload(NotificationLog.channel))
|
||||
.where(NotificationLog.id == log_uuid)
|
||||
)
|
||||
log: NotificationLog | None = log_result.scalar_one_or_none()
|
||||
|
||||
if not log:
|
||||
logger.warning(
|
||||
f"[dispatch_notification] NotificationLog {notification_log_id} non trovato"
|
||||
)
|
||||
return {"status": "not_found", "log_id": notification_log_id}
|
||||
|
||||
if log.status in ("sent", "failed", "skipped"):
|
||||
logger.debug(
|
||||
f"[dispatch_notification] Log {notification_log_id} "
|
||||
f"gia' in stato {log.status!r}, skip"
|
||||
)
|
||||
return {"status": "already_processed", "current_status": log.status}
|
||||
|
||||
channel = log.channel
|
||||
if not channel or not channel.is_active:
|
||||
log.status = "skipped"
|
||||
await db.commit()
|
||||
return {"status": "skipped", "reason": "channel_inactive"}
|
||||
|
||||
# ── Circuit breaker ───────────────────────────────────────────────────
|
||||
if (
|
||||
channel.circuit_open_until
|
||||
and channel.circuit_open_until > datetime.now(UTC)
|
||||
):
|
||||
log.status = "skipped"
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"[dispatch_notification] Canale {channel.name!r} circuit aperto, "
|
||||
f"log {notification_log_id} marcato skipped"
|
||||
)
|
||||
return {"status": "skipped", "reason": "circuit_open"}
|
||||
|
||||
# ── Incrementa contatore tentativi ────────────────────────────────────
|
||||
log.attempt_count += 1
|
||||
current_attempt = log.attempt_count
|
||||
|
||||
# ── Decifra config sensibile ──────────────────────────────────────────
|
||||
secret: dict = {}
|
||||
if channel.config_enc:
|
||||
try:
|
||||
secret = _decrypt_config(channel.config_enc)
|
||||
except Exception as e:
|
||||
log.status = "failed"
|
||||
log.last_error = f"Errore decifratura config: {e}"
|
||||
await db.commit()
|
||||
logger.error(
|
||||
f"[dispatch_notification] Errore decifratura canale "
|
||||
f"{channel.name!r}: {e}"
|
||||
)
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
config = channel.config or {}
|
||||
payload = log.event_payload or {}
|
||||
channel_type = channel.channel_type
|
||||
|
||||
# ── Testo notifica ────────────────────────────────────────────────────
|
||||
notif_text = _build_notification_text(
|
||||
event_type=log.event_type,
|
||||
payload=payload,
|
||||
channel_type=channel_type,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
|
||||
# ── Dispatch al sender ────────────────────────────────────────────────
|
||||
success = False
|
||||
error_msg: str | None = None
|
||||
http_status: int | None = None
|
||||
|
||||
try:
|
||||
if channel_type == "telegram":
|
||||
bot_token = secret.get("bot_token")
|
||||
chat_id = str(config.get("chat_id", ""))
|
||||
if not bot_token or not chat_id:
|
||||
raise ValueError("bot_token o chat_id non configurati")
|
||||
|
||||
from app.notifications.telegram import send_message
|
||||
result = await send_message(
|
||||
bot_token=bot_token,
|
||||
chat_id=chat_id,
|
||||
text=notif_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
http_status = 200
|
||||
success = True
|
||||
logger.info(
|
||||
f"[dispatch_notification] Telegram inviato: "
|
||||
f"message_id={result.get('message_id')} "
|
||||
f"canale={channel.name!r}"
|
||||
)
|
||||
|
||||
elif channel_type == "webhook":
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
raise ValueError("URL webhook non configurato")
|
||||
webhook_secret_val = secret.get("webhook_secret")
|
||||
|
||||
from app.notifications.webhook import send_webhook
|
||||
result = await send_webhook(
|
||||
url=url,
|
||||
payload=_build_notification_payload(log.event_type, payload),
|
||||
event_type=log.event_type,
|
||||
webhook_secret=webhook_secret_val,
|
||||
)
|
||||
http_status = result.get("http_status")
|
||||
success = True
|
||||
logger.info(
|
||||
f"[dispatch_notification] Webhook inviato: "
|
||||
f"HTTP {http_status} delivery={result.get('delivery_id')} "
|
||||
f"canale={channel.name!r}"
|
||||
)
|
||||
|
||||
elif channel_type == "email":
|
||||
smtp_host = config.get("smtp_host")
|
||||
smtp_port = int(config.get("smtp_port", 465))
|
||||
from_email = config.get("from_email")
|
||||
to_email = config.get("to_email")
|
||||
smtp_user = config.get("smtp_user") or from_email
|
||||
use_tls = config.get("smtp_use_tls", True)
|
||||
use_starttls = config.get("smtp_use_starttls", False)
|
||||
from_name = config.get("from_name", "PEChub Notifiche")
|
||||
smtp_password = secret.get("smtp_password", "")
|
||||
|
||||
if not smtp_host or not from_email or not to_email:
|
||||
raise ValueError("Configurazione SMTP incompleta")
|
||||
|
||||
from app.notifications.email_smtp import send_email_notification
|
||||
subject = _build_email_subject(log.event_type, payload)
|
||||
await send_email_notification(
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=smtp_port,
|
||||
smtp_user=smtp_user,
|
||||
smtp_password=smtp_password,
|
||||
from_email=from_email,
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_text=notif_text,
|
||||
body_html=None,
|
||||
from_name=from_name,
|
||||
use_tls=use_tls,
|
||||
use_starttls=use_starttls,
|
||||
)
|
||||
http_status = 200
|
||||
success = True
|
||||
logger.info(
|
||||
f"[dispatch_notification] Email inviata a {to_email} "
|
||||
f"canale={channel.name!r}"
|
||||
)
|
||||
|
||||
elif channel_type == "whatsapp":
|
||||
phone_number_id = config.get("phone_number_id")
|
||||
to_phone = config.get("to_phone")
|
||||
access_token = secret.get("access_token")
|
||||
|
||||
if not phone_number_id or not to_phone or not access_token:
|
||||
raise ValueError("Configurazione WhatsApp incompleta")
|
||||
|
||||
from app.notifications.whatsapp import send_whatsapp_message
|
||||
result = await send_whatsapp_message(
|
||||
phone_number_id=phone_number_id,
|
||||
to_phone=to_phone,
|
||||
text=notif_text,
|
||||
access_token=access_token,
|
||||
)
|
||||
http_status = result.get("http_status")
|
||||
success = True
|
||||
logger.info(
|
||||
f"[dispatch_notification] WhatsApp inviato: "
|
||||
f"message_id={result.get('message_id')} "
|
||||
f"canale={channel.name!r}"
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Tipo canale non supportato: {channel_type!r}")
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
logger.warning(
|
||||
f"[dispatch_notification] Tentativo {current_attempt} fallito "
|
||||
f"canale={channel.name!r} tipo={channel_type!r}: {error_msg}"
|
||||
)
|
||||
|
||||
# ── Aggiorna stato log e canale ───────────────────────────────────────
|
||||
|
||||
if success:
|
||||
log.status = "sent"
|
||||
log.sent_at = datetime.now(UTC)
|
||||
log.http_status = http_status
|
||||
|
||||
# Reset circuit breaker
|
||||
channel.consecutive_failures = 0
|
||||
channel.circuit_open_until = None
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"[dispatch_notification] Notifica {notification_log_id} INVIATA "
|
||||
f"canale={channel.name!r} tipo={channel_type!r}"
|
||||
)
|
||||
return {
|
||||
"status": "sent",
|
||||
"log_id": notification_log_id,
|
||||
"channel": channel.name,
|
||||
"channel_type": channel_type,
|
||||
"attempt": current_attempt,
|
||||
}
|
||||
|
||||
else:
|
||||
# ── Retry o failure definitivo ────────────────────────────────────
|
||||
log.last_error = error_msg
|
||||
log.http_status = http_status
|
||||
|
||||
if current_attempt < log.max_attempts:
|
||||
# Calcola delay backoff
|
||||
delay_idx = min(current_attempt - 1, len(_RETRY_DELAYS) - 1)
|
||||
delay_seconds = _RETRY_DELAYS[delay_idx]
|
||||
next_retry = datetime.now(UTC) + timedelta(seconds=delay_seconds)
|
||||
|
||||
log.next_retry_at = next_retry
|
||||
# Mantieni status "pending" per il retry
|
||||
await db.commit()
|
||||
|
||||
# Re-enqueue con backoff
|
||||
if redis_client:
|
||||
try:
|
||||
await redis_client.enqueue_job(
|
||||
"dispatch_notification",
|
||||
notification_log_id,
|
||||
_defer_by=timedelta(seconds=delay_seconds),
|
||||
)
|
||||
logger.info(
|
||||
f"[dispatch_notification] Retry tentativo "
|
||||
f"{current_attempt + 1} schedulato in {delay_seconds}s "
|
||||
f"per log {notification_log_id}"
|
||||
)
|
||||
except Exception as enqueue_err:
|
||||
logger.error(
|
||||
f"[dispatch_notification] Impossibile re-enqueue "
|
||||
f"log {notification_log_id}: {enqueue_err}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "retry",
|
||||
"log_id": notification_log_id,
|
||||
"attempt": current_attempt,
|
||||
"next_retry_in_seconds": delay_seconds,
|
||||
"error": error_msg,
|
||||
}
|
||||
|
||||
else:
|
||||
# Tutti i tentativi esauriti → FAILED
|
||||
log.status = "failed"
|
||||
|
||||
# Aggiorna circuit breaker canale
|
||||
channel.consecutive_failures += 1
|
||||
if channel.consecutive_failures >= _CIRCUIT_FAILURE_THRESHOLD:
|
||||
channel.circuit_open_until = datetime.now(UTC) + _CIRCUIT_OPEN_DURATION
|
||||
logger.warning(
|
||||
f"[dispatch_notification] Circuit breaker aperto per "
|
||||
f"canale {channel.name!r} "
|
||||
f"({channel.consecutive_failures} fallimenti consecutivi)"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.error(
|
||||
f"[dispatch_notification] Notifica {notification_log_id} FALLITA "
|
||||
f"definitivamente dopo {current_attempt} tentativi: {error_msg}"
|
||||
)
|
||||
return {
|
||||
"status": "failed",
|
||||
"log_id": notification_log_id,
|
||||
"channel": channel.name,
|
||||
"attempts": current_attempt,
|
||||
"error": error_msg,
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
"""
|
||||
Indicizzazione full-text dei messaggi PEC.
|
||||
|
||||
Responsabilita':
|
||||
1. Scarica gli allegati da MinIO
|
||||
2. Estrae il testo in base al formato del file
|
||||
3. Aggiorna la colonna extracted_text in attachments
|
||||
4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati
|
||||
|
||||
Formati supportati:
|
||||
- PDF (.pdf) tramite pypdf
|
||||
- Word (.docx, .doc) tramite python-docx
|
||||
- Excel (.xlsx, .xls) tramite openpyxl
|
||||
- PowerPoint(.pptx, .ppt) tramite python-pptx
|
||||
- LibreOffice (.odt, .ods, .odp) tramite odfpy
|
||||
- RTF (.rtf) tramite striprtf
|
||||
- Testo (.txt, .csv, .xml, .html, .htm) testo grezzo
|
||||
- Email (.eml, .msg) tramite stdlib email
|
||||
- Firmati (.p7m) unwrap CMS poi estrae in base all'estensione interna
|
||||
|
||||
Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante:
|
||||
un'eccezione qui non interrompe la sincronizzazione del messaggio.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dimensione massima del testo estratto per allegato (caratteri)
|
||||
MAX_EXTRACTED_TEXT_LEN = 50_000
|
||||
# Dimensione massima del testo aggregato degli allegati per il search_vector
|
||||
MAX_COMBINED_TEXT_LEN = 200_000
|
||||
|
||||
|
||||
# ─── Rilevamento tipo file ────────────────────────────────────────────────────
|
||||
|
||||
def _ext(filename: str | None) -> str:
|
||||
"""Restituisce l'estensione del file in minuscolo, senza punto."""
|
||||
if not filename:
|
||||
return ""
|
||||
fn = filename.lower()
|
||||
# Gestione doppia estensione es. documento.pdf.p7m
|
||||
if fn.endswith(".p7m"):
|
||||
return "p7m"
|
||||
idx = fn.rfind(".")
|
||||
return fn[idx + 1:] if idx >= 0 else ""
|
||||
|
||||
|
||||
def _is_extractable(content_type: str | None, filename: str | None) -> bool:
|
||||
"""Ritorna True se il formato e' supportato dall'estrattore."""
|
||||
ct = (content_type or "").lower()
|
||||
e = _ext(filename)
|
||||
return e in _EXTRACTORS or ct in _CONTENT_TYPE_MAP
|
||||
|
||||
|
||||
def _resolve_extractor(content_type: str | None, filename: str | None):
|
||||
"""Ritorna la funzione estrattore appropriata, o None."""
|
||||
e = _ext(filename)
|
||||
if e in _EXTRACTORS:
|
||||
return _EXTRACTORS[e]
|
||||
ct = (content_type or "").lower()
|
||||
if ct in _CONTENT_TYPE_MAP:
|
||||
return _EXTRACTORS.get(_CONTENT_TYPE_MAP[ct])
|
||||
return None
|
||||
|
||||
|
||||
# ─── Estrattori ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR.
|
||||
# Un PDF di testo reale produce migliaia di caratteri; una scansione ne produce
|
||||
# zero o pochissimi (artefatti). 50 char e' un valore conservativo sicuro.
|
||||
_PDF_OCR_THRESHOLD = 50
|
||||
|
||||
# Numero massimo di pagine su cui eseguire OCR per evitare timeout su PDF lunghi.
|
||||
_PDF_OCR_MAX_PAGES = 15
|
||||
|
||||
|
||||
def _extract_pdf(content: bytes) -> str:
|
||||
"""
|
||||
Estrae testo da PDF tramite pypdf.
|
||||
|
||||
Se il testo estratto e' inferiore a _PDF_OCR_THRESHOLD caratteri (PDF
|
||||
image-only / scansione), attiva il fallback OCR via Tesseract.
|
||||
"""
|
||||
try:
|
||||
import pypdf # type: ignore[import]
|
||||
reader = pypdf.PdfReader(io.BytesIO(content))
|
||||
parts: list[str] = []
|
||||
for page in reader.pages:
|
||||
try:
|
||||
t = page.extract_text()
|
||||
if t:
|
||||
parts.append(t)
|
||||
except Exception:
|
||||
continue
|
||||
text = " ".join(parts)
|
||||
except ImportError:
|
||||
logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione PDF: {e}")
|
||||
return ""
|
||||
|
||||
# Se il testo e' troppo corto, il PDF e' probabilmente una scansione
|
||||
if len(text.strip()) < _PDF_OCR_THRESHOLD:
|
||||
logger.debug(
|
||||
f"PDF con testo insufficiente ({len(text.strip())} char), "
|
||||
"tentativo OCR..."
|
||||
)
|
||||
ocr_text = _extract_pdf_ocr(content)
|
||||
if len(ocr_text.strip()) > len(text.strip()):
|
||||
return ocr_text
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _extract_pdf_ocr(content: bytes) -> str:
|
||||
"""
|
||||
OCR su PDF image-only tramite pdf2image + Tesseract.
|
||||
|
||||
Converte le pagine del PDF in immagini PIL a 200 DPI (buon compromesso
|
||||
qualita'/velocita' su CPU) e applica Tesseract con lingua italiana + inglese.
|
||||
Processa al massimo _PDF_OCR_MAX_PAGES pagine per evitare timeout.
|
||||
"""
|
||||
try:
|
||||
from pdf2image import convert_from_bytes # type: ignore[import]
|
||||
import pytesseract # type: ignore[import]
|
||||
|
||||
pages = convert_from_bytes(
|
||||
content,
|
||||
dpi=200,
|
||||
last_page=_PDF_OCR_MAX_PAGES,
|
||||
)
|
||||
parts: list[str] = []
|
||||
for page_img in pages:
|
||||
try:
|
||||
t = pytesseract.image_to_string(page_img, lang="ita+eng")
|
||||
if t and t.strip():
|
||||
parts.append(t.strip())
|
||||
except Exception:
|
||||
continue
|
||||
return " ".join(parts)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"pdf2image o pytesseract non installati: impossibile OCR PDF"
|
||||
)
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore OCR PDF: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_image_ocr(content: bytes) -> str:
|
||||
"""
|
||||
Estrae testo da un file immagine (PNG, JPEG, TIFF, BMP, ecc.) tramite OCR.
|
||||
|
||||
Usa Tesseract con lingua italiana + inglese per massima copertura
|
||||
su documenti italiani.
|
||||
"""
|
||||
try:
|
||||
import pytesseract # type: ignore[import]
|
||||
from PIL import Image # type: ignore[import]
|
||||
|
||||
img = Image.open(io.BytesIO(content))
|
||||
# Converti in RGB se necessario (TIFF multi-frame, palette, ecc.)
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
text = pytesseract.image_to_string(img, lang="ita+eng")
|
||||
return " ".join(text.split())
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"pytesseract o Pillow non installati: impossibile OCR immagine"
|
||||
)
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore OCR immagine: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_docx(content: bytes) -> str:
|
||||
"""Estrae testo da DOCX/DOC tramite python-docx."""
|
||||
try:
|
||||
import docx # type: ignore[import]
|
||||
doc = docx.Document(io.BytesIO(content))
|
||||
parts = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
|
||||
# Include anche le tabelle
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
if cell.text and cell.text.strip():
|
||||
parts.append(cell.text.strip())
|
||||
return " ".join(parts)
|
||||
except ImportError:
|
||||
logger.warning("python-docx non installato: impossibile estrarre testo da DOCX")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione DOCX: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_xlsx(content: bytes) -> str:
|
||||
"""Estrae testo da XLSX/XLS tramite openpyxl."""
|
||||
try:
|
||||
import openpyxl # type: ignore[import]
|
||||
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||
parts: list[str] = []
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value is not None:
|
||||
v = str(cell.value).strip()
|
||||
if v:
|
||||
parts.append(v)
|
||||
return " ".join(parts)
|
||||
except ImportError:
|
||||
logger.warning("openpyxl non installato: impossibile estrarre testo da XLSX")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione XLSX: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_pptx(content: bytes) -> str:
|
||||
"""Estrae testo da PPTX/PPT tramite python-pptx."""
|
||||
try:
|
||||
from pptx import Presentation # type: ignore[import]
|
||||
prs = Presentation(io.BytesIO(content))
|
||||
parts: list[str] = []
|
||||
for slide in prs.slides:
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
for para in shape.text_frame.paragraphs:
|
||||
t = para.text.strip()
|
||||
if t:
|
||||
parts.append(t)
|
||||
return " ".join(parts)
|
||||
except ImportError:
|
||||
logger.warning("python-pptx non installato: impossibile estrarre testo da PPTX")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione PPTX: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_odt(content: bytes) -> str:
|
||||
"""Estrae testo da ODT/ODS/ODP tramite odfpy."""
|
||||
try:
|
||||
from odf import opendocument, teletype # type: ignore[import]
|
||||
from odf.text import P # type: ignore[import]
|
||||
doc = opendocument.load(io.BytesIO(content))
|
||||
parts: list[str] = []
|
||||
for el in doc.body.getElementsByType(P):
|
||||
t = teletype.extractText(el).strip()
|
||||
if t:
|
||||
parts.append(t)
|
||||
return " ".join(parts)
|
||||
except ImportError:
|
||||
logger.warning("odfpy non installato: impossibile estrarre testo da ODT")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione ODT: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_rtf(content: bytes) -> str:
|
||||
"""Estrae testo da RTF tramite striprtf."""
|
||||
try:
|
||||
from striprtf.striprtf import rtf_to_text # type: ignore[import]
|
||||
raw = content.decode("latin-1", errors="replace")
|
||||
return rtf_to_text(raw)
|
||||
except ImportError:
|
||||
logger.warning("striprtf non installato: impossibile estrarre testo da RTF")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione RTF: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_plain(content: bytes) -> str:
|
||||
"""Estrae testo da file di testo puro (txt, csv, xml, html, ecc.)."""
|
||||
try:
|
||||
# Prova UTF-8 prima, poi latin-1 come fallback
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = content.decode("latin-1", errors="replace")
|
||||
# Per XML/HTML: rimuove i tag
|
||||
if "<" in text and ">" in text:
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"&[a-zA-Z]+;", " ", text)
|
||||
return " ".join(text.split())
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione testo plain: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_eml(content: bytes) -> str:
|
||||
"""Estrae testo da un file EML allegato."""
|
||||
try:
|
||||
import email as emaillib
|
||||
msg = emaillib.message_from_bytes(content)
|
||||
parts: list[str] = []
|
||||
subject = msg.get("Subject", "")
|
||||
if subject:
|
||||
parts.append(subject)
|
||||
sender = msg.get("From", "")
|
||||
if sender:
|
||||
parts.append(sender)
|
||||
# Estrae body
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
if ct == "text/plain":
|
||||
try:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
parts.append(payload.decode(charset, errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
parts.append(payload.decode(charset, errors="replace")) # type: ignore[arg-type]
|
||||
return " ".join(parts)
|
||||
except Exception as e:
|
||||
logger.debug(f"Errore estrazione EML: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_p7m(content: bytes, original_filename: str | None = None) -> str:
|
||||
"""
|
||||
Estrae testo da un documento con firma digitale CAdES (.p7m).
|
||||
|
||||
Prova a fare l'unwrap del CMS envelope tramite la libreria cryptography
|
||||
(gia' presente nel worker). Se l'unwrap ha successo, determina il formato
|
||||
del documento interno dall'estensione del nome originale (es. fattura.pdf.p7m
|
||||
-> PDF) e applica l'estrattore appropriato.
|
||||
"""
|
||||
inner_content: bytes | None = None
|
||||
|
||||
# Metodo 1: cryptography (CMS/PKCS7)
|
||||
try:
|
||||
from cryptography.hazmat.primitives.serialization import pkcs7 # type: ignore[import]
|
||||
# load_pem_pkcs7_certificates / load_der_pkcs7_certificates non espongono il payload
|
||||
# Usiamo il modulo backend direttamente
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
||||
from cryptography.x509 import load_der_x509_certificate # noqa: F401
|
||||
|
||||
# Prova parsing DER diretto della struttura CMS ContentInfo
|
||||
# La struttura ASN.1 di SignedData contiene encapContentInfo -> eContent
|
||||
from cryptography.hazmat.bindings._rust import ( # type: ignore[import]
|
||||
x509 as rust_x509,
|
||||
)
|
||||
_ = rust_x509 # solo per verificare import
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Metodo piu' semplice: parsing ASN.1 manuale per estrarre eContent
|
||||
# La struttura DER di CMS SignedData:
|
||||
# SEQUENCE {
|
||||
# OID (signedData)
|
||||
# [0] EXPLICIT SEQUENCE {
|
||||
# INTEGER (version)
|
||||
# SET (digestAlgorithms)
|
||||
# SEQUENCE (encapContentInfo) {
|
||||
# OID (contentType = data)
|
||||
# [0] EXPLICIT OCTET STRING (eContent) <- questo e' il contenuto originale
|
||||
# }
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
try:
|
||||
inner_content = _unwrap_p7m_asn1(content)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unwrap P7M ASN1 fallito: {e}")
|
||||
|
||||
if not inner_content:
|
||||
logger.debug("Impossibile estrarre contenuto dal file .p7m")
|
||||
return ""
|
||||
|
||||
# Determina l'estensione interna dal nome file originale
|
||||
# es. "fattura.pdf.p7m" -> inner ext = "pdf"
|
||||
inner_ext = ""
|
||||
if original_filename:
|
||||
fn = original_filename.lower()
|
||||
if fn.endswith(".p7m"):
|
||||
fn = fn[:-4] # rimuove .p7m
|
||||
idx = fn.rfind(".")
|
||||
if idx >= 0:
|
||||
inner_ext = fn[idx + 1:]
|
||||
|
||||
extractor = _EXTRACTORS.get(inner_ext)
|
||||
if extractor:
|
||||
logger.debug(f"P7M: estrazione interna come {inner_ext!r}")
|
||||
return extractor(inner_content)
|
||||
|
||||
# Fallback: prova a riconoscere il formato dall'header del contenuto
|
||||
if inner_content[:4] == b"%PDF":
|
||||
return _extract_pdf(inner_content)
|
||||
if inner_content[:2] in (b"PK",): # ZIP-based (docx, xlsx, pptx, odt)
|
||||
# Prova nell'ordine piu' comune
|
||||
for fn in (_extract_docx, _extract_xlsx, _extract_pptx, _extract_odt):
|
||||
result = fn(inner_content)
|
||||
if result.strip():
|
||||
return result
|
||||
# Ultimo tentativo: plain text
|
||||
return _extract_plain(inner_content)
|
||||
|
||||
|
||||
def _unwrap_p7m_asn1(data: bytes) -> bytes | None:
|
||||
"""
|
||||
Parsing ASN.1 DER minimale per estrarre eContent da una struttura CMS SignedData.
|
||||
Non verifica la firma: serve solo per l'estrazione del testo.
|
||||
"""
|
||||
pos = 0
|
||||
length = len(data)
|
||||
|
||||
def read_tag_length(buf: bytes, offset: int) -> tuple[int, int, int]:
|
||||
"""Ritorna (tag, length, new_offset)."""
|
||||
tag = buf[offset]
|
||||
offset += 1
|
||||
lb = buf[offset]
|
||||
offset += 1
|
||||
if lb & 0x80:
|
||||
num_bytes = lb & 0x7F
|
||||
ln = int.from_bytes(buf[offset:offset + num_bytes], "big")
|
||||
offset += num_bytes
|
||||
else:
|
||||
ln = lb
|
||||
return tag, ln, offset
|
||||
|
||||
# outer SEQUENCE
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x30:
|
||||
return None
|
||||
|
||||
# OID (contentType = signedData)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x06:
|
||||
return None
|
||||
pos += ln # skip OID value
|
||||
|
||||
# [0] EXPLICIT
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0xA0:
|
||||
return None
|
||||
|
||||
# SEQUENCE (SignedData)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x30:
|
||||
return None
|
||||
|
||||
# INTEGER (version)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x02:
|
||||
return None
|
||||
pos += ln
|
||||
|
||||
# SET (digestAlgorithms)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x31:
|
||||
return None
|
||||
pos += ln
|
||||
|
||||
# SEQUENCE (encapContentInfo)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x30:
|
||||
return None
|
||||
|
||||
# OID (contentType dentro encapContentInfo)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x06:
|
||||
return None
|
||||
pos += ln
|
||||
|
||||
# [0] EXPLICIT (eContent, opzionale)
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0xA0:
|
||||
return None
|
||||
|
||||
# OCTET STRING con il contenuto originale
|
||||
tag, ln, pos = read_tag_length(data, pos)
|
||||
if tag != 0x04:
|
||||
return None
|
||||
return data[pos: pos + ln]
|
||||
|
||||
|
||||
# ─── Mapping formato -> estrattore ────────────────────────────────────────────
|
||||
|
||||
_EXTRACTORS: dict[str, object] = {
|
||||
# Documenti Office
|
||||
"pdf": _extract_pdf,
|
||||
"docx": _extract_docx,
|
||||
"doc": _extract_docx,
|
||||
"xlsx": _extract_xlsx,
|
||||
"xls": _extract_xlsx,
|
||||
"pptx": _extract_pptx,
|
||||
"ppt": _extract_pptx,
|
||||
# LibreOffice
|
||||
"odt": _extract_odt,
|
||||
"ods": _extract_odt,
|
||||
"odp": _extract_odt,
|
||||
# Testo
|
||||
"txt": _extract_plain,
|
||||
"csv": _extract_plain,
|
||||
"xml": _extract_plain,
|
||||
"html": _extract_plain,
|
||||
"htm": _extract_plain,
|
||||
"json": _extract_plain,
|
||||
# RTF
|
||||
"rtf": _extract_rtf,
|
||||
# Email
|
||||
"eml": _extract_eml,
|
||||
"msg": _extract_eml,
|
||||
# Firma digitale CAdES
|
||||
"p7m": _extract_p7m,
|
||||
# Immagini (OCR)
|
||||
"png": _extract_image_ocr,
|
||||
"jpg": _extract_image_ocr,
|
||||
"jpeg": _extract_image_ocr,
|
||||
"tiff": _extract_image_ocr,
|
||||
"tif": _extract_image_ocr,
|
||||
"bmp": _extract_image_ocr,
|
||||
"gif": _extract_image_ocr,
|
||||
"webp": _extract_image_ocr,
|
||||
}
|
||||
|
||||
# Mapping content-type -> estensione normalizzata (per fallback quando il filename manca)
|
||||
_CONTENT_TYPE_MAP: dict[str, str] = {
|
||||
"application/pdf": "pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||
"application/msword": "doc",
|
||||
"application/vnd.ms-word": "doc",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.ms-excel": "xls",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
||||
"application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.oasis.opendocument.text": "odt",
|
||||
"application/vnd.oasis.opendocument.spreadsheet": "ods",
|
||||
"application/vnd.oasis.opendocument.presentation": "odp",
|
||||
"application/rtf": "rtf",
|
||||
"text/rtf": "rtf",
|
||||
"text/plain": "txt",
|
||||
"text/csv": "csv",
|
||||
"text/xml": "xml",
|
||||
"application/xml": "xml",
|
||||
"text/html": "html",
|
||||
"message/rfc822": "eml",
|
||||
"application/pkcs7-mime": "p7m",
|
||||
"application/x-pkcs7-mime": "p7m",
|
||||
# Immagini (OCR)
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpeg",
|
||||
"image/jpg": "jpeg",
|
||||
"image/tiff": "tiff",
|
||||
"image/bmp": "bmp",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
|
||||
|
||||
# ─── Job principale ───────────────────────────────────────────────────────────
|
||||
|
||||
async def index_message(
|
||||
message_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""
|
||||
Indicizza un messaggio per la ricerca full-text.
|
||||
|
||||
Non solleva eccezioni: tutti gli errori vengono loggati ma non propagati,
|
||||
per non interrompere il flusso di sincronizzazione.
|
||||
"""
|
||||
try:
|
||||
await _do_index_message(message_id, db)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Errore indicizzazione messaggio {message_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _do_index_message(
|
||||
message_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Logica interna di indicizzazione (puo' sollevare eccezioni)."""
|
||||
from app.config import get_settings
|
||||
from app.models import Attachment, Message
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ── Carica il messaggio ───────────────────────────────────────────────────
|
||||
msg_result = await db.execute(
|
||||
select(Message).where(Message.id == message_id)
|
||||
)
|
||||
message = msg_result.scalar_one_or_none()
|
||||
if not message:
|
||||
logger.warning(f"index_message: messaggio {message_id} non trovato in DB")
|
||||
return
|
||||
|
||||
# ── Carica gli allegati ───────────────────────────────────────────────────
|
||||
att_result = await db.execute(
|
||||
select(Attachment).where(Attachment.message_id == message_id)
|
||||
)
|
||||
attachments = list(att_result.scalars().all())
|
||||
|
||||
if not attachments:
|
||||
logger.debug(f"Messaggio {message_id}: nessun allegato, skip indicizzazione allegati")
|
||||
return
|
||||
|
||||
# ── Crea client MinIO ─────────────────────────────────────────────────────
|
||||
try:
|
||||
from miniopy_async import Minio # type: ignore[import]
|
||||
|
||||
minio = Minio(
|
||||
endpoint=settings.minio_endpoint,
|
||||
access_key=settings.minio_access_key,
|
||||
secret_key=settings.minio_secret_key,
|
||||
secure=settings.minio_use_ssl,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossibile creare client MinIO per indicizzazione {message_id}: {e}")
|
||||
return
|
||||
|
||||
bucket = settings.minio_bucket
|
||||
attachment_texts: list[str] = []
|
||||
indexed_count = 0
|
||||
|
||||
for att in attachments:
|
||||
# Se gia' indicizzato, usa il testo cached
|
||||
if att.extracted_text is not None:
|
||||
attachment_texts.append(att.extracted_text)
|
||||
continue
|
||||
|
||||
# Controlla se il formato e' supportato
|
||||
extractor = _resolve_extractor(att.content_type, att.filename)
|
||||
if extractor is None:
|
||||
logger.debug(
|
||||
f"Formato non supportato per indicizzazione: "
|
||||
f"{att.filename!r} ({att.content_type!r})"
|
||||
)
|
||||
continue
|
||||
|
||||
# Scarica da MinIO
|
||||
try:
|
||||
response = await minio.get_object(bucket, att.storage_path)
|
||||
content = await response.content.read()
|
||||
response.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Impossibile scaricare allegato {att.id} "
|
||||
f"({att.filename!r}) da MinIO: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Estrai testo - per p7m passa anche il filename originale
|
||||
try:
|
||||
e = _ext(att.filename)
|
||||
if e == "p7m":
|
||||
extracted = _extract_p7m(content, att.filename)
|
||||
else:
|
||||
extracted = extractor(content) # type: ignore[operator]
|
||||
except Exception as ex:
|
||||
logger.debug(f"Errore estrazione {att.filename!r}: {ex}")
|
||||
continue
|
||||
|
||||
if not extracted or not extracted.strip():
|
||||
logger.debug(f"Nessun testo estratto da {att.filename!r}")
|
||||
continue
|
||||
|
||||
# Limita la dimensione e salva sull'ORM object (col. mappata)
|
||||
att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
|
||||
attachment_texts.append(att.extracted_text)
|
||||
indexed_count += 1
|
||||
logger.debug(
|
||||
f"Testo estratto da {att.filename!r}: "
|
||||
f"{len(att.extracted_text)} caratteri"
|
||||
)
|
||||
|
||||
# ── Aggiorna search_vector includendo il testo degli allegati ─────────────
|
||||
if attachment_texts:
|
||||
combined = " ".join(attachment_texts)[:MAX_COMBINED_TEXT_LEN]
|
||||
|
||||
await db.execute(
|
||||
text("""
|
||||
UPDATE messages
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
|
||||
setweight(to_tsvector('simple',
|
||||
coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
|
||||
setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
|
||||
setweight(to_tsvector('italian', :att_text), 'D')
|
||||
WHERE id = :message_id
|
||||
"""),
|
||||
{"att_text": combined, "message_id": str(message_id)},
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Indicizzazione completata: messaggio {message_id}, "
|
||||
f"{indexed_count} allegati indicizzati su {len(attachments)} totali"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Messaggio {message_id}: nessun allegato con testo estraibile"
|
||||
)
|
||||
+3
-1
@@ -24,6 +24,8 @@ from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
from app.imap.pool import MailboxPool
|
||||
from app.jobs.apply_routing_rules import apply_routing_rules
|
||||
from app.jobs.dispatch_notification import dispatch_notification
|
||||
from app.jobs.send_pec import send_pec
|
||||
from app.jobs.sync_mailbox import sync_mailbox
|
||||
from app.smtp.receipt_watcher import watch_receipt
|
||||
@@ -132,7 +134,7 @@ class WorkerSettings:
|
||||
"""Configurazione del worker arq."""
|
||||
|
||||
# Funzioni/job registrati
|
||||
functions = [sync_mailbox, send_pec, watch_receipt, health_check]
|
||||
functions = [sync_mailbox, send_pec, watch_receipt, dispatch_notification, apply_routing_rules, health_check]
|
||||
|
||||
# Callbacks lifecycle
|
||||
on_startup = on_startup
|
||||
|
||||
+127
-1
@@ -22,7 +22,7 @@ from sqlalchemy import (
|
||||
ARRAY, BigInteger, Boolean, DateTime, Enum, ForeignKey,
|
||||
Integer, String, Text, func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
@@ -194,9 +194,135 @@ class Attachment(Base):
|
||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# Testo estratto dall'indicizzatore full-text per la ricerca
|
||||
extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relazione inversa verso Message
|
||||
message: Mapped["Message"] = relationship("Message", back_populates="attachments")
|
||||
|
||||
|
||||
# ─── Modelli Notifiche ────────────────────────────────────────────────────────
|
||||
|
||||
NotifChannelType = Enum(
|
||||
"webhook", "email", "telegram", "whatsapp",
|
||||
name="notification_channel_type",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
NotifStatus = Enum(
|
||||
"pending", "sent", "failed", "skipped",
|
||||
name="notification_status",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class NotificationChannel(Base):
|
||||
"""
|
||||
Canale di notifica configurato da un tenant.
|
||||
Corrisponde alla tabella `notification_channels` nel DB.
|
||||
"""
|
||||
|
||||
__tablename__ = "notification_channels"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
channel_type: Mapped[str] = mapped_column(NotifChannelType, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
config_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
circuit_open_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
rules: Mapped[list["NotificationRule"]] = relationship(
|
||||
"NotificationRule", back_populates="channel", lazy="select"
|
||||
)
|
||||
logs: Mapped[list["NotificationLog"]] = relationship(
|
||||
"NotificationLog", back_populates="channel", lazy="select"
|
||||
)
|
||||
|
||||
|
||||
class NotificationRule(Base):
|
||||
"""
|
||||
Regola evento PEC -> canale di notifica.
|
||||
Corrisponde alla tabella `notification_rules` nel DB.
|
||||
"""
|
||||
|
||||
__tablename__ = "notification_rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
filter: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
channel: Mapped["NotificationChannel"] = relationship(
|
||||
"NotificationChannel", back_populates="rules"
|
||||
)
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
"""
|
||||
Log di ogni tentativo di notifica con retry e circuit breaker.
|
||||
Corrisponde alla tabella `notification_log` nel DB.
|
||||
"""
|
||||
|
||||
__tablename__ = "notification_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_channels.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
rule_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("notification_rules.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
event_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
status: Mapped[str] = mapped_column(NotifStatus, nullable=False, default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
channel: Mapped["NotificationChannel"] = relationship(
|
||||
"NotificationChannel", back_populates="logs"
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Senders notifiche multi-canale per il worker
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Email SMTP sender (worker) – invio notifiche via aiosmtplib.
|
||||
|
||||
Copia del sender backend: i due container sono separati.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class EmailSMTPError(Exception):
|
||||
def __init__(self, message: str, smtp_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.smtp_code = smtp_code
|
||||
|
||||
|
||||
async def send_email_notification(
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_user: str,
|
||||
smtp_password: str,
|
||||
from_email: str,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: str | None = None,
|
||||
from_name: str = "PEChub Notifiche",
|
||||
use_tls: bool = True,
|
||||
use_starttls: bool = False,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
"""
|
||||
Invia un'email di notifica via SMTP.
|
||||
|
||||
Raises:
|
||||
EmailSMTPError: in caso di errori di autenticazione, connessione o invio
|
||||
"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{from_name} <{from_email}>" if from_name else from_email
|
||||
msg["To"] = to_email
|
||||
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
msg["X-Mailer"] = "PEChub/1.0"
|
||||
|
||||
msg.attach(MIMEText(body_text, "plain", "utf-8"))
|
||||
if body_html:
|
||||
msg.attach(MIMEText(body_html, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=smtp_host,
|
||||
port=smtp_port,
|
||||
username=smtp_user,
|
||||
password=smtp_password,
|
||||
use_tls=use_tls,
|
||||
start_tls=use_starttls,
|
||||
timeout=timeout,
|
||||
)
|
||||
except aiosmtplib.SMTPAuthenticationError as exc:
|
||||
raise EmailSMTPError(
|
||||
f"Autenticazione SMTP fallita: {exc}", smtp_code=535
|
||||
) from exc
|
||||
except aiosmtplib.SMTPConnectError as exc:
|
||||
raise EmailSMTPError(
|
||||
f"Connessione SMTP fallita a {smtp_host}:{smtp_port}: {exc}"
|
||||
) from exc
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise EmailSMTPError(f"Errore SMTP: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise EmailSMTPError(f"Errore invio email: {exc}") from exc
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Telegram Bot API – invio messaggi via sendMessage (worker).
|
||||
|
||||
Copia del sender backend: i due container sono separati e non
|
||||
possono condividere package, quindi il codice e' duplicato.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class TelegramError(Exception):
|
||||
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.http_status = http_status
|
||||
self.api_code = api_code
|
||||
|
||||
|
||||
async def send_message(
|
||||
bot_token: str,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
parse_mode: str = "HTML",
|
||||
disable_web_page_preview: bool = True,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""
|
||||
Invia un messaggio a un canale/gruppo/utente Telegram.
|
||||
|
||||
Returns:
|
||||
dict con il risultato della API Telegram (result.message_id, ecc.)
|
||||
|
||||
Raises:
|
||||
TelegramError: in caso di errore HTTP o risposta API non-ok
|
||||
"""
|
||||
url = f"{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"
|
||||
payload: dict = {"chat_id": chat_id, "text": text}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
if disable_web_page_preview:
|
||||
payload["link_preview_options"] = {"is_disabled": True}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise TelegramError(f"Timeout Telegram ({timeout}s)") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise TelegramError(f"Errore di rete Telegram: {exc}") from exc
|
||||
|
||||
if response.status_code != 200:
|
||||
raise TelegramError(
|
||||
f"Telegram API HTTP {response.status_code}: {response.text[:200]}",
|
||||
http_status=response.status_code,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
api_code = data.get("error_code")
|
||||
description = data.get("description", "Errore sconosciuto")
|
||||
raise TelegramError(
|
||||
f"Telegram API error {api_code}: {description}",
|
||||
http_status=response.status_code,
|
||||
api_code=api_code,
|
||||
)
|
||||
|
||||
return data.get("result", {})
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Webhook sender (worker) – POST HTTP con firma HMAC-SHA256.
|
||||
|
||||
Copia del sender backend: i due container sono separati.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid as uuid_mod
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class WebhookError(Exception):
|
||||
def __init__(self, message: str, http_status: int | None = None):
|
||||
super().__init__(message)
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
async def send_webhook(
|
||||
url: str,
|
||||
payload: dict,
|
||||
event_type: str = "new_message",
|
||||
webhook_secret: str | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""
|
||||
Invia un payload JSON a un webhook URL.
|
||||
|
||||
Returns:
|
||||
dict con http_status, delivery_id
|
||||
|
||||
Raises:
|
||||
WebhookError: in caso di timeout, errore di rete o HTTP >= 400
|
||||
"""
|
||||
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||
delivery_id = str(uuid_mod.uuid4())
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-PEChub-Event": event_type,
|
||||
"X-Delivery": delivery_id,
|
||||
"User-Agent": "PEChub-Webhook/1.0",
|
||||
}
|
||||
|
||||
if webhook_secret:
|
||||
sig = hmac.new(
|
||||
webhook_secret.encode("utf-8"),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
headers["X-Hub-Signature-256"] = f"sha256={sig}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, content=body, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WebhookError(f"Timeout webhook dopo {timeout}s") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise WebhookError(f"Errore di rete webhook: {exc}") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise WebhookError(
|
||||
f"Webhook HTTP {response.status_code}: {response.text[:200]}",
|
||||
http_status=response.status_code,
|
||||
)
|
||||
|
||||
return {
|
||||
"http_status": response.status_code,
|
||||
"delivery_id": delivery_id,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
WhatsApp sender (worker) – Meta Cloud API v18.
|
||||
|
||||
Copia del sender backend: i due container sono separati.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
META_GRAPH_API_URL = "https://graph.facebook.com/v18.0"
|
||||
DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class WhatsAppError(Exception):
|
||||
def __init__(self, message: str, http_status: int | None = None, api_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.http_status = http_status
|
||||
self.api_code = api_code
|
||||
|
||||
|
||||
async def send_whatsapp_message(
|
||||
phone_number_id: str,
|
||||
to_phone: str,
|
||||
text: str,
|
||||
access_token: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict:
|
||||
"""
|
||||
Invia un messaggio di testo WhatsApp via Meta Cloud API.
|
||||
|
||||
Raises:
|
||||
WhatsAppError: in caso di errore HTTP o risposta API non-ok
|
||||
"""
|
||||
url = f"{META_GRAPH_API_URL}/{phone_number_id}/messages"
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_phone.replace(" ", "").replace("-", ""),
|
||||
"type": "text",
|
||||
"text": {"body": text},
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WhatsAppError(f"Timeout WhatsApp API dopo {timeout}s") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise WhatsAppError(f"Errore di rete WhatsApp: {exc}") from exc
|
||||
|
||||
if response.status_code == 401:
|
||||
raise WhatsAppError("Token Meta non valido o scaduto", http_status=401)
|
||||
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
err_data = response.json()
|
||||
err_msg = err_data.get("error", {}).get("message", response.text[:200])
|
||||
err_code = err_data.get("error", {}).get("code")
|
||||
except Exception:
|
||||
err_msg = response.text[:200]
|
||||
err_code = None
|
||||
raise WhatsAppError(
|
||||
f"Meta API errore HTTP {response.status_code}: {err_msg}",
|
||||
http_status=response.status_code,
|
||||
api_code=err_code,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
messages = data.get("messages", [])
|
||||
message_id = messages[0].get("id") if messages else None
|
||||
return {"message_id": message_id, "http_status": response.status_code}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user