diff --git a/GapAnalysis.md b/GapAnalysis.md index 7f53277..cad5ced 100644 --- a/GapAnalysis.md +++ b/GapAnalysis.md @@ -52,14 +52,7 @@ La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i se 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 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/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/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/main.py b/backend/app/main.py index a300e83..2c22708 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 auth, labels, mailboxes, messages, notifications, permissions, reports, send, tenants, 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,7 @@ 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) # ─── Health check ───────────────────────────────────────────────────────────── 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/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/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/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/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 b0599fc..1c76a43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ 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' /** * Routing principale dell'applicazione PEChub. @@ -80,6 +81,9 @@ export default function App() { {/* Ricerca avanzata full-text */} } /> + {/* Dashboard e Reportistica */} + } /> + {/* Profilo utente */} } /> 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/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/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 6a12575..221f74f 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -51,6 +51,7 @@ import { Building2, Trash2, Search, + BarChart2, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -344,10 +345,10 @@ export function Sidebar() { )} - {/* ── Ricerca avanzata ── */} + {/* ── Ricerca avanzata + Dashboard ── */}
-
+
@@ -364,6 +365,22 @@ export function Sidebar() { {!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} +
diff --git a/frontend/src/pages/Reports/ReportsPage.tsx b/frontend/src/pages/Reports/ReportsPage.tsx new file mode 100644 index 0000000..f9c58db --- /dev/null +++ b/frontend/src/pages/Reports/ReportsPage.tsx @@ -0,0 +1,528 @@ +/** + * ReportsPage – Dashboard e Reportistica (Fase 7). + * + * Visualizza: + * - 6 KPI cards (ricevute oggi, inviate oggi, anomalie, tasso consegna, + * caselle in errore, messaggi non letti) + * - Selettore periodo (7 / 30 giorni) + * - Grafico a barre: PEC ricevute vs inviate per giorno + * - Grafico a torta: distribuzione stati messaggi outbound + * - Tabella: dettaglio per casella + * - Pulsanti export CSV e PDF + */ + +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + PieChart, + Pie, + Cell, + ResponsiveContainer, +} from 'recharts' +import { + Inbox, + Send, + AlertTriangle, + CheckCircle2, + ServerCrash, + MailOpen, + Download, + FileText, + RefreshCw, + BarChart2, +} from 'lucide-react' +import { format, parseISO } from 'date-fns' +import { it } from 'date-fns/locale' +import { reportsApi } from '@/api/reports.api' +import { cn } from '@/lib/utils' + +// ─── Costanti ───────────────────────────────────────────────────────────────── + +const PERIOD_OPTIONS = [ + { label: '7 giorni', value: 7 }, + { label: '30 giorni', value: 30 }, + { label: '90 giorni', value: 90 }, +] + +const STATE_COLORS: Record = { + delivered: '#22c55e', + accepted: '#3b82f6', + sent: '#8b5cf6', + anomaly: '#ef4444', + failed: '#dc2626', + queued: '#f59e0b', + draft: '#6b7280', + unknown: '#9ca3af', +} + +const STATE_LABELS: Record = { + delivered: 'Consegnata', + accepted: 'Accettata', + sent: 'Inviata', + anomaly: 'Anomalia', + failed: 'Fallita', + queued: 'In coda', + draft: 'Bozza', + unknown: 'Sconosciuto', +} + +const STATUS_BADGE: Record = { + active: 'bg-green-100 text-green-800', + paused: 'bg-yellow-100 text-yellow-800', + error: 'bg-red-100 text-red-800', + deleted: 'bg-gray-100 text-gray-600', +} + +// ─── Componente KPI card ────────────────────────────────────────────────────── + +interface KpiCardProps { + title: string + value: string | number + subtitle?: string + icon: React.ReactNode + color: string + alert?: boolean +} + +function KpiCard({ title, value, subtitle, icon, color, alert }: KpiCardProps) { + return ( +
+
+ {icon} +
+
+

{title}

+

+ {value} +

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ ) +} + +// ─── Tooltip grafico barre ──────────────────────────────────────────────────── + +function CustomBarTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null + return ( +
+

{label}

