diff --git a/.gitignore b/.gitignore index 6c4d8e0..4d26537 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e288c78..0941f76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.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 diff --git a/GapAnalysis.md b/GapAnalysis.md new file mode 100644 index 0000000..a4bc308 --- /dev/null +++ b/GapAnalysis.md @@ -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'. \ No newline at end of file diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index 27e3624..d2662f1 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 036dd8c..4fca4c8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/alembic/versions/0007_add_trash.py b/backend/alembic/versions/0007_add_trash.py new file mode 100644 index 0000000..d784a44 --- /dev/null +++ b/backend/alembic/versions/0007_add_trash.py @@ -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') diff --git a/backend/alembic/versions/0008_full_text_search.py b/backend/alembic/versions/0008_full_text_search.py new file mode 100644 index 0000000..a173f77 --- /dev/null +++ b/backend/alembic/versions/0008_full_text_search.py @@ -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') diff --git a/backend/alembic/versions/0009_add_conservation.py b/backend/alembic/versions/0009_add_conservation.py new file mode 100644 index 0000000..484b7c7 --- /dev/null +++ b/backend/alembic/versions/0009_add_conservation.py @@ -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") diff --git a/backend/alembic/versions/0010_add_templates.py b/backend/alembic/versions/0010_add_templates.py new file mode 100644 index 0000000..de196bd --- /dev/null +++ b/backend/alembic/versions/0010_add_templates.py @@ -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") diff --git a/backend/alembic/versions/0011_add_routing_rules.py b/backend/alembic/versions/0011_add_routing_rules.py new file mode 100644 index 0000000..4274296 --- /dev/null +++ b/backend/alembic/versions/0011_add_routing_rules.py @@ -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") diff --git a/backend/alembic/versions/0012_add_deadline.py b/backend/alembic/versions/0012_add_deadline.py new file mode 100644 index 0000000..6a01d9d --- /dev/null +++ b/backend/alembic/versions/0012_add_deadline.py @@ -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") diff --git a/backend/alembic/versions/0013_add_scheduled_send.py b/backend/alembic/versions/0013_add_scheduled_send.py new file mode 100644 index 0000000..c0ba2b2 --- /dev/null +++ b/backend/alembic/versions/0013_add_scheduled_send.py @@ -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") diff --git a/backend/alembic/versions/0014_add_pec_contacts.py b/backend/alembic/versions/0014_add_pec_contacts.py new file mode 100644 index 0000000..fcdb3bc --- /dev/null +++ b/backend/alembic/versions/0014_add_pec_contacts.py @@ -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") diff --git a/backend/app/api/v1/audit_log.py b/backend/app/api/v1/audit_log.py new file mode 100644 index 0000000..c3d9244 --- /dev/null +++ b/backend/app/api/v1/audit_log.py @@ -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, + ) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 89815c1..1ac2146 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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"), + ) diff --git a/backend/app/api/v1/contacts.py b/backend/app/api/v1/contacts.py new file mode 100644 index 0000000..cdb3de5 --- /dev/null +++ b/backend/app/api/v1/contacts.py @@ -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) diff --git a/backend/app/api/v1/deadlines.py b/backend/app/api/v1/deadlines.py new file mode 100644 index 0000000..7740cd3 --- /dev/null +++ b/backend/app/api/v1/deadlines.py @@ -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, + ) diff --git a/backend/app/api/v1/mailboxes.py b/backend/app/api/v1/mailboxes.py index a0d9c7a..a219b45 100644 --- a/backend/app/api/v1/mailboxes.py +++ b/backend/app/api/v1/mailboxes.py @@ -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}", + ) diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 1661341..407c2e3 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -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"{label}{date_str}" + + if receipt_rows: + receipts_html = f""" +
+

Tracciamento invio

