mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Semantic search
This commit is contained in:
@@ -888,44 +888,6 @@ END $$;
|
|||||||
|
|
||||||
## 4. Decisioni Architetturali
|
## 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
|
### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo
|
||||||
|
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
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
|
||||||
|
1. Dispatch automatico notifiche (Sistema di notifiche incompleto al 60%)
|
||||||
|
|
||||||
|
Il CRUD canali/regole/log e' implementato, ma manca tutto il lato dispatch
|
||||||
|
Non esiste worker/app/jobs/dispatch_notification.py
|
||||||
|
NotificationService non ha il metodo evaluate_rules(event_type, message) che valuta le regole e accoda i job
|
||||||
|
L'IMAP sync (sync.py) non chiama nulla al salvataggio di un nuovo messaggio
|
||||||
|
Il test canale webhook e email e' uno stub che restituisce sempre successo (solo Telegram ha invio reale)
|
||||||
|
La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i segreti (bot_token, webhook_secret, smtp_password) sono leggibili in chiaro nel DB
|
||||||
|
Canale WhatsApp: nessuna implementazione reale (stub completo)
|
||||||
|
Canale Email SMTP: nessuna implementazione reale (stub completo)
|
||||||
|
Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente
|
||||||
|
2. Ricerca avanzata full-text (Fase 5-B – completamente mancante)
|
||||||
|
|
||||||
|
Non esiste backend/app/api/v1/search.py
|
||||||
|
Non esiste backend/app/services/search_service.py
|
||||||
|
Non esiste frontend/src/pages/Search/ ne' frontend/src/api/search.api.ts
|
||||||
|
La "ricerca" nell'InboxPage usa solo ILIKE su subject/from_address/body_text: e' lenta su volumi grandi e non cerca nel testo degli allegati
|
||||||
|
La colonna search_vector tsvector non e' nelle migrazioni Alembic attuali (0001–0007)
|
||||||
|
Non c'e' Apache Tika e non c'e' worker/app/jobs/index_message.py per l'estrazione testo da PDF/DOCX
|
||||||
|
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
|
||||||
|
4. Dashboard e Reportistica (Fase 7 – completamente mancante)
|
||||||
|
|
||||||
|
Non esistono endpoint /reports/summary, /reports/export
|
||||||
|
Non esiste pagina Reports/Dashboard nel frontend (nessuna rotta in App.tsx)
|
||||||
|
Non c'e' generazione PDF (WeasyPrint) ne' export CSV
|
||||||
|
Non c'e' nessun grafico o KPI visibile (PEC ricevute/inviate oggi, anomalie, tasso consegna)
|
||||||
|
5. Audit Log – modello esistente, tutto il resto mancante
|
||||||
|
|
||||||
|
Il modello audit_log.py e la tabella esistono
|
||||||
|
Non c'e' nessun endpoint API GET /audit-log per leggerlo
|
||||||
|
Non c'e' nessuna pagina frontend per la visualizzazione
|
||||||
|
Non e' chiaro se il backend registra effettivamente gli eventi (nessuna chiamata a AuditLog trovata nei servizi)
|
||||||
|
COSA MANCA – PRIORITA' MEDIA
|
||||||
|
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
|
||||||
|
8. Invio PEC – funzionalita' mancanti
|
||||||
|
|
||||||
|
Non c'e' Forward messaggio (la risposta e' parzialmente implementata in ComposePage ma non e' chiaro se funziona end-to-end)
|
||||||
|
Non c'e' endpoint per forzare un re-sync manuale di una casella (utile dopo un errore di connessione)
|
||||||
|
Non c'e' indicazione visiva del numero di messaggi non letti nella sidebar per casella
|
||||||
|
La barra ricerca nell'Inbox non ha filtri per data (da/a), stato PEC, tipo PEC
|
||||||
|
9. Ruolo Supervisor
|
||||||
|
|
||||||
|
Il ruolo supervisor e' definito nell'enum DB e nella documentazione ma non ha logica differenziata dal operator nel codice: is_admin controlla solo admin/super_admin, tutto il resto e' trattato uguale
|
||||||
|
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'.
|
||||||
@@ -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')
|
||||||
@@ -26,6 +26,8 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.services.search_service import SearchService
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.exceptions import ForbiddenError, NotFoundError
|
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -184,8 +186,11 @@ async def list_messages(
|
|||||||
is_starred: Optional[bool] = Query(None),
|
is_starred: Optional[bool] = Query(None),
|
||||||
is_archived: Optional[bool] = Query(False),
|
is_archived: Optional[bool] = Query(False),
|
||||||
is_trashed: Optional[bool] = Query(False),
|
is_trashed: Optional[bool] = Query(False),
|
||||||
search: Optional[str] = Query(None, max_length=200),
|
search: Optional[str] = Query(None, max_length=500),
|
||||||
pec_type: Optional[str] = Query(None),
|
pec_type: Optional[str] = Query(None),
|
||||||
|
# Filtri data (ISO 8601, es. 2026-01-01T00:00:00Z)
|
||||||
|
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)"),
|
||||||
# Paginazione
|
# Paginazione
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
@@ -195,7 +200,8 @@ async def list_messages(
|
|||||||
|
|
||||||
- `is_archived=False` (default) esclude i messaggi archiviati.
|
- `is_archived=False` (default) esclude i messaggi archiviati.
|
||||||
- `is_trashed=False` (default) esclude i messaggi nel cestino.
|
- `is_trashed=False` (default) esclude i messaggi nel cestino.
|
||||||
- `search` cerca su subject, from_address, to_addresses.
|
- `search` usa ricerca full-text (tsvector) con fallback ILIKE.
|
||||||
|
- `date_from` / `date_to` filtrano per data ricezione o invio.
|
||||||
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
|
- `vbox_id` filtra per Virtual Box assegnata all'utente corrente.
|
||||||
"""
|
"""
|
||||||
# Determinare le caselle visibili (normale check permessi)
|
# Determinare le caselle visibili (normale check permessi)
|
||||||
@@ -284,16 +290,30 @@ async def list_messages(
|
|||||||
if is_trashed is not None:
|
if is_trashed is not None:
|
||||||
q = q.where(Message.is_trashed == is_trashed)
|
q = q.where(Message.is_trashed == is_trashed)
|
||||||
|
|
||||||
|
# ── Full-text search (FTS con fallback ILIKE per messaggi non indicizzati) ───
|
||||||
if 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(
|
q = q.where(
|
||||||
or_(
|
or_(
|
||||||
Message.subject.ilike(term),
|
Message.search_vector.op("@@")(tsquery),
|
||||||
Message.from_address.ilike(term),
|
# Fallback per messaggi non ancora indicizzati dal worker
|
||||||
Message.body_text.ilike(term),
|
Message.search_vector.is_(None) & or_(
|
||||||
|
Message.subject.ilike(term_like),
|
||||||
|
Message.from_address.ilike(term_like),
|
||||||
|
Message.body_text.ilike(term_like),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 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))
|
||||||
|
|
||||||
# Applica le regole della Virtual Box (AND tra le regole)
|
# Applica le regole della Virtual Box (AND tra le regole)
|
||||||
for rule in vbox_rules:
|
for rule in vbox_rules:
|
||||||
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
|
q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value)
|
||||||
@@ -302,13 +322,23 @@ async def list_messages(
|
|||||||
count_q = select(func.count()).select_from(q.subquery())
|
count_q = select(func.count()).select_from(q.subquery())
|
||||||
total = (await db.execute(count_q)).scalar_one()
|
total = (await db.execute(count_q)).scalar_one()
|
||||||
|
|
||||||
# Ordinamento e paginazione
|
# Ordinamento: se c'e' una ricerca, ordina per rilevanza FTS, poi data
|
||||||
|
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()]
|
||||||
|
|
||||||
|
# Paginazione
|
||||||
q = (
|
q = (
|
||||||
q.options(selectinload(Message.labels))
|
q.options(selectinload(Message.labels))
|
||||||
.order_by(
|
.order_by(*order_clauses)
|
||||||
Message.received_at.desc().nullslast(),
|
|
||||||
Message.created_at.desc(),
|
|
||||||
)
|
|
||||||
.offset((page - 1) * page_size)
|
.offset((page - 1) * page_size)
|
||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob.
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
ARRAY,
|
ARRAY,
|
||||||
@@ -18,7 +19,7 @@ from sqlalchemy import (
|
|||||||
Text,
|
Text,
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import TSVECTOR, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -96,6 +97,9 @@ class Message(Base):
|
|||||||
|
|
||||||
raw_eml_path: Mapped[str | None] = mapped_column(Text, 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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
@@ -126,6 +130,7 @@ class Message(Base):
|
|||||||
postgresql_where="parent_message_id IS NOT NULL",
|
postgresql_where="parent_message_id IS NOT NULL",
|
||||||
),
|
),
|
||||||
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
|
Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"),
|
||||||
|
Index("idx_messages_fts", "search_vector", postgresql_using="gin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -149,6 +154,8 @@ class Attachment(Base):
|
|||||||
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,6 +11,7 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage'
|
|||||||
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
|
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
|
||||||
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
|
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
|
||||||
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
|
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
|
||||||
|
import { SearchPage } from '@/pages/Search/SearchPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routing principale dell'applicazione PEChub.
|
* Routing principale dell'applicazione PEChub.
|
||||||
@@ -76,6 +77,9 @@ export default function App() {
|
|||||||
{/* Super Admin – Gestione Multi-Tenant */}
|
{/* Super Admin – Gestione Multi-Tenant */}
|
||||||
<Route path="/multitenant" element={<MultiTenantPage />} />
|
<Route path="/multitenant" element={<MultiTenantPage />} />
|
||||||
|
|
||||||
|
{/* Ricerca avanzata full-text */}
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
|
|
||||||
{/* Profilo utente */}
|
{/* Profilo utente */}
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ export interface MessageFilters {
|
|||||||
mailbox_id?: string
|
mailbox_id?: string
|
||||||
direction?: 'inbound' | 'outbound'
|
direction?: 'inbound' | 'outbound'
|
||||||
state?: string
|
state?: string
|
||||||
|
pec_type?: string
|
||||||
is_read?: boolean
|
is_read?: boolean
|
||||||
is_starred?: boolean
|
is_starred?: boolean
|
||||||
is_archived?: boolean
|
is_archived?: boolean
|
||||||
is_trashed?: boolean
|
is_trashed?: boolean
|
||||||
search?: string
|
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 {
|
export interface MessageBulkUpdatePayload {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
Building2,
|
Building2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
@@ -343,6 +344,29 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Ricerca avanzata ── */}
|
||||||
|
<div>
|
||||||
|
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||||
|
<div className="px-2">
|
||||||
|
<NavLink
|
||||||
|
to="/search"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
|
||||||
|
collapsed && 'justify-center px-2',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={collapsed ? 'Ricerca avanzata' : undefined}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>Ricerca</span>}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Nuova PEC ── */}
|
{/* ── Nuova PEC ── */}
|
||||||
<div>
|
<div>
|
||||||
<div className="border-t border-gray-700 mx-4 mb-3" />
|
<div className="border-t border-gray-700 mx-4 mb-3" />
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
MailX,
|
MailX,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
@@ -80,6 +83,14 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
// ── Filtri avanzati ──────────────────────────────────────────────────────────
|
||||||
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
const [pecTypeFilter, setPecTypeFilter] = useState('')
|
||||||
|
|
||||||
|
const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter].filter(Boolean).length
|
||||||
|
|
||||||
// ── Stato selezione ─────────────────────────────────────────────────────────
|
// ── Stato selezione ─────────────────────────────────────────────────────────
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
@@ -91,6 +102,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
|||||||
setDebouncedSearch('')
|
setDebouncedSearch('')
|
||||||
setIsReadFilter(undefined)
|
setIsReadFilter(undefined)
|
||||||
setIsStarredFilter(undefined)
|
setIsStarredFilter(undefined)
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
|
setPecTypeFilter('')
|
||||||
|
setShowAdvancedFilters(false)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
}, [mailboxId, vboxId, viewMode])
|
}, [mailboxId, vboxId, viewMode])
|
||||||
@@ -125,19 +140,27 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
|||||||
? myVboxes.find((v) => v.id === vboxId)
|
? myVboxes.find((v) => v.id === vboxId)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
// ── Reset pagina per filtri avanzati ─────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [dateFrom, dateTo, pecTypeFilter])
|
||||||
|
|
||||||
// ── Query messaggi ───────────────────────────────────────────────────────────
|
// ── Query messaggi ───────────────────────────────────────────────────────────
|
||||||
const queryFilters = (() => {
|
const queryFilters = (() => {
|
||||||
const base = {
|
const advancedBase = {
|
||||||
vbox_id: vboxId,
|
vbox_id: vboxId,
|
||||||
mailbox_id: mailboxId,
|
mailbox_id: mailboxId,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
|
pec_type: pecTypeFilter || undefined,
|
||||||
|
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
|
||||||
|
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
|
||||||
page,
|
page,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
}
|
}
|
||||||
switch (viewMode) {
|
switch (viewMode) {
|
||||||
case 'inbox':
|
case 'inbox':
|
||||||
return {
|
return {
|
||||||
...base,
|
...advancedBase,
|
||||||
direction: 'inbound' as const,
|
direction: 'inbound' as const,
|
||||||
is_read: isReadFilter,
|
is_read: isReadFilter,
|
||||||
is_starred: isStarredFilter,
|
is_starred: isStarredFilter,
|
||||||
@@ -146,7 +169,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
|||||||
}
|
}
|
||||||
case 'sent':
|
case 'sent':
|
||||||
return {
|
return {
|
||||||
...base,
|
...advancedBase,
|
||||||
direction: 'outbound' as const,
|
direction: 'outbound' as const,
|
||||||
is_starred: isStarredFilter,
|
is_starred: isStarredFilter,
|
||||||
is_archived: false,
|
is_archived: false,
|
||||||
@@ -154,20 +177,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
|||||||
}
|
}
|
||||||
case 'starred':
|
case 'starred':
|
||||||
return {
|
return {
|
||||||
...base,
|
...advancedBase,
|
||||||
is_starred: true,
|
is_starred: true,
|
||||||
is_archived: false,
|
is_archived: false,
|
||||||
is_trashed: false,
|
is_trashed: false,
|
||||||
}
|
}
|
||||||
case 'archived':
|
case 'archived':
|
||||||
return {
|
return {
|
||||||
...base,
|
...advancedBase,
|
||||||
is_archived: true,
|
is_archived: true,
|
||||||
is_trashed: false,
|
is_trashed: false,
|
||||||
}
|
}
|
||||||
case 'trash':
|
case 'trash':
|
||||||
return {
|
return {
|
||||||
...base,
|
...advancedBase,
|
||||||
is_trashed: true,
|
is_trashed: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +522,84 @@ export function InboxPage({ viewMode }: InboxPageProps) {
|
|||||||
Preferiti
|
Preferiti
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pulsante filtri avanzati */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('h-9 text-xs', activeAdvancedFiltersCount > 0 && 'border-primary text-primary')}
|
||||||
|
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Filtri
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center h-4 w-4 rounded-full bg-primary text-white text-[10px] font-bold">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showAdvancedFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Pannello filtri avanzati ── */}
|
||||||
|
{showAdvancedFilters && (
|
||||||
|
<div className="mt-3 bg-muted/40 rounded-lg border p-3 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{/* Data da */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data da</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data a */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data a</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tipo PEC */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">Tipo PEC</label>
|
||||||
|
<select
|
||||||
|
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={pecTypeFilter}
|
||||||
|
onChange={(e) => setPecTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Tutti i tipi</option>
|
||||||
|
<option value="posta_certificata">Posta certificata</option>
|
||||||
|
<option value="accettazione">Accettazione</option>
|
||||||
|
<option value="avvenuta_consegna">Avvenuta consegna</option>
|
||||||
|
<option value="mancata_consegna">Mancata consegna</option>
|
||||||
|
<option value="non_accettazione">Non accettazione</option>
|
||||||
|
<option value="errore_consegna">Errore consegna</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset filtri */}
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs text-muted-foreground w-full"
|
||||||
|
onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter('') }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Azzera
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Barra azioni bulk ── */}
|
{/* ── Barra azioni bulk ── */}
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
/**
|
||||||
|
* SearchPage – ricerca full-text avanzata tra tutti i messaggi PEC.
|
||||||
|
*
|
||||||
|
* Funzionalita':
|
||||||
|
* - Barra di ricerca full-text (usa FTS su search_vector PostgreSQL)
|
||||||
|
* - Pannello filtri avanzati collassabile:
|
||||||
|
* - Data da / Data a
|
||||||
|
* - Direzione (in arrivo / inviata)
|
||||||
|
* - Stato PEC
|
||||||
|
* - Tipo PEC
|
||||||
|
* - Casella specifica
|
||||||
|
* - Lista risultati ordinata per rilevanza
|
||||||
|
* - Paginazione
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
X,
|
||||||
|
Inbox,
|
||||||
|
Send,
|
||||||
|
MailOpen,
|
||||||
|
Mail,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Input } from '@/components/ui/Input'
|
||||||
|
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
|
||||||
|
import { TagBadgeList } from '@/components/TagManager/TagBadge'
|
||||||
|
import { messagesApi } from '@/api/messages.api'
|
||||||
|
import { mailboxesApi } from '@/api/mailboxes.api'
|
||||||
|
import { formatRelative, truncate } from '@/lib/utils'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { MessageResponse } from '@/types/api.types'
|
||||||
|
|
||||||
|
// ─── Costanti ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const PEC_STATES = [
|
||||||
|
{ value: '', label: 'Tutti gli stati' },
|
||||||
|
{ value: 'received', label: 'Ricevuta' },
|
||||||
|
{ value: 'sent', label: 'Inviata' },
|
||||||
|
{ value: 'accepted', label: 'Accettata' },
|
||||||
|
{ value: 'delivered', label: 'Consegnata' },
|
||||||
|
{ value: 'anomaly', label: 'Anomalia' },
|
||||||
|
{ value: 'failed', label: 'Fallita' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PEC_TYPES = [
|
||||||
|
{ value: '', label: 'Tutti i tipi' },
|
||||||
|
{ value: 'posta_certificata', label: 'Posta certificata' },
|
||||||
|
{ value: 'accettazione', label: 'Accettazione' },
|
||||||
|
{ value: 'avvenuta_consegna', label: 'Avvenuta consegna' },
|
||||||
|
{ value: 'mancata_consegna', label: 'Mancata consegna' },
|
||||||
|
{ value: 'non_accettazione', label: 'Non accettazione' },
|
||||||
|
{ value: 'presa_in_carico', label: 'Presa in carico' },
|
||||||
|
{ value: 'errore_consegna', label: 'Errore consegna' },
|
||||||
|
{ value: 'preavviso_mancata_consegna', label: 'Preavviso mancata consegna' },
|
||||||
|
{ value: 'rilevazione_virus', label: 'Rilevazione virus' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DIRECTIONS = [
|
||||||
|
{ value: '', label: 'Tutte le direzioni' },
|
||||||
|
{ value: 'inbound', label: 'In arrivo' },
|
||||||
|
{ value: 'outbound', label: 'Inviata' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Componente principale ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SearchPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
|
// Legge i parametri dall'URL (permette di condividere/bookmark la ricerca)
|
||||||
|
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '')
|
||||||
|
const [committedSearch, setCommittedSearch] = useState(searchParams.get('q') || '')
|
||||||
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
// Filtri avanzati
|
||||||
|
const [dateFrom, setDateFrom] = useState(searchParams.get('date_from') || '')
|
||||||
|
const [dateTo, setDateTo] = useState(searchParams.get('date_to') || '')
|
||||||
|
const [direction, setDirection] = useState(searchParams.get('direction') || '')
|
||||||
|
const [state, setState] = useState(searchParams.get('state') || '')
|
||||||
|
const [pecType, setPecType] = useState(searchParams.get('pec_type') || '')
|
||||||
|
const [mailboxId, setMailboxId] = useState(searchParams.get('mailbox_id') || '')
|
||||||
|
|
||||||
|
// Caselle disponibili per il filtro
|
||||||
|
const { data: mailboxesData } = useQuery({
|
||||||
|
queryKey: ['mailboxes'],
|
||||||
|
queryFn: () => mailboxesApi.list(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const mailboxes = mailboxesData?.items ?? []
|
||||||
|
|
||||||
|
// Numero di filtri attivi
|
||||||
|
const activeFiltersCount = [dateFrom, dateTo, direction, state, pecType, mailboxId].filter(Boolean).length
|
||||||
|
|
||||||
|
// Sincronizza l'URL quando cambiano i filtri
|
||||||
|
useEffect(() => {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (committedSearch) params.q = committedSearch
|
||||||
|
if (dateFrom) params.date_from = dateFrom
|
||||||
|
if (dateTo) params.date_to = dateTo
|
||||||
|
if (direction) params.direction = direction
|
||||||
|
if (state) params.state = state
|
||||||
|
if (pecType) params.pec_type = pecType
|
||||||
|
if (mailboxId) params.mailbox_id = mailboxId
|
||||||
|
setSearchParams(params, { replace: true })
|
||||||
|
}, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId, setSearchParams])
|
||||||
|
|
||||||
|
// Reset pagina quando cambiano i filtri
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId])
|
||||||
|
|
||||||
|
// Costruisce i filtri per l'API
|
||||||
|
const filters = {
|
||||||
|
search: committedSearch || undefined,
|
||||||
|
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
|
||||||
|
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
|
||||||
|
direction: direction as 'inbound' | 'outbound' | undefined || undefined,
|
||||||
|
state: state || undefined,
|
||||||
|
pec_type: pecType || undefined,
|
||||||
|
mailbox_id: mailboxId || undefined,
|
||||||
|
is_archived: undefined as boolean | undefined,
|
||||||
|
is_trashed: false,
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query messaggi
|
||||||
|
const {
|
||||||
|
data: messagesData,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['search', filters],
|
||||||
|
queryFn: () => messagesApi.list(filters),
|
||||||
|
enabled: !!(committedSearch || dateFrom || dateTo || direction || state || pecType || mailboxId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = messagesData?.items || []
|
||||||
|
const total = messagesData?.total || 0
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
|
const hasQuery = !!(committedSearch || activeFiltersCount > 0)
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
setCommittedSearch(searchInput.trim())
|
||||||
|
setPage(1)
|
||||||
|
}, [searchInput])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') handleSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSearchInput('')
|
||||||
|
setCommittedSearch('')
|
||||||
|
setDateFrom('')
|
||||||
|
setDateTo('')
|
||||||
|
setDirection('')
|
||||||
|
setState('')
|
||||||
|
setPecType('')
|
||||||
|
setMailboxId('')
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessageClick = (message: MessageResponse) => {
|
||||||
|
navigate(`/messages/${message.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* ── Header ricerca ── */}
|
||||||
|
<div className="border-b bg-background px-6 py-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<Search className="h-5 w-5 text-primary" />
|
||||||
|
Ricerca avanzata
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barra di ricerca principale */}
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder='Cerca in tutti i messaggi… (es. "fattura gennaio" -spam)'
|
||||||
|
className="pl-9 h-10 text-sm"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearchInput(''); setCommittedSearch('') }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSearch} className="h-10 px-5">
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
Cerca
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn('h-10', activeFiltersCount > 0 && 'border-primary text-primary')}
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Filtri
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary text-white text-xs font-bold">
|
||||||
|
{activeFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showFilters ? <ChevronUp className="h-3.5 w-3.5 ml-2" /> : <ChevronDown className="h-3.5 w-3.5 ml-2" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pannello filtri avanzati */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="bg-muted/40 rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{/* Data da */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
Data da
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data a */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
Data a
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direzione */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
Direzione
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={direction}
|
||||||
|
onChange={(e) => setDirection(e.target.value)}
|
||||||
|
>
|
||||||
|
{DIRECTIONS.map((d) => (
|
||||||
|
<option key={d.value} value={d.value}>{d.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stato PEC */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
Stato PEC
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={state}
|
||||||
|
onChange={(e) => setState(e.target.value)}
|
||||||
|
>
|
||||||
|
{PEC_STATES.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tipo PEC */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
Tipo PEC
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={pecType}
|
||||||
|
onChange={(e) => setPecType(e.target.value)}
|
||||||
|
>
|
||||||
|
{PEC_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Casella */}
|
||||||
|
{mailboxes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
Casella PEC
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={mailboxId}
|
||||||
|
onChange={(e) => setMailboxId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Tutte le caselle</option>
|
||||||
|
{mailboxes.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.display_name || m.email_address}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pulsante reset filtri */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleClearAll} className="text-xs text-muted-foreground">
|
||||||
|
<X className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Azzera tutti i filtri
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Risultati ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{!hasQuery ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||||
|
<p className="text-muted-foreground font-medium">Inizia a cercare</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-1 max-w-xs">
|
||||||
|
Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : isLoading || isFetching ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
<p className="text-sm text-muted-foreground">Ricerca in corso…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||||
|
<p className="text-muted-foreground font-medium">Nessun risultato</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-1">
|
||||||
|
Prova a modificare i termini di ricerca o i filtri
|
||||||
|
</p>
|
||||||
|
{hasQuery && (
|
||||||
|
<Button variant="ghost" size="sm" className="mt-3" onClick={handleClearAll}>
|
||||||
|
<X className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Azzera ricerca
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Intestazione risultati */}
|
||||||
|
<div className="px-6 py-2.5 bg-muted/20 border-b flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{total}</span>{' '}
|
||||||
|
{total === 1 ? 'risultato trovato' : 'risultati trovati'}
|
||||||
|
{committedSearch && (
|
||||||
|
<> per <span className="font-medium text-foreground italic">"{committedSearch}"</span></>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{(isFetching) && (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista risultati */}
|
||||||
|
<div className="divide-y">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<SearchResultRow
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
searchTerm={committedSearch}
|
||||||
|
mailboxName={
|
||||||
|
mailboxes.find((m) => m.id === message.mailbox_id)?.email_address
|
||||||
|
}
|
||||||
|
onClick={() => handleMessageClick(message)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Paginazione ── */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="border-t px-6 py-3 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pagina {page} di {totalPages} ({total} risultati)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Riga singolo risultato ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SearchResultRowProps {
|
||||||
|
message: MessageResponse
|
||||||
|
searchTerm: string
|
||||||
|
mailboxName?: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchResultRowProps) {
|
||||||
|
const isUnread = !message.is_read && message.direction === 'inbound'
|
||||||
|
|
||||||
|
// Evidenzia il termine cercato nel testo
|
||||||
|
const highlight = (text: string | null | undefined, term: string): React.ReactNode => {
|
||||||
|
if (!text || !term) return text || ''
|
||||||
|
const idx = text.toLowerCase().indexOf(term.toLowerCase())
|
||||||
|
if (idx === -1) return truncate(text, 150)
|
||||||
|
const start = Math.max(0, idx - 60)
|
||||||
|
const end = Math.min(text.length, idx + term.length + 90)
|
||||||
|
const snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')
|
||||||
|
const snipIdx = snippet.toLowerCase().indexOf(term.toLowerCase())
|
||||||
|
if (snipIdx === -1) return snippet
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{snippet.slice(0, snipIdx)}
|
||||||
|
<mark className="bg-yellow-200 dark:bg-yellow-800/60 rounded px-0.5 font-medium">
|
||||||
|
{snippet.slice(snipIdx, snipIdx + term.length)}
|
||||||
|
</mark>
|
||||||
|
{snippet.slice(snipIdx + term.length)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 px-6 py-3.5 cursor-pointer hover:bg-muted/50 transition-colors',
|
||||||
|
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* Icona direzione */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{message.direction === 'inbound' ? (
|
||||||
|
isUnread ? (
|
||||||
|
<Mail className="h-5 w-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<MailOpen className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Send className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenuto */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Riga 1: mittente + badge casella + stato + data */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm truncate',
|
||||||
|
isUnread ? 'font-semibold' : 'font-medium',
|
||||||
|
)}>
|
||||||
|
{message.direction === 'inbound'
|
||||||
|
? (message.from_address || 'Mittente sconosciuto')
|
||||||
|
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
|
||||||
|
</span>
|
||||||
|
{mailboxName && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
|
||||||
|
{mailboxName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<PecStateBadge state={message.state} />
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatRelative(message.received_at || message.sent_at || message.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Riga 2: oggetto */}
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
{isUnread && <span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0" />}
|
||||||
|
<p className={cn(
|
||||||
|
'text-sm',
|
||||||
|
isUnread ? 'font-medium text-foreground' : 'text-foreground',
|
||||||
|
)}>
|
||||||
|
{searchTerm
|
||||||
|
? highlight(message.subject || '(nessun oggetto)', searchTerm)
|
||||||
|
: truncate(message.subject || '(nessun oggetto)', 100)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Riga 3: snippet corpo */}
|
||||||
|
{message.body_text && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tag */}
|
||||||
|
{message.labels && message.labels.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicatore allegati */}
|
||||||
|
{message.has_attachments && (
|
||||||
|
<span className="text-xs text-muted-foreground flex-shrink-0 mt-1"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.jobs.index_message import index_message
|
||||||
from app.models import Attachment, Mailbox, Message
|
from app.models import Attachment, Mailbox, Message
|
||||||
from app.parsers.eml_parser import parse_eml
|
from app.parsers.eml_parser import parse_eml
|
||||||
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
|
from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message
|
||||||
@@ -635,6 +636,12 @@ async def _save_message(
|
|||||||
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
|
f"direction={direction!r} pec_type={pec_class.pec_type!r} "
|
||||||
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
|
f"subject={message.subject!r} allegati={len(parsed.attachments)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Indicizzazione full-text (non bloccante, non interrompe la sync) ─────
|
||||||
|
# Chiamata dopo il flush degli allegati: index_message puo' leggere
|
||||||
|
# sia il messaggio che gli allegati dalla sessione corrente.
|
||||||
|
await index_message(message.id, db)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Indicizzazione full-text dei messaggi PEC.
|
||||||
|
|
||||||
|
Responsabilita':
|
||||||
|
1. Scarica gli allegati PDF e DOCX da MinIO
|
||||||
|
2. Estrae il testo con pypdf (PDF) e python-docx (DOCX)
|
||||||
|
3. Aggiorna la colonna extracted_text in attachments
|
||||||
|
4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati
|
||||||
|
|
||||||
|
Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante:
|
||||||
|
un'eccezione qui non interrompe la sincronizzazione del messaggio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Dimensione massima del testo estratto per allegato (caratteri)
|
||||||
|
MAX_EXTRACTED_TEXT_LEN = 50_000
|
||||||
|
# Dimensione massima del testo aggregato degli allegati per il search_vector
|
||||||
|
MAX_COMBINED_TEXT_LEN = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Estrazione testo ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _extract_pdf_text(content: bytes) -> str:
|
||||||
|
"""Estrae testo da un PDF usando pypdf."""
|
||||||
|
try:
|
||||||
|
import pypdf # type: ignore[import]
|
||||||
|
|
||||||
|
reader = pypdf.PdfReader(io.BytesIO(content))
|
||||||
|
parts: list[str] = []
|
||||||
|
for page in reader.pages:
|
||||||
|
try:
|
||||||
|
txt = page.extract_text()
|
||||||
|
if txt:
|
||||||
|
parts.append(txt)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return " ".join(parts)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pypdf non installato: impossibile estrarre testo da PDF")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Errore estrazione testo PDF: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_docx_text(content: bytes) -> str:
|
||||||
|
"""Estrae testo da un DOCX usando python-docx."""
|
||||||
|
try:
|
||||||
|
import docx # type: ignore[import]
|
||||||
|
|
||||||
|
doc = docx.Document(io.BytesIO(content))
|
||||||
|
parts = [para.text for para in doc.paragraphs if para.text and para.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 testo DOCX: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pdf(content_type: str | None, filename: str | None) -> bool:
|
||||||
|
ct = (content_type or "").lower()
|
||||||
|
fn = (filename or "").lower()
|
||||||
|
return ct == "application/pdf" or fn.endswith(".pdf")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_docx(content_type: str | None, filename: str | None) -> bool:
|
||||||
|
ct = (content_type or "").lower()
|
||||||
|
fn = (filename or "").lower()
|
||||||
|
return ct in (
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.ms-word",
|
||||||
|
) or fn.endswith((".docx", ".doc"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Job principale ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def index_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Indicizza un messaggio per la ricerca full-text.
|
||||||
|
|
||||||
|
Non solleva eccezioni: tutti gli errori vengono loggati ma non propagati,
|
||||||
|
per non interrompere il flusso di sincronizzazione.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await _do_index_message(message_id, db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Errore indicizzazione messaggio {message_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_index_message(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Logica interna di indicizzazione (puo' sollevare eccezioni)."""
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Attachment, Message
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# ── Carica il messaggio ───────────────────────────────────────────────────
|
||||||
|
msg_result = await db.execute(
|
||||||
|
select(Message).where(Message.id == message_id)
|
||||||
|
)
|
||||||
|
message = msg_result.scalar_one_or_none()
|
||||||
|
if not message:
|
||||||
|
logger.warning(f"index_message: messaggio {message_id} non trovato in DB")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Carica gli allegati ───────────────────────────────────────────────────
|
||||||
|
att_result = await db.execute(
|
||||||
|
select(Attachment).where(Attachment.message_id == message_id)
|
||||||
|
)
|
||||||
|
attachments = list(att_result.scalars().all())
|
||||||
|
|
||||||
|
if not attachments:
|
||||||
|
logger.debug(f"Messaggio {message_id}: nessun allegato, skip indicizzazione allegati")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Crea client MinIO ─────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from miniopy_async import Minio # type: ignore[import]
|
||||||
|
|
||||||
|
minio = Minio(
|
||||||
|
endpoint=settings.minio_endpoint,
|
||||||
|
access_key=settings.minio_access_key,
|
||||||
|
secret_key=settings.minio_secret_key,
|
||||||
|
secure=settings.minio_use_ssl,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Impossibile creare client MinIO per indicizzazione {message_id}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
bucket = settings.minio_bucket
|
||||||
|
attachment_texts: list[str] = []
|
||||||
|
indexed_count = 0
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
# Se gia' indicizzato, usa il testo cached
|
||||||
|
if att.extracted_text is not None:
|
||||||
|
attachment_texts.append(att.extracted_text)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Controlla se e' un PDF o DOCX
|
||||||
|
if not (_is_pdf(att.content_type, att.filename) or _is_docx(att.content_type, att.filename)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Scarica da MinIO
|
||||||
|
try:
|
||||||
|
response = await minio.get_object(bucket, att.storage_path)
|
||||||
|
content = await response.content.read()
|
||||||
|
response.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Impossibile scaricare allegato {att.id} "
|
||||||
|
f"({att.filename!r}) da MinIO: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Estrai testo
|
||||||
|
if _is_pdf(att.content_type, att.filename):
|
||||||
|
extracted = _extract_pdf_text(content)
|
||||||
|
else:
|
||||||
|
extracted = _extract_docx_text(content)
|
||||||
|
|
||||||
|
if not extracted or not extracted.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Limita la dimensione e salva
|
||||||
|
att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN]
|
||||||
|
attachment_texts.append(att.extracted_text)
|
||||||
|
indexed_count += 1
|
||||||
|
|
||||||
|
# ── Aggiorna search_vector includendo il testo degli allegati ─────────────
|
||||||
|
if attachment_texts:
|
||||||
|
combined = " ".join(attachment_texts)[:MAX_COMBINED_TEXT_LEN]
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE messages
|
||||||
|
SET search_vector =
|
||||||
|
setweight(to_tsvector('italian', coalesce(subject, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple',
|
||||||
|
coalesce(array_to_string(to_addresses, ' '), '')), 'B') ||
|
||||||
|
setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('italian', :att_text), 'D')
|
||||||
|
WHERE id = :message_id
|
||||||
|
"""),
|
||||||
|
{"att_text": combined, "message_id": str(message_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Indicizzazione completata: messaggio {message_id}, "
|
||||||
|
f"{indexed_count} allegati indicizzati su {len(attachments)} totali"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Messaggio {message_id}: nessun allegato PDF/DOCX con testo estraibile"
|
||||||
|
)
|
||||||
@@ -41,6 +41,10 @@ dependencies = [
|
|||||||
# Utilities
|
# Utilities
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"email-validator>=2.2.0",
|
"email-validator>=2.2.0",
|
||||||
|
|
||||||
|
# Full-text search: estrazione testo da allegati PDF e DOCX
|
||||||
|
"pypdf>=4.0.0",
|
||||||
|
"python-docx>=1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Reference in New Issue
Block a user