+ {payload.map((p: any) => ( +

+ {p.name}: {p.value} +

+ ))} +
+ ) +} + +// ─── Tooltip grafico torta ──────────────────────────────────────────────────── + +function CustomPieTooltip({ active, payload }: any) { + if (!active || !payload?.length) return null + const item = payload[0] + return ( +
+

+ {STATE_LABELS[item.name] ?? item.name} +

+

Totale: {item.value}

+
+ ) +} + +// ─── Pagina principale ──────────────────────────────────────────────────────── + +export function ReportsPage() { + const [days, setDays] = useState(7) + + const { data, isLoading, isError, refetch, isFetching } = useQuery({ + queryKey: ['reports-summary', days], + queryFn: () => reportsApi.getSummary(days), + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: false, + }) + + // ── Formatta le date per l'asse X del grafico ──────────────────────────── + const chartData = (data?.daily_stats ?? []).map((s) => ({ + giorno: format(parseISO(s.day), 'dd/MM', { locale: it }), + Ricevute: s.received, + Inviate: s.sent, + })) + + const pieData = (data?.outbound_states ?? []) + .filter((s) => s.count > 0) + .map((s) => ({ + name: s.state, + value: s.count, + fill: STATE_COLORS[s.state] ?? '#9ca3af', + })) + + const kpi = data?.kpi + const generatedAt = data?.generated_at + ? format(parseISO(data.generated_at), "dd/MM/yyyy HH:mm", { locale: it }) + : null + + // ── URL export con token auth (il browser apre con i cookie di sessione) ── + const handleExport = (format: 'csv' | 'pdf') => { + const url = format === 'csv' + ? reportsApi.exportCsv() + : reportsApi.exportPdf() + + // Apre il link in una nuova scheda; il token JWT viene inviato + // automaticamente grazie all'interceptor axios, ma per i download + // diretti usiamo fetch con il token dallo store. + const token = localStorage.getItem('access_token') + if (!token) { + window.open(url, '_blank') + return + } + fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + .then((res) => { + if (!res.ok) throw new Error('Errore download') + return res.blob() + }) + .then((blob) => { + const ext = format === 'csv' ? 'csv' : 'pdf' + const ts = new Date().toISOString().slice(0, 16).replace('T', '_').replace(':', '') + const filename = `pechub_report_${ts}.${ext}` + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = filename + a.click() + URL.revokeObjectURL(a.href) + }) + .catch(() => alert('Errore durante il download del report')) + } + + // ───────────────────────────────────────────────────────────────────────── + + return ( +
+ {/* ── Intestazione ── */} +
+
+ +
+

Dashboard

+ {generatedAt && ( +

Aggiornato il {generatedAt}

+ )} +
+
+ +
+ {/* Selettore periodo */} +
+ {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Aggiorna */} + + + {/* Export CSV */} + + + {/* Export PDF */} + +
+
+ + {/* ── Contenuto ── */} +
+ + {/* ── Loading / Errore ── */} + {isLoading && ( +
+ + Caricamento dati... +
+ )} + + {isError && ( +
+ Errore nel caricamento dei dati. Riprova. +
+ )} + + {data && ( + <> + {/* ── Riga KPI ── */} +
+ } + color="bg-blue-50" + /> + } + color="bg-indigo-50" + /> + } + color="bg-orange-50" + alert={kpi!.anomalie_attive > 0} + /> + } + color="bg-green-50" + /> + } + color="bg-red-50" + alert={kpi!.caselle_in_errore > 0} + /> + } + color="bg-purple-50" + /> +
+ + {/* ── Grafici ── */} +
+ + {/* Grafico a barre (2/3) */} +
+

+ Attivita PEC – ultimi {days} giorni +

+ {chartData.length === 0 ? ( +
+ Nessun dato nel periodo selezionato +
+ ) : ( + + + + + + } /> + + + + + + )} +
+ + {/* Grafico a torta (1/3) */} +
+

+ Stato messaggi outbound +