+ + + {receipt_rows} +
Tipo ricevutaData
+
""" + + att_rows = "" + for att in attachments: + size_str = f"{att.size_bytes:,} byte" if att.size_bytes else "" + att_rows += f"
  • {att.filename} ({att.content_type or ''}) {size_str}
  • " + + att_html = f"

    Allegati ({len(attachments)})

    " 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"
    {message.body_html}
    " + elif message.body_text: + body_html = f"
    {message.body_text}
    " + + deadline_html = "" + if message.deadline_at: + dl_str = message.deadline_at.strftime("%d/%m/%Y %H:%M") + deadline_html = f"

    Scadenza: {dl_str}

    " + if message.deadline_note: + deadline_html += f"

    Nota scadenza: {message.deadline_note}

    " + + html = f""" + + + +PEC - {message.subject or '(nessun oggetto)'} + + + +

    {message.subject or '(nessun oggetto)'}

    +
    +

    {from_label}: {from_val}

    + {'

    Da: ' + message.from_address + '

    ' if message.direction == "outbound" and message.from_address else ''} + {'

    A: ' + ', '.join(message.to_addresses or []) + '

    ' if message.direction == "inbound" and message.to_addresses else ''} + {'

    Cc: ' + ', '.join(message.cc_addresses or []) + '

    ' if message.cc_addresses else ''} +

    Data: {date_val}

    +

    Stato: {message.state}

    +

    Tipo: {message.pec_type}

    +
    +{deadline_html} +{body_html} +{att_html} +{receipts_html} +

    + Documento generato da PEChub il {date_val} – ID messaggio: {message.id} +

    + +""" + + return HTMLResponse(content=html, media_type="text/html; charset=utf-8") diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py new file mode 100644 index 0000000..1d60a68 --- /dev/null +++ b/backend/app/api/v1/reports.py @@ -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)), + }, + ) diff --git a/backend/app/api/v1/routing_rules.py b/backend/app/api/v1/routing_rules.py new file mode 100644 index 0000000..f0473f7 --- /dev/null +++ b/backend/app/api/v1/routing_rules.py @@ -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) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 3bf9691..b3501ba 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -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=. """ -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= 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) diff --git a/backend/app/api/v1/templates.py b/backend/app/api/v1/templates.py new file mode 100644 index 0000000..ffd38c5 --- /dev/null +++ b/backend/app/api/v1/templates.py @@ -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) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 771960f..f51268b 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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)] diff --git a/backend/app/main.py b/backend/app/main.py index a300e83..76a45e8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 ───────────────────────────────────────────────────────────── diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index eb7d327..3018dd9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/message.py b/backend/app/models/message.py index 3af1989..e3fba06 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -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() ) diff --git a/backend/app/models/pec_contact.py b/backend/app/models/pec_contact.py new file mode 100644 index 0000000..2c1bdb2 --- /dev/null +++ b/backend/app/models/pec_contact.py @@ -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"" diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py index 237dac9..ce0cf3c 100644 --- a/backend/app/models/permission.py +++ b/backend/app/models/permission.py @@ -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 diff --git a/backend/app/models/routing_rule.py b/backend/app/models/routing_rule.py new file mode 100644 index 0000000..e07d56b --- /dev/null +++ b/backend/app/models/routing_rule.py @@ -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"" + + +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"), + ) diff --git a/backend/app/models/template.py b/backend/app/models/template.py new file mode 100644 index 0000000..34bcc41 --- /dev/null +++ b/backend/app/models/template.py @@ -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"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a208ff8..f3104fe 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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"" diff --git a/backend/app/notifications/__init__.py b/backend/app/notifications/__init__.py index 089f831..2a45687 100644 --- a/backend/app/notifications/__init__.py +++ b/backend/app/notifications/__init__.py @@ -1 +1 @@ -# Modulo notifiche – mittenti multi-canale +-# Modulo notifiche – mittenti multi-canale diff --git a/backend/app/notifications/email_smtp.py b/backend/app/notifications/email_smtp.py new file mode 100644 index 0000000..de73c43 --- /dev/null +++ b/backend/app/notifications/email_smtp.py @@ -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"

    PEChub – Test canale Email

    " + f"

    Il canale {channel_name} e' configurato correttamente.

    " + f"

    Data/ora: {ts}
    " + f"Destinatario: {to_email}

    " + f"

    Inviato da PEChub Notification Engine

    " + ) + 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, + ) diff --git a/backend/app/notifications/webhook.py b/backend/app/notifications/webhook.py new file mode 100644 index 0000000..c9e4db8 --- /dev/null +++ b/backend/app/notifications/webhook.py @@ -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, + ) diff --git a/backend/app/notifications/whatsapp.py b/backend/app/notifications/whatsapp.py new file mode 100644 index 0000000..2be0e0e --- /dev/null +++ b/backend/app/notifications/whatsapp.py @@ -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, + ) diff --git a/backend/app/schemas/audit_log.py b/backend/app/schemas/audit_log.py new file mode 100644 index 0000000..875f732 --- /dev/null +++ b/backend/app/schemas/audit_log.py @@ -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] diff --git a/backend/app/schemas/mailbox.py b/backend/app/schemas/mailbox.py index 7ada16f..a4d1400 100644 --- a/backend/app/schemas/mailbox.py +++ b/backend/app/schemas/mailbox.py @@ -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] diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index a3a37bc..f3b074a 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -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): diff --git a/backend/app/schemas/pec_contact.py b/backend/app/schemas/pec_contact.py new file mode 100644 index 0000000..607c195 --- /dev/null +++ b/backend/app/schemas/pec_contact.py @@ -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] = [] diff --git a/backend/app/schemas/permission.py b/backend/app/schemas/permission.py index c09cbfc..8f7b532 100644 --- a/backend/app/schemas/permission.py +++ b/backend/app/schemas/permission.py @@ -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 diff --git a/backend/app/schemas/reports.py b/backend/app/schemas/reports.py new file mode 100644 index 0000000..b14da16 --- /dev/null +++ b/backend/app/schemas/reports.py @@ -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) diff --git a/backend/app/schemas/routing_rule.py b/backend/app/schemas/routing_rule.py new file mode 100644 index 0000000..0c82079 --- /dev/null +++ b/backend/app/schemas/routing_rule.py @@ -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 diff --git a/backend/app/schemas/send.py b/backend/app/schemas/send.py index b6e022e..0cb650d 100644 --- a/backend/app/schemas/send.py +++ b/backend/app/schemas/send.py @@ -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 diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py new file mode 100644 index 0000000..c04fa03 --- /dev/null +++ b/backend/app/schemas/template.py @@ -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 diff --git a/backend/app/schemas/tenant_settings.py b/backend/app/schemas/tenant_settings.py index d200349..0556c9f 100644 --- a/backend/app/schemas/tenant_settings.py +++ b/backend/app/schemas/tenant_settings.py @@ -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." + ), + ) diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..7cf7bde --- /dev/null +++ b/backend/app/services/audit_service.py @@ -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, + ) diff --git a/backend/app/services/indexing_service.py b/backend/app/services/indexing_service.py new file mode 100644 index 0000000..28424ff --- /dev/null +++ b/backend/app/services/indexing_service.py @@ -0,0 +1,1226 @@ +""" +Servizio di gestione indicizzazione full-text dei messaggi. + +Funzionalita': + - Statistiche sull'indicizzazione (messaggi indicizzati vs totali) + - Avvio reindex totale o differenziale in background + - Monitoraggio progresso tramite Redis + - Cancellazione di un job in corso + - Avvio rescan allegati: ri-estrazione testo da MinIO + aggiornamento search_vector + +Stato del job reindex salvato in Redis: + pechub:reindex:{tenant_id} -> JSON con stato corrente (TTL 24h) + pechub:reindex:{tenant_id}:cancel -> flag cancellazione (TTL 10min) + +Stato del job rescan salvato in Redis: + pechub:rescan:{tenant_id} -> JSON con stato corrente (TTL 24h) + pechub:rescan:{tenant_id}:cancel -> flag cancellazione (TTL 10min) + +Il background task usa una sessione DB propria (non quella della request). +""" + +import asyncio +import io +import json +import logging +import re +import uuid +from datetime import datetime, timezone +from typing import Literal, Optional + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger + +logger = get_logger(__name__) + +# ─── Costanti ───────────────────────────────────────────────────────────────── + +REDIS_KEY_PREFIX = "pechub:reindex" +REDIS_RESCAN_PREFIX = "pechub:rescan" +REDIS_TTL_STATUS = 60 * 60 * 24 # 24 ore +REDIS_TTL_CANCEL = 60 * 10 # 10 minuti +BATCH_SIZE = 500 # messaggi per batch (reindex) +RESCAN_BATCH_SIZE = 50 # allegati per batch (rescan - piu' pesante) +STALE_THRESHOLD_HOURS = 2 # ore prima di segnalare un job come stale + +MAX_EXTRACTED_TEXT_LEN = 50_000 +MAX_COMBINED_TEXT_LEN = 200_000 + +ReindexMode = Literal["full", "differential"] +JobStatus = Literal["idle", "running", "completed", "failed", "cancelled"] + +# ─── Content-type e estensioni supportate per rescan ───────────────────────── + +_SUPPORTED_EXTENSIONS = { + "pdf", "docx", "doc", "xlsx", "xls", "pptx", "ppt", + "odt", "ods", "odp", "rtf", "txt", "csv", "xml", + "html", "htm", "json", "eml", "msg", "p7m", + "md", + # Immagini (OCR) + "png", "jpg", "jpeg", "tiff", "tif", "bmp", "gif", "webp", +} + +_SUPPORTED_CONTENT_TYPES = { + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + "application/vnd.ms-word", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.ms-powerpoint", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.presentation", + "application/rtf", + "text/rtf", + "text/plain", + "text/csv", + "text/xml", + "application/xml", + "text/html", + "message/rfc822", + "application/pkcs7-mime", + "application/x-pkcs7-mime", + "text/markdown", + # Immagini (OCR) + "image/png", + "image/jpeg", + "image/tiff", + "image/bmp", + "image/gif", + "image/webp", +} + + +# ─── Helpers Redis ───────────────────────────────────────────────────────────── + +def _redis_key(tenant_id: uuid.UUID) -> str: + return f"{REDIS_KEY_PREFIX}:{tenant_id}" + + +def _redis_cancel_key(tenant_id: uuid.UUID) -> str: + return f"{REDIS_KEY_PREFIX}:{tenant_id}:cancel" + + +def _redis_rescan_key(tenant_id: uuid.UUID) -> str: + return f"{REDIS_RESCAN_PREFIX}:{tenant_id}" + + +def _redis_rescan_cancel_key(tenant_id: uuid.UUID) -> str: + return f"{REDIS_RESCAN_PREFIX}:{tenant_id}:cancel" + + +# ─── Estrattori testo allegati ───────────────────────────────────────────────── + +def _ext(filename: str | None) -> str: + """Restituisce l'estensione del file in minuscolo, senza punto.""" + if not filename: + return "" + fn = filename.lower() + if fn.endswith(".p7m"): + return "p7m" + idx = fn.rfind(".") + return fn[idx + 1:] if idx >= 0 else "" + + +# Soglia minima di caratteri estratti da pypdf prima di ricorrere all'OCR. +_PDF_OCR_THRESHOLD = 50 +# Numero massimo di pagine 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 "" + + 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 a 200 DPI 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)) + 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: + 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()] + 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_doc(content: bytes) -> str: + """ + Estrae testo da file DOC (formato OLE2/legacy Microsoft Word). + + Prima tenta python-docx (gestisce .docx eventualmente rinominati come .doc). + Se fallisce, esegue uno scan del binario OLE2 estraendo sequenze di caratteri + stampabili di almeno 5 caratteri consecutivi (approccio 'strings'). + Non richiede librerie aggiuntive e funziona per la maggior parte dei .doc + in lingua italiana/europea (ASCII + Latin-1). + """ + # Tentativo 1: python-docx (per .docx rinominati o ZIP-based) + result = _extract_docx(content) + if result.strip(): + return result + + # Tentativo 2: scan binario ASCII per OLE2 + try: + run: list[int] = [] + parts: list[str] = [] + for byte in content: + if 32 <= byte <= 126: # ASCII stampabile + run.append(byte) + else: + if len(run) >= 5: + parts.append(bytes(run).decode("ascii", errors="ignore")) + run = [] + if len(run) >= 5: + parts.append(bytes(run).decode("ascii", errors="ignore")) + + # Mantieni solo sequenze con almeno 3 lettere (filtra sequenze di simboli) + meaningful = [p for p in parts if sum(1 for c in p if c.isalpha()) >= 3] + if meaningful: + text = " ".join(meaningful) + return " ".join(text.split())[:MAX_EXTRACTED_TEXT_LEN] + except Exception as e: + logger.debug(f"Errore estrazione DOC OLE2 binario: {e}") + + return "" + + +def _extract_plain(content: bytes) -> str: + try: + try: + txt = content.decode("utf-8") + except UnicodeDecodeError: + txt = content.decode("latin-1", errors="replace") + if "<" in txt and ">" in txt: + txt = re.sub(r"<[^>]+>", " ", txt) + txt = re.sub(r"&[a-zA-Z]+;", " ", txt) + return " ".join(txt.split()) + except Exception as e: + logger.debug(f"Errore estrazione plain: {e}") + return "" + + +def _extract_eml(content: bytes) -> str: + 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) + 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 _unwrap_p7m_asn1(data: bytes) -> bytes | None: + def read_tag_length(buf: bytes, offset: int): + 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 + + pos = 0 + try: + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x30: + return None + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x06: + return None + pos += ln + tag, ln, pos = read_tag_length(data, pos) + if tag != 0xA0: + return None + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x30: + return None + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x02: + return None + pos += ln + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x31: + return None + pos += ln + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x30: + return None + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x06: + return None + pos += ln + tag, ln, pos = read_tag_length(data, pos) + if tag != 0xA0: + return None + tag, ln, pos = read_tag_length(data, pos) + if tag != 0x04: + return None + return data[pos: pos + ln] + except Exception: + return None + + +def _extract_p7m(content: bytes, original_filename: str | None = None) -> str: + inner_content = _unwrap_p7m_asn1(content) + if not inner_content: + return "" + inner_ext = "" + if original_filename: + fn = original_filename.lower() + if fn.endswith(".p7m"): + fn = fn[:-4] + idx = fn.rfind(".") + if idx >= 0: + inner_ext = fn[idx + 1:] + extractor = _EXTRACTORS_SYNC.get(inner_ext) + if extractor: + return extractor(inner_content) + if inner_content[:4] == b"%PDF": + return _extract_pdf(inner_content) + if inner_content[:2] == b"PK": + for fn_try in (_extract_docx, _extract_plain): + result = fn_try(inner_content) + if result.strip(): + return result + return _extract_plain(inner_content) + + +_EXTRACTORS_SYNC: dict = { + "pdf": _extract_pdf, + "docx": _extract_docx, + "doc": _extract_doc, # usa fallback OLE2 per .doc legacy + "txt": _extract_plain, + "csv": _extract_plain, + "xml": _extract_plain, + "html": _extract_plain, + "htm": _extract_plain, + "json": _extract_plain, + "md": _extract_plain, # Markdown e' testo semplice + "eml": _extract_eml, + "msg": _extract_eml, + "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, +} + + +def _resolve_extractor(content_type: str | None, filename: str | None): + """Ritorna la funzione estrattore appropriata, o None.""" + e = _ext(filename) + if e in _EXTRACTORS_SYNC: + return _EXTRACTORS_SYNC[e] + ct = (content_type or "").lower() + _ct_map = { + "application/pdf": "pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/msword": "doc", + "application/vnd.ms-word": "doc", + "text/plain": "txt", + "text/csv": "csv", + "text/xml": "xml", + "application/xml": "xml", + "text/html": "html", + "text/markdown": "md", + "message/rfc822": "eml", + "application/pkcs7-mime": "p7m", + "application/x-pkcs7-mime": "p7m", + # Immagini (OCR) + "image/png": "png", + "image/jpeg": "jpeg", + "image/tiff": "tiff", + "image/bmp": "bmp", + "image/gif": "gif", + "image/webp": "webp", + } + mapped = _ct_map.get(ct) + if mapped: + return _EXTRACTORS_SYNC.get(mapped) + return None + + +def _is_extractable(content_type: str | None, filename: str | None) -> bool: + e = _ext(filename) + if e in _SUPPORTED_EXTENSIONS: + return True + ct = (content_type or "").lower() + return ct in _SUPPORTED_CONTENT_TYPES + + +# ─── Servizio ────────────────────────────────────────────────────────────────── + +class IndexingService: + """Gestisce le operazioni di indicizzazione full-text per un tenant.""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + # ── Statistiche ────────────────────────────────────────────────────────── + + async def get_stats(self, tenant_id: uuid.UUID) -> dict: + """ + Restituisce le statistiche di copertura dell'indicizzazione per il tenant. + """ + from app.models.message import Attachment, Message + + total_q = await self.db.execute( + select(func.count(Message.id)).where( + Message.tenant_id == tenant_id, + Message.parent_message_id.is_(None), + ) + ) + total_messages: int = total_q.scalar_one() + + indexed_q = await self.db.execute( + select(func.count(Message.id)).where( + Message.tenant_id == tenant_id, + Message.parent_message_id.is_(None), + Message.search_vector.isnot(None), + ) + ) + indexed_messages: int = indexed_q.scalar_one() + + _supported_content_types_list = list(_SUPPORTED_CONTENT_TYPES) + _supported_extensions_like = [f"%.{e}" for e in _SUPPORTED_EXTENSIONS] + + from sqlalchemy import or_ + ext_conditions = [Attachment.filename.ilike(ext) for ext in _supported_extensions_like] + + att_total_q = await self.db.execute( + select(func.count(Attachment.id)).where( + Attachment.tenant_id == tenant_id, + or_( + Attachment.content_type.in_(_supported_content_types_list), + *ext_conditions, + ), + ) + ) + attachments_total: int = att_total_q.scalar_one() + + att_extracted_q = await self.db.execute( + select(func.count(Attachment.id)).where( + Attachment.tenant_id == tenant_id, + Attachment.extracted_text.isnot(None), + ) + ) + attachments_extracted: int = att_extracted_q.scalar_one() + + unindexed_messages = total_messages - indexed_messages + coverage_pct = ( + round(indexed_messages / total_messages * 100, 1) + if total_messages > 0 + else 100.0 + ) + attachments_pct = ( + round(attachments_extracted / attachments_total * 100, 1) + if attachments_total > 0 + else 100.0 + ) + + return { + "total_messages": total_messages, + "indexed_messages": indexed_messages, + "unindexed_messages": unindexed_messages, + "coverage_pct": coverage_pct, + "attachments_total": attachments_total, + "attachments_extracted": attachments_extracted, + "attachments_pct": attachments_pct, + } + + # ── Stato job reindex ───────────────────────────────────────────────────── + + @staticmethod + async def get_job_status(tenant_id: uuid.UUID, redis_url: str) -> dict: + """Legge lo stato del job di reindex da Redis.""" + return await IndexingService._read_job_state( + _redis_key(tenant_id), redis_url + ) + + # ── Stato job rescan ────────────────────────────────────────────────────── + + @staticmethod + async def get_rescan_status(tenant_id: uuid.UUID, redis_url: str) -> dict: + """Legge lo stato del job di rescan allegati da Redis.""" + return await IndexingService._read_job_state( + _redis_rescan_key(tenant_id), redis_url + ) + + @staticmethod + async def _read_job_state(redis_key_str: str, redis_url: str) -> dict: + """Helper generico: legge uno stato job da Redis.""" + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await client.get(redis_key_str) + finally: + await client.aclose() + + if not raw: + return { + "status": "idle", + "mode": None, + "total": 0, + "processed": 0, + "progress_pct": 0.0, + "started_at": None, + "finished_at": None, + "started_by": None, + "elapsed_seconds": None, + "is_stale": False, + "error": None, + } + + try: + data: dict = json.loads(raw) + except json.JSONDecodeError: + return {"status": "idle"} + + is_stale = False + elapsed_seconds = None + if data.get("started_at"): + try: + started = datetime.fromisoformat(data["started_at"]) + finished_str = data.get("finished_at") + ref_time = ( + datetime.fromisoformat(finished_str) + if finished_str + else datetime.now(timezone.utc) + ) + elapsed_seconds = int((ref_time - started).total_seconds()) + if data.get("status") == "running": + elapsed_hours = elapsed_seconds / 3600 + is_stale = elapsed_hours >= STALE_THRESHOLD_HOURS + except Exception: + pass + + total = data.get("total", 0) + processed = data.get("processed", 0) + progress_pct = round(processed / total * 100, 1) if total > 0 else 0.0 + + return { + "status": data.get("status", "idle"), + "mode": data.get("mode"), + "total": total, + "processed": processed, + "progress_pct": progress_pct, + "started_at": data.get("started_at"), + "finished_at": data.get("finished_at"), + "started_by": data.get("started_by"), + "elapsed_seconds": elapsed_seconds, + "is_stale": is_stale, + "error": data.get("error"), + } + + # ── Avvio reindex ───────────────────────────────────────────────────────── + + @staticmethod + async def start_reindex( + tenant_id: uuid.UUID, + mode: ReindexMode, + started_by_email: str, + redis_url: str, + db_url: str, + ) -> None: + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await client.get(_redis_key(tenant_id)) + if raw: + data = json.loads(raw) + if data.get("status") == "running": + raise ValueError("Un job di reindex e' gia' in corso per questo tenant") + # Controlla anche se il rescan e' in corso + raw_rescan = await client.get(_redis_rescan_key(tenant_id)) + if raw_rescan: + data_rescan = json.loads(raw_rescan) + if data_rescan.get("status") == "running": + raise ValueError( + "Un job di scansione allegati e' in corso. " + "Attendi il termine prima di avviare il reindex." + ) + finally: + await client.aclose() + + await IndexingService._set_state( + _redis_key(tenant_id), + redis_url, + { + "status": "running", + "mode": mode, + "total": 0, + "processed": 0, + "started_at": datetime.now(timezone.utc).isoformat(), + "finished_at": None, + "started_by": started_by_email, + "error": None, + }, + ) + + asyncio.create_task( + IndexingService._run_reindex_bg(tenant_id, mode, redis_url, db_url), + name=f"reindex-{tenant_id}", + ) + + # ── Avvio rescan allegati ───────────────────────────────────────────────── + + @staticmethod + async def start_rescan( + tenant_id: uuid.UUID, + started_by_email: str, + redis_url: str, + db_url: str, + force: bool = False, + ) -> None: + """ + Avvia il job di rescan allegati in background. + + force=False: processa solo allegati con extracted_text IS NULL + force=True: processa tutti gli allegati (ri-estrae anche quelli gia' estratti) + """ + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await client.get(_redis_rescan_key(tenant_id)) + if raw: + data = json.loads(raw) + if data.get("status") == "running": + raise ValueError("Un job di scansione allegati e' gia' in corso per questo tenant") + # Controlla anche se il reindex e' in corso + raw_reindex = await client.get(_redis_key(tenant_id)) + if raw_reindex: + data_reindex = json.loads(raw_reindex) + if data_reindex.get("status") == "running": + raise ValueError( + "Un job di reindex e' in corso. " + "Attendi il termine prima di avviare la scansione allegati." + ) + finally: + await client.aclose() + + mode_label = "force" if force else "differential" + + await IndexingService._set_state( + _redis_rescan_key(tenant_id), + redis_url, + { + "status": "running", + "mode": mode_label, + "total": 0, + "processed": 0, + "started_at": datetime.now(timezone.utc).isoformat(), + "finished_at": None, + "started_by": started_by_email, + "error": None, + }, + ) + + asyncio.create_task( + IndexingService._run_rescan_bg(tenant_id, force, redis_url, db_url), + name=f"rescan-{tenant_id}", + ) + + # ── Cancellazione reindex ───────────────────────────────────────────────── + + @staticmethod + async def cancel_reindex(tenant_id: uuid.UUID, redis_url: str) -> bool: + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await client.get(_redis_key(tenant_id)) + if raw: + data = json.loads(raw) + if data.get("status") == "running": + await client.setex( + _redis_cancel_key(tenant_id), + REDIS_TTL_CANCEL, + "1", + ) + return True + finally: + await client.aclose() + return False + + # ── Cancellazione rescan ────────────────────────────────────────────────── + + @staticmethod + async def cancel_rescan(tenant_id: uuid.UUID, redis_url: str) -> bool: + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await client.get(_redis_rescan_key(tenant_id)) + if raw: + data = json.loads(raw) + if data.get("status") == "running": + await client.setex( + _redis_rescan_cancel_key(tenant_id), + REDIS_TTL_CANCEL, + "1", + ) + return True + finally: + await client.aclose() + return False + + # ── Helpers Redis ───────────────────────────────────────────────────────── + + @staticmethod + async def _set_state(redis_key_str: str, redis_url: str, state: dict) -> None: + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + await client.setex( + redis_key_str, + REDIS_TTL_STATUS, + json.dumps(state, default=str), + ) + finally: + await client.aclose() + + @staticmethod + async def _check_cancel_flag(cancel_key: str, redis_url: str) -> bool: + import redis.asyncio as aioredis + + client = aioredis.from_url(redis_url, decode_responses=True) + try: + flag = await client.get(cancel_key) + return flag == "1" + finally: + await client.aclose() + + # Alias per retrocompatibilita' con il codice esistente + @staticmethod + async def _set_job_state(tenant_id: uuid.UUID, redis_url: str, state: dict) -> None: + await IndexingService._set_state(_redis_key(tenant_id), redis_url, state) + + @staticmethod + async def _check_cancel(tenant_id: uuid.UUID, redis_url: str) -> bool: + return await IndexingService._check_cancel_flag( + _redis_cancel_key(tenant_id), redis_url + ) + + # ── Logica interna reindex ───────────────────────────────────────────────── + + @staticmethod + async def _run_reindex_bg( + tenant_id: uuid.UUID, + mode: ReindexMode, + redis_url: str, + db_url: str, + ) -> None: + from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + from sqlalchemy.orm import sessionmaker + + log = logging.getLogger(__name__) + log.info(f"Avvio reindex {mode} per tenant {tenant_id}") + + engine = create_async_engine(db_url, echo=False) + AsyncSessionFactory = sessionmaker( # type: ignore[call-overload] + engine, class_=AsyncSession, expire_on_commit=False + ) + + state: dict = { + "status": "running", + "mode": mode, + "total": 0, + "processed": 0, + "started_at": None, + "finished_at": None, + "started_by": None, + "error": None, + } + + try: + async with AsyncSessionFactory() as db: + from app.models.message import Message + + count_q = select(func.count(Message.id)).where( + Message.tenant_id == tenant_id, + Message.parent_message_id.is_(None), + ) + if mode == "differential": + count_q = count_q.where(Message.search_vector.is_(None)) + + total: int = (await db.execute(count_q)).scalar_one() + + import redis.asyncio as aioredis + redis_client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await redis_client.get(_redis_key(tenant_id)) + if raw: + state = json.loads(raw) + state["total"] = total + state["processed"] = 0 + await redis_client.setex( + _redis_key(tenant_id), + REDIS_TTL_STATUS, + json.dumps(state, default=str), + ) + finally: + await redis_client.aclose() + + log.info(f"Reindex {mode}: {total} messaggi da processare") + + if total == 0: + state.update({ + "status": "completed", + "processed": 0, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + await IndexingService._set_job_state(tenant_id, redis_url, state) + return + + ids_q = select(Message.id).where( + Message.tenant_id == tenant_id, + Message.parent_message_id.is_(None), + ) + if mode == "differential": + ids_q = ids_q.where(Message.search_vector.is_(None)) + + ids_result = await db.execute(ids_q) + all_ids = [str(row[0]) for row in ids_result.fetchall()] + + processed = 0 + for batch_start in range(0, len(all_ids), BATCH_SIZE): + if await IndexingService._check_cancel(tenant_id, redis_url): + log.info(f"Reindex {mode} annullato al batch {batch_start}") + state.update({ + "status": "cancelled", + "processed": processed, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + await IndexingService._set_job_state(tenant_id, redis_url, state) + return + + batch_ids = all_ids[batch_start: batch_start + BATCH_SIZE] + + 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', coalesce(( + SELECT string_agg(a.extracted_text, ' ') + FROM attachments a + WHERE a.message_id = messages.id + AND a.extracted_text IS NOT NULL + ), '')), 'D') + WHERE id = ANY(:ids) + """), + {"ids": batch_ids}, + ) + await db.commit() + + processed += len(batch_ids) + state["processed"] = processed + await IndexingService._set_job_state(tenant_id, redis_url, state) + + await asyncio.sleep(0.05) + + state.update({ + "status": "completed", + "processed": processed, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + await IndexingService._set_job_state(tenant_id, redis_url, state) + log.info(f"Reindex {mode} completato: {processed}/{total} messaggi") + + except Exception as exc: + log.error(f"Errore reindex {mode} tenant {tenant_id}: {exc}", exc_info=True) + state.update({ + "status": "failed", + "finished_at": datetime.now(timezone.utc).isoformat(), + "error": str(exc), + }) + await IndexingService._set_job_state(tenant_id, redis_url, state) + finally: + await engine.dispose() + + # ── Logica interna rescan allegati ──────────────────────────────────────── + + @staticmethod + async def _run_rescan_bg( + tenant_id: uuid.UUID, + force: bool, + redis_url: str, + db_url: str, + ) -> None: + """ + Coroutine di rescan allegati eseguita in background. + + Algoritmo: + 1. Trova gli allegati del tenant con formato supportato + (solo quelli con extracted_text IS NULL se force=False) + 2. Per ogni batch: scarica da MinIO, estrae testo, aggiorna extracted_text + 3. Dopo ogni batch: ricostruisce search_vector per i messaggi interessati + 4. Aggiorna progresso in Redis dopo ogni batch + 5. Controlla flag di cancellazione tra un batch e l'altro + """ + from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + from sqlalchemy.orm import sessionmaker + + log = logging.getLogger(__name__) + mode_str = "force" if force else "differential" + log.info(f"Avvio rescan allegati {mode_str} per tenant {tenant_id}") + + engine = create_async_engine(db_url, echo=False) + AsyncSessionFactory = sessionmaker( # type: ignore[call-overload] + engine, class_=AsyncSession, expire_on_commit=False + ) + + state: dict = { + "status": "running", + "mode": mode_str, + "total": 0, + "processed": 0, + "started_at": None, + "finished_at": None, + "started_by": None, + "error": None, + } + + try: + async with AsyncSessionFactory() as db: + from app.config import get_settings + from app.models.message import Attachment + + settings = get_settings() + + # ── 1. Conta allegati da processare ─────────────────────────── + from sqlalchemy import or_ + + ext_conditions = [ + Attachment.filename.ilike(f"%.{e}") for e in _SUPPORTED_EXTENSIONS + ] + base_filter = [ + Attachment.tenant_id == tenant_id, + or_( + Attachment.content_type.in_(list(_SUPPORTED_CONTENT_TYPES)), + *ext_conditions, + ), + ] + if not force: + base_filter.append(Attachment.extracted_text.is_(None)) + + count_q = await db.execute( + select(func.count(Attachment.id)).where(*base_filter) + ) + total: int = count_q.scalar_one() + + # Aggiorna totale in Redis + import redis.asyncio as aioredis + redis_client = aioredis.from_url(redis_url, decode_responses=True) + try: + raw = await redis_client.get(_redis_rescan_key(tenant_id)) + if raw: + state = json.loads(raw) + state["total"] = total + state["processed"] = 0 + await redis_client.setex( + _redis_rescan_key(tenant_id), + REDIS_TTL_STATUS, + json.dumps(state, default=str), + ) + finally: + await redis_client.aclose() + + log.info(f"Rescan {mode_str}: {total} allegati da processare") + + if total == 0: + state.update({ + "status": "completed", + "processed": 0, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + await IndexingService._set_state( + _redis_rescan_key(tenant_id), redis_url, state + ) + return + + # ── 2. Recupera IDs allegati da processare ──────────────────── + ids_q = select(Attachment.id).where(*base_filter) + ids_result = await db.execute(ids_q) + all_att_ids = [row[0] for row in ids_result.fetchall()] + + # ── 3. 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, + ) + bucket = settings.minio_bucket + except Exception as e: + raise RuntimeError(f"Impossibile creare client MinIO: {e}") from e + + # ── 4. Processa in batch ─────────────────────────────────────── + processed = 0 + for batch_start in range(0, len(all_att_ids), RESCAN_BATCH_SIZE): + # Controlla cancellazione + if await IndexingService._check_cancel_flag( + _redis_rescan_cancel_key(tenant_id), redis_url + ): + log.info(f"Rescan {mode_str} annullato al batch {batch_start}") + state.update({ + "status": "cancelled", + "processed": processed, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + await IndexingService._set_state( + _redis_rescan_key(tenant_id), redis_url, state + ) + return + + batch_ids = all_att_ids[batch_start: batch_start + RESCAN_BATCH_SIZE] + + # Carica allegati del batch + att_result = await db.execute( + select(Attachment).where(Attachment.id.in_(batch_ids)) + ) + attachments = list(att_result.scalars().all()) + + affected_message_ids: set[str] = set() + + for att in attachments: + extractor = _resolve_extractor(att.content_type, att.filename) + if extractor is None: + continue + + try: + response = await minio.get_object(bucket, att.storage_path) + content = await response.content.read() + response.close() + except Exception as e: + log.warning( + f"Impossibile scaricare allegato {att.id} " + f"({att.filename!r}) da MinIO: {e}" + ) + continue + + try: + e_name = _ext(att.filename) + if e_name == "p7m": + extracted = _extract_p7m(content, att.filename) + else: + extracted = extractor(content) # type: ignore[operator] + except Exception as ex: + log.debug(f"Errore estrazione {att.filename!r}: {ex}") + continue + + if not extracted or not extracted.strip(): + continue + + att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN] + affected_message_ids.add(str(att.message_id)) + log.debug( + f"Testo estratto da {att.filename!r}: " + f"{len(att.extracted_text)} caratteri" + ) + + await db.flush() + + # Ricostruisce search_vector per i messaggi interessati + if affected_message_ids: + 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', coalesce(( + SELECT string_agg(a.extracted_text, ' ') + FROM attachments a + WHERE a.message_id = messages.id + AND a.extracted_text IS NOT NULL + ), '')), 'D') + WHERE id = ANY(:ids) + """), + {"ids": list(affected_message_ids)}, + ) + + await db.commit() + + processed += len(batch_ids) + state["processed"] = processed + await IndexingService._set_state( + _redis_rescan_key(tenant_id), redis_url, state + ) + + await asyncio.sleep(0.1) + + # ── 5. Completato ────────────────────────────────────────────── + state.update({ + "status": "completed", + "processed": processed, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + await IndexingService._set_state( + _redis_rescan_key(tenant_id), redis_url, state + ) + log.info(f"Rescan {mode_str} completato: {processed}/{total} allegati") + + except Exception as exc: + log.error(f"Errore rescan tenant {tenant_id}: {exc}", exc_info=True) + state.update({ + "status": "failed", + "finished_at": datetime.now(timezone.utc).isoformat(), + "error": str(exc), + }) + await IndexingService._set_state( + _redis_rescan_key(tenant_id), redis_url, state + ) + finally: + await engine.dispose() diff --git a/backend/app/services/mailbox_service.py b/backend/app/services/mailbox_service.py index 17c6cc3..94d2601 100644 --- a/backend/app/services/mailbox_service.py +++ b/backend/app/services/mailbox_service.py @@ -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) ─────────────────── diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 699f397..1875d3b 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -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 ─────────────────────────────────────────────────────────────── diff --git a/backend/app/services/pec_contact_service.py b/backend/app/services/pec_contact_service.py new file mode 100644 index 0000000..de1bc57 --- /dev/null +++ b/backend/app/services/pec_contact_service.py @@ -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()) diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py index e35ab1d..c458eb1 100644 --- a/backend/app/services/permission_service.py +++ b/backend/app/services/permission_service.py @@ -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 ] diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py new file mode 100644 index 0000000..3192a10 --- /dev/null +++ b/backend/app/services/report_service.py @@ -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", +] diff --git a/backend/app/services/routing_rule_service.py b/backend/app/services/routing_rule_service.py new file mode 100644 index 0000000..cab70a0 --- /dev/null +++ b/backend/app/services/routing_rule_service.py @@ -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 diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 0000000..9b9baf6 --- /dev/null +++ b/backend/app/services/search_service.py @@ -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 diff --git a/backend/app/services/send_service.py b/backend/app/services/send_service.py index 6945700..70bb6ba 100644 --- a/backend/app/services/send_service.py +++ b/backend/app/services/send_service.py @@ -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__) diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py new file mode 100644 index 0000000..f536887 --- /dev/null +++ b/backend/app/services/template_service.py @@ -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() diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 9b82426..eadd2f9 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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}, + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 792b925..ca5cc67 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/frontend/node_modules/.package-lock.json b/frontend/node_modules/.package-lock.json index 8a09e67..7acab78 100644 --- a/frontend/node_modules/.package-lock.json +++ b/frontend/node_modules/.package-lock.json @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1fcd11e..1df2a11 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 6f4ef33..8a2f74a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d79137..2fb28d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> {/* Vista globale: tutte le caselle insieme */} - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Vista per singola casella PEC */} - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Vista per Virtual Box assegnata */} } /> @@ -74,6 +87,21 @@ export default function App() { {/* Super Admin – Gestione Multi-Tenant */} } /> + {/* Ricerca avanzata full-text */} + } /> + + {/* Dashboard e Reportistica */} + } /> + + {/* Audit Log */} + } /> + + {/* Nuove funzionalita' */} + } /> + } /> + } /> + } /> + {/* Profilo utente */} } /> diff --git a/frontend/src/api/audit_log.api.ts b/frontend/src/api/audit_log.api.ts new file mode 100644 index 0000000..01e66ee --- /dev/null +++ b/frontend/src/api/audit_log.api.ts @@ -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 | null + outcome: 'success' | 'failure' + occurred_at: string +} + +export type AuditLogListResponse = PaginatedResponse + +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 => + apiClient + .get('/audit-log', { params }) + .then((r) => r.data), +} diff --git a/frontend/src/api/contacts.api.ts b/frontend/src/api/contacts.api.ts new file mode 100644 index 0000000..8409044 --- /dev/null +++ b/frontend/src/api/contacts.api.ts @@ -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('/contacts/autocomplete', { params: { q } }).then((r) => r.data), + + get: (id: string) => + apiClient.get(`/contacts/${id}`).then((r) => r.data), + + create: (data: PecContactCreate) => + apiClient.post('/contacts', data).then((r) => r.data), + + update: (id: string, data: PecContactUpdate) => + apiClient.put(`/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('/contacts/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }).then((r) => r.data) + }, +} diff --git a/frontend/src/api/deadlines.api.ts b/frontend/src/api/deadlines.api.ts new file mode 100644 index 0000000..39b70f7 --- /dev/null +++ b/frontend/src/api/deadlines.api.ts @@ -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('/deadlines', { params }).then((r) => r.data), + + setDeadline: (messageId: string, data: DeadlineSetRequest) => + apiClient.post(`/messages/${messageId}/deadline`, data).then((r) => r.data), +} diff --git a/frontend/src/api/mailboxes.api.ts b/frontend/src/api/mailboxes.api.ts index 08d5bfd..bd3fcda 100644 --- a/frontend/src/api/mailboxes.api.ts +++ b/frontend/src/api/mailboxes.api.ts @@ -28,4 +28,16 @@ export const mailboxesApi = { apiClient .post(`/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 }>('/mailboxes/unread-counts') + .then((r) => r.data.counts), } diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index 0750c8f..364244d 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -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(`/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(`/messages/${id}`, { is_trashed: true }) + .then((r) => r.data), + + untrash: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_trashed: false }) + .then((r) => r.data), + + /** Sposta un messaggio nella cartella Da Conservare */ + conserve: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_pending_conservation: true }) + .then((r) => r.data), + + /** Rimuove un messaggio dalla cartella Da Conservare */ + unconserve: (id: string) => + apiClient + .patch(`/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('/messages/bulk', payload) @@ -91,4 +127,47 @@ export const messagesApi = { getReceipts: (id: string) => apiClient.get(`/messages/${id}/receipts`).then((r) => r.data), + + // ─── Feature 3: Thread ──────────────────────────────────────────────────── + + getThread: (id: string) => + apiClient.get(`/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 => { + 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) + }, } diff --git a/frontend/src/api/reports.api.ts b/frontend/src/api/reports.api.ts new file mode 100644 index 0000000..a44848f --- /dev/null +++ b/frontend/src/api/reports.api.ts @@ -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 => { + const res = await apiClient.get('/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() + }, +} diff --git a/frontend/src/api/routing_rules.api.ts b/frontend/src/api/routing_rules.api.ts new file mode 100644 index 0000000..d416f19 --- /dev/null +++ b/frontend/src/api/routing_rules.api.ts @@ -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 + +export const routingRulesApi = { + list: () => + apiClient.get<{ items: RoutingRuleResponse[]; total: number }>('/routing-rules').then((r) => r.data), + + get: (id: string) => + apiClient.get(`/routing-rules/${id}`).then((r) => r.data), + + create: (data: RoutingRuleCreate) => + apiClient.post('/routing-rules', data).then((r) => r.data), + + update: (id: string, data: RoutingRuleUpdate) => + apiClient.put(`/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(`/routing-rules/${id}/toggle`).then((r) => r.data), +} diff --git a/frontend/src/api/settings.api.ts b/frontend/src/api/settings.api.ts index f18f64b..e201e2e 100644 --- a/frontend/src/api/settings.api.ts +++ b/frontend/src/api/settings.api.ts @@ -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('/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.get('/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.get('/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.post( + '/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.delete( + '/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.get( + '/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.post( + '/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 => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.delete( + '/settings/indexing/rescan', + { params } + ) + return data + }, } diff --git a/frontend/src/api/templates.api.ts b/frontend/src/api/templates.api.ts new file mode 100644 index 0000000..30225a2 --- /dev/null +++ b/frontend/src/api/templates.api.ts @@ -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(`/templates/${id}`).then((r) => r.data), + + create: (data: TemplateCreate) => + apiClient.post('/templates', data).then((r) => r.data), + + update: (id: string, data: TemplateUpdate) => + apiClient.put(`/templates/${id}`, data).then((r) => r.data), + + delete: (id: string) => + apiClient.delete(`/templates/${id}`).then((r) => r.data), +} diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 7c11e62..5f1c37d 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -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>(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() { {!collapsed && Archiviati} + + {/* Cestino globale */} + + 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} + > + + {!collapsed && Cestino} + + + {/* ── Sezione Conservazione (admin e supervisor) ── */} + {(isAdmin || isSupervisor) && ( + <> + {!collapsed && ( +
    +

    + Conservazione +

    +
    + )} + + 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} + > + + {!collapsed && Da Conservare} + + + 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} + > + + {!collapsed && Storico} + + + )} @@ -290,6 +373,7 @@ export function Sidebar() { collapsed={collapsed} isExpanded={isMailboxExpanded(mailbox.id)} onToggle={() => toggleMailbox(mailbox.id)} + unreadCount={unreadCounts[mailbox.id] ?? 0} /> ))} @@ -323,6 +407,77 @@ export function Sidebar() { )} + {/* ── Strumenti operativi ── */} +
    +
    +
    + + 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} + > + + {!collapsed && Scadenzario} + + + 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} + > + + {!collapsed && Rubrica PEC} + + + 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} + > + + {!collapsed && Ricerca} + + + 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} + > + + {!collapsed && Dashboard} + +
    +
    + {/* ── Nuova PEC ── */}
    @@ -346,6 +501,40 @@ export function Sidebar() {
    + {/* ── Sezione Supervisione – visibile solo ai supervisor ── */} + {isSupervisor && ( +
    + {!collapsed && ( + <> +
    +

    + Supervisione +

    + + )} + {collapsed &&
    } + +
    + + 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} + > + + {!collapsed && Caselle PEC} + +
    +
    + )} + {/* ── Sezione Amministrazione ── */} {isAdmin && (
    @@ -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) => ( 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)` : ''}`} >
    @@ -523,6 +718,11 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav dotClass, )} /> + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )}
    ) @@ -554,6 +754,13 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav {displayName} + {/* Badge non letti per casella */} + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + {/* Chevron espandi/comprimi */} Archiviati + + + 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', + ) + } + > + + Cestino + + + {/* Conservazione (solo admin/supervisor) */} + {canSeeConservation && ( + <> + + 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', + ) + } + > + + Da Conservare + + + 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', + ) + } + > + + Storico + + + )}
    )}
    diff --git a/frontend/src/components/ReceiptTree/ReceiptTree.tsx b/frontend/src/components/ReceiptTree/ReceiptTree.tsx index 44f7e3a..601b89b 100644 --- a/frontend/src/components/ReceiptTree/ReceiptTree.tsx +++ b/frontend/src/components/ReceiptTree/ReceiptTree.tsx @@ -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}`)} /> ))}
    @@ -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 = (
    {/* Indicatore timeline */}
    - {label} + + {label} + {state && !isRoot && } {type && type !== 'posta_certificata' && } + {isClickable && ( + + )}
    {date && (

    {formatDate(date)}

    @@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
    ) + + if (isClickable) { + return ( + + ) + } + + return
    {content}
    } diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 621fc32..d658db7 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -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, diff --git a/frontend/src/pages/AuditLog/AuditLogPage.tsx b/frontend/src/pages/AuditLog/AuditLogPage.tsx new file mode 100644 index 0000000..38234e2 --- /dev/null +++ b/frontend/src/pages/AuditLog/AuditLogPage.tsx @@ -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 ( + + {isSuccess ? ( + + ) : ( + + )} + {isSuccess ? 'Successo' : 'Fallito'} + + ) +} + +// ─── Etichetta azione leggibile ─────────────────────────────────────────────── + +function actionLabel(action: string): string { + const map: Record = { + '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({}) + + 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 ( +
    + {/* Intestazione */} +
    +

    Audit Log

    +

    + Registro cronologico degli eventi di sistema e delle operazioni degli utenti. +

    +
    + + {/* Filtri */} +
    +
    + {/* Azione */} +
    + + +
    + + {/* Esito */} +
    + + +
    + + {/* Data da */} +
    + + 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" + /> +
    + + {/* Data a */} +
    + + 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" + /> +
    +
    + + {/* Bottoni */} +
    + + +
    +
    + + {/* Tabella */} +
    + {/* Header tabella con count */} +
    + + {isLoading ? 'Caricamento...' : `${total.toLocaleString('it-IT')} eventi trovati`} + + {total > 0 && ( + + Pagina {page} di {pages} + + )} +
    + + {/* Stato errore */} + {isError && ( +
    + +

    Errore nel caricamento dei dati.

    +
    + )} + + {/* Stato vuoto */} + {!isLoading && !isError && items.length === 0 && ( +
    + +

    Nessun evento trovato.

    +
    + )} + + {/* Dati */} + {items.length > 0 && ( +
    + + + + + + + + + + + + + {items.map((entry) => ( + + {/* Data/ora */} + + + {/* Azione */} + + + {/* Esito */} + + + {/* Risorsa */} + + + {/* IP */} + + + {/* Utente (UUID abbreviato) */} + + + ))} + +
    + Data / Ora + + Azione + + Esito + + Risorsa + + IP + + Utente +
    + {format(new Date(entry.occurred_at), 'dd/MM/yyyy HH:mm:ss', { locale: it })} + + + {actionLabel(entry.action)} + + + ({entry.action}) + + + + + {entry.resource_type ? ( + + {entry.resource_type} + {entry.resource_id && ( + + {entry.resource_id.split('-')[0]}... + + )} + + ) : ( + + )} + + {entry.ip_address ?? } + + {entry.user_id ? ( + + {entry.user_id.split('-')[0]}... + + ) : ( + + )} +
    +
    + )} + + {/* Paginazione */} + {pages > 1 && ( +
    + + + {page} / {pages} + + +
    + )} +
    +
    + ) +} diff --git a/frontend/src/pages/Compose/ComposePage.tsx b/frontend/src/pages/Compose/ComposePage.tsx index 53dbb1f..a08faa7 100644 --- a/frontend/src/pages/Compose/ComposePage.tsx +++ b/frontend/src/pages/Compose/ComposePage.tsx @@ -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(null) // Corpo HTML (gestito da TipTap) const [bodyHtml, setBodyHtml] = useState(() => { - if (!replyTo) return '' - const date = new Date( - replyTo.received_at || replyTo.created_at - ).toLocaleDateString('it-IT') - return [ - '

    ', - '

    ', - '
    ', - `

    In risposta al messaggio del ${date}

    `, - `

    Da: ${replyTo.from_address || ''}

    `, - `

    A: ${replyTo.to_addresses?.join(', ') || ''}

    `, - `

    Oggetto: ${replyTo.subject || ''}

    `, - ].join('') + if (replyTo) { + const date = new Date( + replyTo.received_at || replyTo.created_at + ).toLocaleDateString('it-IT') + return [ + '

    ', + '

    ', + '
    ', + `

    In risposta al messaggio del ${date}

    `, + `

    Da: ${replyTo.from_address || ''}

    `, + `

    A: ${replyTo.to_addresses?.join(', ') || ''}

    `, + `

    Oggetto: ${replyTo.subject || ''}

    `, + ].join('') + } + if (forwardOf) { + const date = new Date( + forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at + ).toLocaleDateString('it-IT') + const bodyContent = forwardOf.body_text + ? `
    ${forwardOf.body_text}
    ` + : '' + return [ + '

    ', + '

    ', + '
    ', + `

    Messaggio inoltrato

    `, + `

    Da: ${forwardOf.from_address || ''}

    `, + `

    A: ${forwardOf.to_addresses?.join(', ') || ''}

    `, + `

    Data: ${date}

    `, + `

    Oggetto: ${forwardOf.subject || ''}

    `, + bodyContent, + ].join('') + } + return '' }) // Allegati @@ -80,12 +102,16 @@ export function ComposePage() { formState: { errors }, } = useForm({ 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() {

    - {replyTo ? 'Rispondi a PEC' : 'Nuova PEC'} + {replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'}

    {replyTo && (

    In risposta a: {replyTo.subject}

    )} + {forwardOf && ( +

    + Inoltro di: {forwardOf.subject} +

    + )}
    @@ -239,6 +270,17 @@ export function ComposePage() {

    + {/* Avviso allegati messaggio inoltrato */} + {forwardOf && forwardOf.has_attachments && ( +
    + +

    + Il messaggio originale contiene allegati. Per includerli nell'inoltro, + scaricali dal messaggio originale e aggiungili qui manualmente. +

    +
    + )} + {/* Casella mittente */}
    diff --git a/frontend/src/pages/Contacts/ContactsPage.tsx b/frontend/src/pages/Contacts/ContactsPage.tsx new file mode 100644 index 0000000..c298f9a --- /dev/null +++ b/frontend/src/pages/Contacts/ContactsPage.tsx @@ -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(null) + const fileInputRef = useRef(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) => { + const file = e.target.files?.[0] + if (file) { + importMutation.mutate(file) + e.target.value = '' + } + } + + const items = data?.items ?? [] + const total = data?.total ?? 0 + + return ( +
    +
    +
    +

    Rubrica PEC

    +

    + {total} contatti nella rubrica +

    +
    +
    + + + +
    +
    + +
    +
    + + setQ(e.target.value)} + className="pl-9" + /> +
    + + {isLoading ? ( +
    +
    +
    + ) : items.length === 0 ? ( +
    + +

    Nessun contatto trovato

    +

    + Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization +

    +
    + ) : ( +
    + + + + + + + + + + + + {items.map((c) => ( + + + + + + + + + ))} + +
    Email PECNomeOrganizzazioneTipoAggiornato +
    {c.email}{c.name ?? '-'} + {c.organization ? ( + + + {c.organization} + + ) : '-'} + + + {c.auto_saved ? 'Automatico' : 'Manuale'} + + {formatDate(c.updated_at)} +
    + + + +
    +
    +
    + )} +
    + + !o && closeForm()}> + + + {editing ? 'Modifica contatto' : 'Nuovo contatto'} + + +
    +
    + + setFormEmail(e.target.value)} placeholder="indirizzo@pec.it" disabled={!!editing} type="email" /> +
    +
    + + setFormName(e.target.value)} placeholder="Mario Rossi" /> +
    +
    + + setFormOrg(e.target.value)} placeholder="Comune di Roma" /> +
    +
    + + setFormNotes(e.target.value)} placeholder="Note aggiuntive..." /> +
    + +
    + + + + + +
    +
    +
    + ) +} diff --git a/frontend/src/pages/Deadlines/DeadlinesPage.tsx b/frontend/src/pages/Deadlines/DeadlinesPage.tsx new file mode 100644 index 0000000..358550d --- /dev/null +++ b/frontend/src/pages/Deadlines/DeadlinesPage.tsx @@ -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 ( +
    navigate(`/messages/${item.id}`)} + > +
    + {item.is_overdue ? ( + + ) : ( + + )} +
    +
    +

    {item.subject || '(nessun oggetto)'}

    +

    + {item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`} +

    + {item.deadline_note && ( +

    {item.deadline_note}

    + )} +
    +
    +

    + {deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'} +

    + {item.is_overdue && ( +

    Scaduto

    + )} +
    + +
    + ) +} + +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 ( +
    +
    + +

    {title} ({items.length})

    +
    +
    + {items.map((item) => ( + + ))} +
    +
    + ) +} + +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 ( +
    +
    +
    +

    + + Scadenzario +

    +

    + {total} messaggi con scadenze +

    +
    +
    + + +
    +
    + +
    + {isLoading ? ( +
    +
    +
    + ) : total === 0 ? ( +
    + +

    Nessuna scadenza trovata

    +

    + Le scadenze si impostano dal dettaglio di ogni messaggio. +

    +
    + ) : ( +
    + + + + +
    + )} +
    +
    + ) +} diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index 4a99c74..0b03a74 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -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>(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) { Aggiorna - + {viewMode !== 'trash' && ( + + )}
    @@ -440,13 +561,107 @@ export function InboxPage({ viewMode }: InboxPageProps) { Preferiti )} + + {/* Pulsante filtri avanzati */} +
    + + {/* ── Pannello filtri avanzati ── */} + {showAdvancedFilters && ( +
    + {/* Data da */} +
    + + setDateFrom(e.target.value)} + /> +
    + + {/* Data a */} +
    + + setDateTo(e.target.value)} + /> +
    + + {/* Tipo PEC */} +
    + + +
    + + {/* Stato PEC */} +
    + + +
    + + {/* Reset filtri */} + {activeAdvancedFiltersCount > 0 && ( +
    + +
    + )} +
    + )}
    - {/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */} + {/* ── Barra azioni bulk ── */} {someSelected && (
    - {/* Contatore + deseleziona */}
    + )} + + {/* Stella */} + {viewMode !== 'starred' && viewMode !== 'trash' && ( + {/* Cestino / Ripristina cestino (solo modalita' casella) */} + {isMailboxMode && viewMode !== 'trash' && ( + + )} + + {isMailboxMode && viewMode === 'trash' && ( + + )} + + {/* Tag */} + {viewMode !== 'trash' && ( + + )} + + {/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */} + {canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && ( + + )} + + {/* Rimuovi da Da Conservare */} + {canConserve && viewMode === 'conservation_pending' && ( + + )}
    )} @@ -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'}

    ) : (
    - {/* Riga "seleziona tutto" */} {messages.length > 0 && (
    - {/* Tag badges */} {message.labels && message.labels.length > 0 && (
    e.stopPropagation()}> @@ -784,9 +1093,9 @@ function MessageRow({ )}
    - {/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */} + {/* ── Azioni rapide (visibili su hover) ── */}
    - {/* Pulsante stella (rapido, su hover o se stellata) */} + {/* Stella */} - {/* Pulsante archivia/ripristina (rapido, su hover) */} - + {/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */} + {isMailboxMode && message.is_read && viewMode !== 'trash' && ( + + )} + + {/* Archivia/Ripristina (non nel cestino) */} + {viewMode !== 'trash' && ( + + )} + + {/* Cestino / Ripristina dal cestino (solo in modalita' casella) */} + {isMailboxMode && ( + + )} + + {/* Invia a Conservazione / Rimuovi da Da Conservare */} + {canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && ( + + )} {/* Indicatore allegati */} {message.has_attachments && ( diff --git a/frontend/src/pages/Mailboxes/MailboxesPage.tsx b/frontend/src/pages/Mailboxes/MailboxesPage.tsx index 2eb04ed..955ff4a 100644 --- a/frontend/src/pages/Mailboxes/MailboxesPage.tsx +++ b/frontend/src/pages/Mailboxes/MailboxesPage.tsx @@ -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(null) const [testingId, setTestingId] = useState(null) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [syncingId, setSyncingId] = useState(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() {

    Caselle PEC

    ({mailboxes.length})
    - + {isAdmin && ( + + )}
    {/* Contenuto */} @@ -111,12 +133,14 @@ export function MailboxesPage() {

    Nessuna casella PEC configurata

    - Aggiungi una casella per iniziare a gestire le PEC + {isAdmin ? 'Aggiungi una casella per iniziare a gestire le PEC' : 'Nessuna casella disponibile'}

    - + {isAdmin && ( + + )} ) : (
    @@ -168,38 +192,53 @@ export function MailboxesPage() {
    -
    - {/* Test connessione */} - + {isAdmin && ( +
    + {/* Test connessione */} + - {/* Modifica */} - + {/* Forza sincronizzazione */} + - {/* Elimina */} - -
    + {/* Modifica */} + + + {/* Elimina */} + +
    + )} ) })} diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index c927521..170cc35 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -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 ( +
    +

    + + Conversazione ({thread.length} messaggi) +

    +
    + {thread.map((msg) => { + const isCurrent = msg.id === currentId + return ( + + ) + })} +
    +
    + ) +} + +// ─── 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 ( +
    +
    +
    + {filename} + +
    +
    + {isLoading && ( +
    + )} + {!isLoading && data && ( + <> + {data.previewable && data.url ? ( + <> + {contentType.startsWith('image/') ? ( + {filename} + ) : contentType === 'application/pdf' ? ( +