+ {pieData.length === 0 ? ( +
+ Nessun messaggio outbound +
+ ) : ( + <> + + + + {pieData.map((entry, index) => ( + + ))} + + } /> + + + {/* Legenda manuale */} +
+ {pieData.map((entry) => ( +
+
+ + + {STATE_LABELS[entry.name] ?? entry.name} + +
+ {entry.value} +
+ ))} +
+ + )} +
+
+ + {/* ── Tabella caselle ── */} + {data.mailbox_stats.length > 0 && ( +
+
+

+ Dettaglio per casella +

+
+
+ + + + + + + + + + + + + + {data.mailbox_stats.map((mb) => ( + + + + + + + + + + ))} + +
CasellaStatoRicevuteInviateAnomalieNon lettiUltima sync
+
+ {mb.display_name || mb.email_address} +
+ {mb.display_name && ( +
{mb.email_address}
+ )} +
+ + {mb.status} + + + {mb.received_total} + + {mb.sent_total} + + {mb.anomalie > 0 ? ( + {mb.anomalie} + ) : ( + 0 + )} + + {mb.non_letti > 0 ? ( + {mb.non_letti} + ) : ( + 0 + )} + + {mb.last_sync_at + ? format(parseISO(mb.last_sync_at), 'dd/MM HH:mm', { locale: it }) + : '—'} +
+
+
+ )} + + {/* Footer */} +

+ Totale messaggi nel sistema: {kpi!.totale_messaggi.toLocaleString('it-IT')} +

+ + )} +
+
+ ) +} diff --git a/frontend/src/pages/Settings/SettingsPage.tsx b/frontend/src/pages/Settings/SettingsPage.tsx index ce2e7ea..150972b 100644 --- a/frontend/src/pages/Settings/SettingsPage.tsx +++ b/frontend/src/pages/Settings/SettingsPage.tsx @@ -1,14 +1,15 @@ /** - * SettingsPage – impostazioni profilo utente e configurazione tenant (admin). + * SettingsPage - impostazioni profilo utente e configurazione tenant (admin). * * Sezioni: * - Informazioni profilo (nome visualizzato, email, ruolo) * - Modifica nome * - Cambio password - * - [Solo admin] Archiviazione Sostitutiva – toggle mock/produzione + credenziali conservatore + * - [Solo admin] Archiviazione Sostitutiva - toggle mock/produzione + credenziali conservatore + * - [Solo admin] Indicizzazione Full-Text - statistiche, reindex, monitoraggio job */ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Settings, User, @@ -23,33 +24,72 @@ import { EyeOff, FlaskConical, Zap, + Search, + RefreshCw, + XCircle, + Clock, + FileText, + BarChart3, + AlertCircle, + CheckCircle2, + Loader2, } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useAuthStore } from '@/store/auth.store' import { usersApi } from '@/api/users.api' -import { settingsApi, type TenantSettingsResponse, type ArchivalMode } from '@/api/settings.api' +import { + settingsApi, + type TenantSettingsResponse, + type ArchivalMode, + type IndexingStats, + type IndexingJobStatus, + type ReindexMode, +} from '@/api/settings.api' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' import { Card } from '@/components/ui/Card' import toast from 'react-hot-toast' -// ─── Etichetta ruolo ───────────────────────────────────────────────────────── +// ─── Utility ───────────────────────────────────────────────────────────────── function roleLabel(role: string): string { switch (role) { - case 'super_admin': - return 'Super Amministratore' - case 'admin': - return 'Amministratore' - case 'operator': - return 'Operatore' - default: - return role + case 'super_admin': return 'Super Amministratore' + case 'admin': return 'Amministratore' + case 'operator': return 'Operatore' + default: return role } } -// ─── Badge modalità archiviazione ──────────────────────────────────────────── +function formatElapsed(seconds: number | null): string { + if (seconds === null || seconds < 0) return '-' + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + return `${h}h ${m}m` +} + +function formatDatetime(iso: string | null): string { + if (!iso) return '-' + return new Date(iso).toLocaleString('it-IT', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }) +} + +function modeLabel(mode: ReindexMode | null): string { + if (!mode) return '-' + return mode === 'full' ? 'Totale' : 'Differenziale' +} + +function rescanModeLabel(mode: string | null): string { + if (!mode) return '-' + return mode === 'force' ? 'Forzata (tutti)' : 'Differenziale' +} + +// ─── Badge modalita' archiviazione ─────────────────────────────────────────── function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) { if (mode === 'production') { @@ -68,12 +108,856 @@ function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) { ) } -// ─── Pagina ────────────────────────────────────────────────────────────────── +// ─── Badge stato job ────────────────────────────────────────────────────────── + +function JobStatusBadge({ status, isStale }: { status: string; isStale: boolean }) { + if (status === 'running') { + return ( + + {isStale + ? + : + } + {isStale ? 'Bloccato?' : 'In esecuzione'} + + ) + } + if (status === 'completed') { + return ( + + + Completato + + ) + } + if (status === 'failed') { + return ( + + + Errore + + ) + } + if (status === 'cancelled') { + return ( + + + Annullato + + ) + } + // idle + return ( + + + Inattivo + + ) +} + +// ─── Barra di avanzamento ──────────────────────────────────────────────────── + +function ProgressBar({ + value, + max = 100, + color = 'blue', + animated = false, +}: { + value: number + max?: number + color?: 'blue' | 'green' | 'amber' | 'red' | 'gray' + animated?: boolean +}) { + const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0 + const colorClass = { + blue: 'bg-blue-500', + green: 'bg-green-500', + amber: 'bg-amber-500', + red: 'bg-red-500', + gray: 'bg-gray-400', + }[color] + + return ( +
+
+
+ ) +} + +// ─── Sezione Indicizzazione ─────────────────────────────────────────────────── + +interface IndexingSectionProps { + isSuperAdmin: boolean +} + +function IndexingSection({ isSuperAdmin }: IndexingSectionProps) { + const [expanded, setExpanded] = useState(false) + const [stats, setStats] = useState(null) + const [jobStatus, setJobStatus] = useState(null) + const [rescanStatus, setRescanStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(false) + const [rescanActionLoading, setRescanActionLoading] = useState(false) + const [showCancelConfirm, setShowCancelConfirm] = useState(false) + const [showFullReindexConfirm, setShowFullReindexConfirm] = useState(false) + const [showRescanCancelConfirm, setShowRescanCancelConfirm] = useState(false) + const [showForceRescanConfirm, setShowForceRescanConfirm] = useState(false) + + // Polling reindex e rescan separati + const pollingRef = useRef | null>(null) + const rescanPollingRef = useRef | null>(null) + + const isRunning = jobStatus?.status === 'running' + const isRescanRunning = rescanStatus?.status === 'running' + const anyJobRunning = isRunning || isRescanRunning + + // Carica dati iniziali quando si espande la sezione + const loadData = async () => { + setLoading(true) + try { + const [s, j, r] = await Promise.all([ + settingsApi.getIndexingStats(), + settingsApi.getIndexingStatus(), + settingsApi.getRescanStatus(), + ]) + setStats(s) + setJobStatus(j) + setRescanStatus(r) + } catch { + toast.error('Errore durante il caricamento delle statistiche di indicizzazione') + } finally { + setLoading(false) + } + } + + // Polling reindex + const refreshStatus = async () => { + try { + const j = await settingsApi.getIndexingStatus() + setJobStatus(j) + if (j.status !== 'running' && isRunning) { + const s = await settingsApi.getIndexingStats() + setStats(s) + } + } catch { + // Silenzioso + } + } + + // Polling rescan + const refreshRescanStatus = async () => { + try { + const r = await settingsApi.getRescanStatus() + setRescanStatus(r) + if (r.status !== 'running' && isRescanRunning) { + const s = await settingsApi.getIndexingStats() + setStats(s) + } + } catch { + // Silenzioso + } + } + + useEffect(() => { + if (expanded) { + loadData() + } + }, [expanded]) + + // Polling reindex + useEffect(() => { + if (isRunning) { + pollingRef.current = setInterval(refreshStatus, 3000) + } else { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + } + return () => { + if (pollingRef.current) clearInterval(pollingRef.current) + } + }, [isRunning]) + + // Polling rescan + useEffect(() => { + if (isRescanRunning) { + rescanPollingRef.current = setInterval(refreshRescanStatus, 3000) + } else { + if (rescanPollingRef.current) { + clearInterval(rescanPollingRef.current) + rescanPollingRef.current = null + } + } + return () => { + if (rescanPollingRef.current) clearInterval(rescanPollingRef.current) + } + }, [isRescanRunning]) + + const handleStartReindex = async (mode: ReindexMode) => { + setActionLoading(true) + setShowFullReindexConfirm(false) + try { + const j = await settingsApi.startReindex(mode) + setJobStatus(j) + toast.success( + mode === 'full' + ? 'Reindex totale avviato in background' + : 'Reindex differenziale avviato in background' + ) + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } }) + ?.response?.data?.detail + toast.error(msg ?? 'Errore durante l\'avvio del reindex') + } finally { + setActionLoading(false) + } + } + + const handleCancel = async () => { + setActionLoading(true) + setShowCancelConfirm(false) + try { + const j = await settingsApi.cancelReindex() + setJobStatus(j) + toast.success('Segnale di annullamento inviato. Il job si fermera\' al prossimo batch.') + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } }) + ?.response?.data?.detail + toast.error(msg ?? 'Errore durante l\'annullamento') + } finally { + setActionLoading(false) + } + } + + const handleStartRescan = async (force: boolean) => { + setRescanActionLoading(true) + setShowForceRescanConfirm(false) + try { + const r = await settingsApi.startRescan(force) + setRescanStatus(r) + toast.success( + force + ? 'Riscansione forzata avviata in background' + : 'Riscansione allegati avviata in background' + ) + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } }) + ?.response?.data?.detail + toast.error(msg ?? 'Errore durante l\'avvio della scansione allegati') + } finally { + setRescanActionLoading(false) + } + } + + const handleCancelRescan = async () => { + setRescanActionLoading(true) + setShowRescanCancelConfirm(false) + try { + const r = await settingsApi.cancelRescan() + setRescanStatus(r) + toast.success('Segnale di annullamento inviato. La scansione si fermera\' al prossimo batch.') + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } }) + ?.response?.data?.detail + toast.error(msg ?? 'Errore durante l\'annullamento della scansione') + } finally { + setRescanActionLoading(false) + } + } + + // Colore della barra di copertura + const coverageColor = (pct: number) => { + if (pct >= 90) return 'green' as const + if (pct >= 60) return 'amber' as const + return 'red' as const + } + + return ( + + {/* Header collassabile */} + + + {expanded && ( +
+ {loading ? ( +
+ + Caricamento statistiche... +
+ ) : ( + <> + {/* ── Spiegazione ── */} +
+

Come funziona l'indicizzazione

+

+ Ogni messaggio PEC contiene un vettore di ricerca full-text (search_vector) + generato automaticamente da oggetto, mittente, destinatari e corpo. + Il worker indicizza anche il testo estratto dagli allegati PDF e DOCX. + Il reindex rigenera questi vettori manualmente, utile dopo migrazioni o + in caso di messaggi non indicizzati. +

+
+ + {/* ── Statistiche messaggi ── */} + {stats && ( +
+
+ +

+ Copertura indicizzazione +

+ +
+ + {/* Card statistiche messaggi */} +
+
+

+ {stats.total_messages.toLocaleString('it-IT')} +

+

Messaggi totali

+
+
+

+ {stats.indexed_messages.toLocaleString('it-IT')} +

+

Indicizzati

+
+
0 + ? 'bg-amber-50 border-amber-200' + : 'bg-gray-50 border-gray-200' + }`}> +

0 ? 'text-amber-700' : 'text-gray-500' + }`}> + {stats.unindexed_messages.toLocaleString('it-IT')} +

+

0 ? 'text-amber-600' : 'text-gray-500' + }`}> + Non indicizzati +

+
+
+ + {/* Barra copertura messaggi */} +
+
+ Copertura messaggi + = 90 ? 'text-green-700' : + stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700' + }`}> + {stats.coverage_pct}% + +
+ + {stats.unindexed_messages > 0 && ( +

+ {stats.unindexed_messages.toLocaleString('it-IT')} messaggi non hanno ancora + il vettore di ricerca. Usa il reindex differenziale per indicizzarli. +

+ )} +
+ + {/* Sezione allegati */} + {stats.attachments_total > 0 && ( +
+
+
+ + + Allegati PDF/DOCX con testo estratto + +
+ {rescanStatus && ( + + )} +
+
+ + {stats.attachments_extracted.toLocaleString('it-IT')} / {stats.attachments_total.toLocaleString('it-IT')} allegati + + {stats.attachments_pct}% +
+ + {stats.attachments_pct < 100 && ( +

+ {(stats.attachments_total - stats.attachments_extracted).toLocaleString('it-IT')} allegati non hanno ancora il testo estratto. + Usa Riscansiona allegati per elaborarli. +

+ )} +

+ Il testo degli allegati viene estratto automaticamente dal worker + durante la sincronizzazione IMAP. Il reindex include il testo + gia' estratto nei vettori di ricerca. +

+
+ )} +
+ )} + +
+ + {/* ── Stato job in corso ── */} + {jobStatus && ( +
+
+ +

+ Stato indicizzazione +

+ {isRunning && ( + + aggiornamento automatico ogni 3s + + )} +
+ +
+ {/* Riga stato */} +
+
+ + {jobStatus.mode && ( + + Modalita': {modeLabel(jobStatus.mode)} + + )} +
+ {isRunning && ( + + {jobStatus.processed.toLocaleString('it-IT')} / {jobStatus.total.toLocaleString('it-IT')} + + )} +
+ + {/* Barra progresso (quando running o completed) */} + {(jobStatus.status === 'running' || jobStatus.status === 'completed') && jobStatus.total > 0 && ( +
+ +
+ {jobStatus.progress_pct}% completato + {jobStatus.elapsed_seconds !== null && ( + Durata: {formatElapsed(jobStatus.elapsed_seconds)} + )} +
+
+ )} + + {/* Metadati job */} + {jobStatus.status !== 'idle' && ( +
+ {jobStatus.started_at && ( +
+ Avviato: + {formatDatetime(jobStatus.started_at)} +
+ )} + {jobStatus.finished_at && ( +
+ Terminato: + {formatDatetime(jobStatus.finished_at)} +
+ )} + {jobStatus.started_by && ( +
+ Avviato da: + {jobStatus.started_by} +
+ )} +
+ )} + + {/* Alert stale */} + {jobStatus.is_stale && ( +
+ +
+

Job potenzialmente bloccato

+

+ Il job e' in esecuzione da {formatElapsed(jobStatus.elapsed_seconds)} e + potrebbe essere bloccato. Usa il pulsante "Termina indicizzazione" per + cancellarlo e riavviarlo. +

+
+
+ )} + + {/* Errore */} + {jobStatus.status === 'failed' && jobStatus.error && ( +
+ {jobStatus.error} +
+ )} + + {/* Info idle */} + {jobStatus.status === 'idle' && ( +

+ Nessun job di reindex in corso. Usa i pulsanti in basso per avviarne uno. +

+ )} +
+
+ )} + + {/* ── Stato scansione allegati ── */} + {rescanStatus && ( +
+
+ +

+ Stato scansione allegati +

+ {isRescanRunning && ( + + aggiornamento automatico ogni 3s + + )} +
+ +
+
+
+ + {rescanStatus.mode && ( + + Modalita': {rescanModeLabel(rescanStatus.mode)} + + )} +
+ {isRescanRunning && ( + + {rescanStatus.processed.toLocaleString('it-IT')} / {rescanStatus.total.toLocaleString('it-IT')} allegati + + )} +
+ + {(rescanStatus.status === 'running' || rescanStatus.status === 'completed') && rescanStatus.total > 0 && ( +
+ +
+ {rescanStatus.progress_pct}% completato + {rescanStatus.elapsed_seconds !== null && ( + Durata: {formatElapsed(rescanStatus.elapsed_seconds)} + )} +
+
+ )} + + {rescanStatus.status !== 'idle' && ( +
+ {rescanStatus.started_at && ( +
+ Avviato: + {formatDatetime(rescanStatus.started_at)} +
+ )} + {rescanStatus.finished_at && ( +
+ Terminato: + {formatDatetime(rescanStatus.finished_at)} +
+ )} + {rescanStatus.started_by && ( +
+ Avviato da: + {rescanStatus.started_by} +
+ )} +
+ )} + + {rescanStatus.status === 'failed' && rescanStatus.error && ( +
+ {rescanStatus.error} +
+ )} + + {rescanStatus.status === 'idle' && ( +

+ Nessuna scansione allegati in corso. Usa il pulsante "Riscansiona allegati" per avviarne una. +

+ )} +
+
+ )} + + {/* ── Dialogs conferma ── */} + {showCancelConfirm && ( +
+ +
+

Conferma annullamento reindex

+

+ Il job si fermera' alla fine del batch corrente. + I messaggi gia' indicizzati rimarranno indicizzati. +

+
+ + · + +
+
+
+ )} + + {showFullReindexConfirm && ( +
+ +
+

Reindex totale

+

+ Verra' riscritto il vettore di ricerca per tutti i{' '} + {stats?.total_messages.toLocaleString('it-IT')} messaggi. + La ricerca rimane disponibile durante il processo. +

+
+ + · + +
+
+
+ )} + + {showForceRescanConfirm && ( +
+ +
+

Riscansione forzata allegati

+

+ Il testo verra' ri-estratto da tutti gli{' '} + {stats?.attachments_total.toLocaleString('it-IT')} allegati del tenant, + sovrascrivendo quelli gia' presenti. Operazione piu' lunga del differenziale. +

+
+ + · + +
+
+
+ )} + + {showRescanCancelConfirm && ( +
+ +
+

Conferma annullamento scansione

+

+ La scansione si fermera' alla fine del batch corrente. +

+
+ + · + +
+
+
+ )} + + {/* ── Pulsanti reindex ── */} +
+

Reindex messaggi

+
+ + + {isRunning && ( + + )} +
+
+ + {/* ── Pulsanti scansione allegati ── */} +
+

Scansione allegati

+
+ + + {isRescanRunning && ( + + )} +
+
+ + {/* Legenda pulsanti */} +
+

+ Reindex differenziale – Indicizza solo i + messaggi con search_vector NULL. Rapido, ideale per uso routinario. +

+

+ Reindex totale – Riscrive il vettore di + ricerca per tutti i messaggi, includendo il testo degli allegati gia' estratti. +

+

+ Riscansiona allegati – Scarica da MinIO + gli allegati senza testo estratto (PDF, DOCX, ecc.), ne estrae il testo e + aggiorna il vettore di ricerca. Differenziale: solo allegati non ancora elaborati. +

+

+ Riscansione forzata – Come + riscansiona, ma ri-estrae il testo da tutti gli allegati (sovrascrive). + Utile dopo migrazioni o per correggere estrazioni errate. +

+
+ + )} +
+ )} +
+ ) +} + +// ─── Pagina principale ──────────────────────────────────────────────────────── export function SettingsPage() { const { user } = useAuth() const loadUser = useAuthStore((s) => s.loadUser) const isAdmin = user?.role === 'admin' || user?.role === 'super_admin' + const isSuperAdmin = user?.role === 'super_admin' /* ── Stato modifica nome ── */ const [fullName, setFullName] = useState(user?.full_name ?? '') @@ -98,8 +982,6 @@ export function SettingsPage() { const [archivalNotes, setArchivalNotes] = useState('') const [showPassword, setShowPassword] = useState(false) const [savingArchival, setSavingArchival] = useState(false) - - // Conferma passaggio a produzione const [showProductionConfirm, setShowProductionConfirm] = useState(false) /* ── Carica impostazioni archiviazione ── */ @@ -114,12 +996,10 @@ export function SettingsPage() { try { const data = await settingsApi.get() setArchivalSettings(data) - // Popola form setArchivalMode(data.archival_mode) setConservatoreId(data.conservatore_id) setConservatoreEndpoint(data.conservatore_endpoint ?? '') setArchivalNotes(data.archival_notes ?? '') - // Username/password non vengono mai restituiti in chiaro } catch { toast.error('Errore durante il caricamento delle impostazioni di archiviazione') } finally { @@ -131,7 +1011,7 @@ export function SettingsPage() { const handleSaveName = async () => { if (!user) return if (!fullName.trim()) { - toast.error('Il nome non può essere vuoto') + toast.error('Il nome non puo\' essere vuoto') return } setSavingName(true) @@ -170,10 +1050,9 @@ export function SettingsPage() { } } - /* ── Cambio modalità archiviazione ── */ + /* ── Cambio modalita' archiviazione ── */ const handleModeToggle = (newMode: ArchivalMode) => { if (newMode === 'production' && archivalMode === 'mock') { - // Chiedi conferma prima di passare a produzione setShowProductionConfirm(true) } else { setArchivalMode(newMode) @@ -183,9 +1062,8 @@ export function SettingsPage() { /* ── Salva impostazioni archiviazione ── */ const handleSaveArchival = async () => { - // Validazione client-side per modalità produzione if (archivalMode === 'production' && !conservatoreEndpoint.trim()) { - toast.error('La modalità produzione richiede un URL endpoint del conservatore') + toast.error('La modalita\' produzione richiede un URL endpoint del conservatore') return } @@ -198,13 +1076,8 @@ export function SettingsPage() { archival_notes: archivalNotes || undefined, } - // Includi credenziali solo se l'utente ha inserito qualcosa - if (conservatoreUsername) { - payload.conservatore_username = conservatoreUsername - } - if (conservatorePassword) { - payload.conservatore_password = conservatorePassword - } + if (conservatoreUsername) payload.conservatore_username = conservatoreUsername + if (conservatorePassword) payload.conservatore_password = conservatorePassword const updated = await settingsApi.update(payload) setArchivalSettings(updated) @@ -212,15 +1085,14 @@ export function SettingsPage() { setConservatoreId(updated.conservatore_id) setConservatoreEndpoint(updated.conservatore_endpoint ?? '') setArchivalNotes(updated.archival_notes ?? '') - // Reset credenziali (non rimostrare in chiaro) setConservatoreUsername('') setConservatorePassword('') setShowProductionConfirm(false) toast.success( updated.archival_mode === 'production' - ? ' Archiviazione attivata in modalità PRODUZIONE' - : ' Archiviazione impostata in modalità mock' + ? 'Archiviazione attivata in modalita\' PRODUZIONE' + : 'Archiviazione impostata in modalita\' mock' ) } catch (err: unknown) { const msg = (err as { response?: { data?: { detail?: string } } }) @@ -286,7 +1158,7 @@ export function SettingsPage() { size="sm" > - {savingName ? 'Salvataggio…' : 'Salva'} + {savingName ? 'Salvataggio...' : 'Salva'}
@@ -329,7 +1201,7 @@ export function SettingsPage() { disabled={savingPwd || !newPassword || !confirmPassword} > - {savingPwd ? 'Aggiornamento…' : 'Aggiorna password'} + {savingPwd ? 'Aggiornamento...' : 'Aggiorna password'}
@@ -359,25 +1231,23 @@ export function SettingsPage() { {archivalExpanded && (
- {loadingArchival ? (

- Caricamento impostazioni… + Caricamento impostazioni...

) : ( <> - {/* ── Toggle modalità ── */} + {/* Toggle modalita' */}
- +

- In modalità mock le operazioni di versamento vengono + In modalita' mock le operazioni di versamento vengono simulate localmente senza inviare dati a sistemi esterni. Attiva la - modalità produzione solo dopo aver configurato l'endpoint + modalita' produzione solo dopo aver configurato l'endpoint e le credenziali del conservatore AgID.

- {/* Bottone Mock */}
- {archivalMode === 'mock' && ( - - )} + {archivalMode === 'mock' && } - {/* Bottone Produzione */}
- {archivalMode === 'production' && ( - - )} + {archivalMode === 'production' && }
- {/* Banner conferma passaggio a produzione */} {showProductionConfirm && archivalMode === 'mock' && (

- Stai per attivare la modalità produzione + Stai per attivare la modalita' produzione

I versamenti verranno inviati al conservatore AgID reale. - Assicurati che l'endpoint e le credenziali siano corretti.

·
- {/* ── Note operative ── */}