From 95f60fa3b7a33494ccc63b6412187d797f2a05ac Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Tue, 24 Mar 2026 10:30:19 +0100 Subject: [PATCH 01/11] fix notifiche --- backend/app/api/v1/messages.py | 8 +------- backend/app/notifications/__init__.py | 2 +- backend/app/services/notification_service.py | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 1661341..f2a012f 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -422,13 +422,7 @@ async def update_message( .options(selectinload(Message.labels)) ) message = refreshed.scalar_one() - return MessageResponse.model_validate(message) - - -@router.get("/{message_id}/attachments", response_model=list[AttachmentResponse]) -async def list_attachments( - message_id: uuid.UUID, - current_user: CurrentUser, + return MessageResponse.model_validate(messa db: DB, ) -> list[AttachmentResponse]: """Elenca gli allegati di un messaggio.""" diff --git a/backend/app/notifications/__init__.py b/backend/app/notifications/__init__.py index 089f831..2a45687 100644 --- a/backend/app/notifications/__init__.py +++ b/backend/app/notifications/__init__.py @@ -1 +1 @@ -# Modulo notifiche – mittenti multi-canale +-# Modulo notifiche – mittenti multi-canale diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 699f397..cdee678 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,4 +1,4 @@ -""" +is""" Servizio Notifiche Multi-canale – CRUD canali, regole, log. Nota: la cifratura AES-256-GCM di config_enc avviene qui usando From 03be5d0e3213edede322445b6c9c2522aaa07c3a Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Wed, 25 Mar 2026 17:49:13 +0100 Subject: [PATCH 02/11] Trash! --- .gitignore | 1 + KnowledgeBaseCline.md | 2 +- backend/alembic/versions/0007_add_trash.py | 31 ++ backend/app/api/v1/messages.py | 46 ++- backend/app/models/message.py | 2 + backend/app/schemas/message.py | 5 + backend/app/services/notification_service.py | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/messages.api.ts | 15 +- frontend/src/components/Layout/Sidebar.tsx | 35 +++ frontend/src/pages/Inbox/InboxPage.tsx | 277 ++++++++++++++---- .../pages/MessageDetail/MessageDetailPage.tsx | 136 +++++++-- frontend/src/types/api.types.ts | 2 + 13 files changed, 458 insertions(+), 98 deletions(-) create mode 100644 backend/alembic/versions/0007_add_trash.py diff --git a/.gitignore b/.gitignore index 6c4d8e0..4d26537 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +KnowledgeBaseCline.md diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index 27e3624..d2662f1 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -30,7 +30,7 @@ Porta: 465 SSL: Sì -Se devi, effettua i test di invio solo al destinatario matteo1801@spidmail.it +Quando necessario, effettua i test di invio solo al destinatario matteo1801@spidmail.it Tutto il frontend deve essere in italiano diff --git a/backend/alembic/versions/0007_add_trash.py b/backend/alembic/versions/0007_add_trash.py new file mode 100644 index 0000000..d784a44 --- /dev/null +++ b/backend/alembic/versions/0007_add_trash.py @@ -0,0 +1,31 @@ +"""add is_trashed and trashed_at to messages + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-03-25 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '0007' +down_revision = '0006' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + 'messages', + sa.Column('is_trashed', sa.Boolean(), nullable=False, server_default=sa.text('false')), + ) + op.add_column( + 'messages', + sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('messages', 'trashed_at') + op.drop_column('messages', 'is_trashed') diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index f2a012f..289a8e0 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -4,7 +4,8 @@ Router messaggi PEC. Fornisce: - GET /messages – lista messaggi con filtri (inbox/sent/search/...) - GET /messages/{id} – singolo messaggio - - PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived) + - PATCH /messages/{id} – aggiorna flags (is_read, is_starred, is_archived, is_trashed) + - PATCH /messages/bulk – aggiorna in blocco piu messaggi - GET /messages/{id}/attachments – lista allegati - GET /messages/{id}/attachments/{att_id}/download – scarica allegato da MinIO - GET /messages/{id}/receipts – ricevute (messaggi figlio) @@ -58,7 +59,7 @@ def _apply_vbox_rule(q, field: str, operator: str, value: str): elif field == "from_address": col = Message.from_address elif field == "to_address": - # to_addresses è ARRAY(Text) – converte in stringa per il confronto + # to_addresses e ARRAY(Text) – converte in stringa per il confronto arr_text = func.array_to_string(Message.to_addresses, ",") if operator == "contains": return q.where(arr_text.ilike(f"%{value}%")) @@ -94,7 +95,7 @@ async def _get_visible_mailbox_ids( ) -> Optional[list[uuid.UUID]]: """ Per utenti non-admin restituisce la lista di mailbox_id accessibili. - Restituisce None se l'utente è admin (accesso illimitato al tenant). + Restituisce None se l'utente e admin (accesso illimitato al tenant). """ if user.is_admin: return None # nessun filtro per admin @@ -111,10 +112,10 @@ async def _resolve_message( ) -> Message: """Carica il messaggio e verifica i permessi di accesso. - L'accesso è consentito se: - 1. L'utente è admin del tenant, oppure + L'accesso e consentito se: + 1. L'utente e admin del tenant, oppure 2. L'utente ha un permesso diretto can_read sulla casella, oppure - 3. L'utente è assegnato a una Virtual Box attiva che include la casella. + 3. L'utente e assegnato a una Virtual Box attiva che include la casella. """ result = await db.execute( select(Message) @@ -182,6 +183,7 @@ async def list_messages( is_read: Optional[bool] = Query(None), is_starred: Optional[bool] = Query(None), is_archived: Optional[bool] = Query(False), + is_trashed: Optional[bool] = Query(False), search: Optional[str] = Query(None, max_length=200), pec_type: Optional[str] = Query(None), # Paginazione @@ -192,6 +194,7 @@ async def list_messages( Elenca i messaggi PEC con filtri opzionali. - `is_archived=False` (default) esclude i messaggi archiviati. + - `is_trashed=False` (default) esclude i messaggi nel cestino. - `search` cerca su subject, from_address, to_addresses. - `vbox_id` filtra per Virtual Box assegnata all'utente corrente. """ @@ -278,6 +281,9 @@ async def list_messages( if is_archived is not None: q = q.where(Message.is_archived == is_archived) + if is_trashed is not None: + q = q.where(Message.is_trashed == is_trashed) + if search: term = f"%{search}%" q = q.where( @@ -325,7 +331,7 @@ async def bulk_update_messages( db: DB, ) -> MessageBulkUpdateResponse: """ - Aggiorna in blocco i flag operativi (is_starred, is_archived) di più messaggi. + Aggiorna in blocco i flag operativi (is_starred, is_archived, is_trashed) di piu messaggi. Restituisce il numero di messaggi aggiornati e la lista aggiornata. I messaggi non trovati o non accessibili vengono silenziosamente ignorati. @@ -352,6 +358,8 @@ async def bulk_update_messages( now = datetime.now(timezone.utc) for message in messages: + if data.is_read is not None: + message.is_read = data.is_read if data.is_starred is not None: message.is_starred = data.is_starred if data.is_archived is not None: @@ -360,6 +368,12 @@ async def bulk_update_messages( message.archived_at = now elif not data.is_archived: message.archived_at = None + if data.is_trashed is not None: + message.is_trashed = data.is_trashed + if data.is_trashed and not message.trashed_at: + message.trashed_at = now + elif not data.is_trashed: + message.trashed_at = None await db.commit() @@ -399,7 +413,7 @@ async def update_message( ) -> MessageResponse: """ Aggiorna i flag operativi di un messaggio: - is_read, is_starred, is_archived. + is_read, is_starred, is_archived, is_trashed. """ message = await _resolve_message(message_id, current_user, db) @@ -413,6 +427,12 @@ async def update_message( message.archived_at = datetime.now(timezone.utc) elif not data.is_archived: message.archived_at = None + if data.is_trashed is not None: + message.is_trashed = data.is_trashed + if data.is_trashed and not message.trashed_at: + message.trashed_at = datetime.now(timezone.utc) + elif not data.is_trashed: + message.trashed_at = None await db.commit() # Re-query con selectinload per evitare MissingGreenlet sui labels @@ -422,7 +442,13 @@ async def update_message( .options(selectinload(Message.labels)) ) message = refreshed.scalar_one() - return MessageResponse.model_validate(messa + return MessageResponse.model_validate(message) + + +@router.get("/{message_id}/attachments", response_model=list[AttachmentResponse]) +async def list_attachments( + message_id: uuid.UUID, + current_user: CurrentUser, db: DB, ) -> list[AttachmentResponse]: """Elenca gli allegati di un messaggio.""" @@ -473,7 +499,7 @@ async def download_attachment( secure=settings.minio_use_ssl, ) - # storage_path è del tipo "tenant_id/attachments/filename" + # storage_path e del tipo "tenant_id/attachments/filename" storage_path = attachment.storage_path response = await client.get_object(settings.minio_bucket, storage_path) diff --git a/backend/app/models/message.py b/backend/app/models/message.py index 3af1989..b037b01 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -91,6 +91,8 @@ class Message(Base): is_starred: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_trashed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + trashed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py index a3a37bc..5c4c799 100644 --- a/backend/app/schemas/message.py +++ b/backend/app/schemas/message.py @@ -49,6 +49,8 @@ class MessageResponse(BaseModel): is_starred: bool = False is_archived: bool = False archived_at: Optional[datetime] = None + is_trashed: bool = False + trashed_at: Optional[datetime] = None raw_eml_path: Optional[str] = None created_at: datetime updated_at: datetime @@ -84,12 +86,15 @@ class MessageUpdateRequest(BaseModel): is_read: Optional[bool] = None is_starred: Optional[bool] = None is_archived: Optional[bool] = None + is_trashed: Optional[bool] = None class MessageBulkUpdateRequest(BaseModel): ids: list[uuid.UUID] + is_read: Optional[bool] = None is_starred: Optional[bool] = None is_archived: Optional[bool] = None + is_trashed: Optional[bool] = None class MessageBulkUpdateResponse(BaseModel): diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index cdee678..699f397 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,4 +1,4 @@ -is""" +""" Servizio Notifiche Multi-canale – CRUD canali, regole, log. Nota: la cifratura AES-256-GCM di config_enc avviene qui usando diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d79137..eda1ddb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,12 +48,14 @@ export default function App() { } /> } /> } /> + } /> {/* Vista per singola casella PEC */} } /> } /> } /> } /> + } /> {/* Vista per Virtual Box assegnata */} } /> diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index 0750c8f..e30c106 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -16,13 +16,16 @@ export interface MessageFilters { is_read?: boolean is_starred?: boolean is_archived?: boolean + is_trashed?: boolean search?: string } export interface MessageBulkUpdatePayload { ids: string[] + is_read?: boolean is_starred?: boolean is_archived?: boolean + is_trashed?: boolean } export interface MessageBulkUpdateResponse { @@ -60,7 +63,17 @@ export const messagesApi = { .patch(`/messages/${id}`, { is_archived: false }) .then((r) => r.data), - /** Aggiorna in blocco is_starred e/o is_archived su più messaggi */ + trash: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_trashed: true }) + .then((r) => r.data), + + untrash: (id: string) => + apiClient + .patch(`/messages/${id}`, { is_trashed: false }) + .then((r) => r.data), + + /** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */ bulkUpdate: (payload: MessageBulkUpdatePayload) => apiClient .patch('/messages/bulk', payload) diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 7c11e62..827f72f 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -49,6 +49,7 @@ import { Star, Archive, Building2, + Trash2, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -266,6 +267,25 @@ export function Sidebar() { {!collapsed && Archiviati} + + {/* Cestino globale */} + + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-300 hover:bg-gray-700 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Cestino (tutte le caselle)' : undefined} + > + + {!collapsed && Cestino} + @@ -625,6 +645,21 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNav Archiviati + + + cn( + 'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors', + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-400 hover:bg-gray-700 hover:text-white', + ) + } + > + + Cestino + )} diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index 4a99c74..e28bc30 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -1,17 +1,18 @@ /** - * InboxPage – visualizza la posta in arrivo, inviata, preferiti o archiviata. + * InboxPage – visualizza la posta in arrivo, inviata, preferiti, archiviata o cestino. * - * Può operare in quattro modalità (viewMode): - * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false) - * - 'sent' → Posta Inviata (solo outbound, is_archived=false) - * - 'starred' → Preferiti (tutte le direzioni, is_starred=true) - * - 'archived' → Archiviati (tutte le direzioni, is_archived=true) + * Modalita' (viewMode): + * - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false, is_trashed=false) + * - 'sent' → Posta Inviata (solo outbound, is_archived=false, is_trashed=false) + * - 'starred' → Preferiti (is_starred=true, is_trashed=false) + * - 'archived' → Archiviati (is_archived=true, is_trashed=false) + * - 'trash' → Cestino (is_trashed=true) * - * Funzionalità: + * Funzionalita': * - Selezione singola e multipla tramite checkbox - * - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio, assegna tag) - * - Pulsanti azione rapida su hover di ogni riga (stella, archivia) - * - Badge tag colorati per ogni messaggio + * - Barra azioni bulk (stella, archivia, cestino, segna da leggere, tag) + * - Pulsanti azione rapida su hover di ogni riga + * - "Segna come da leggere" e "Sposta nel cestino" solo fuori dalle Virtual Box */ import { useEffect, useState, useCallback } from 'react' import { useNavigate, useParams } from 'react-router-dom' @@ -32,6 +33,9 @@ import { Square, X, Tag, + Trash2, + RotateCcw, + MailX, } from 'lucide-react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' @@ -51,10 +55,9 @@ import { getErrorMessage } from '@/api/client' // ─── Props ──────────────────────────────────────────────────────────────────── -export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' +export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' interface InboxPageProps { - /** Modalità vista */ viewMode: InboxViewMode } @@ -64,10 +67,11 @@ export function InboxPage({ viewMode }: InboxPageProps) { const navigate = useNavigate() const queryClient = useQueryClient() - // mailboxId è presente solo nei percorsi /mailbox/:mailboxId/... - // vboxId è presente solo nei percorsi /virtual-box/:vboxId/... const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>() + // true = stiamo navigando in una casella reale (non virtual box) + const isMailboxMode = !vboxId + // ── Stato filtri locale ────────────────────────────────────────────────────── const [searchInput, setSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') @@ -82,7 +86,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { // ── Stato dialog tag bulk ──────────────────────────────────────────────────── const [showTagSelector, setShowTagSelector] = useState(false) - // Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella useEffect(() => { setSearchInput('') setDebouncedSearch('') @@ -92,7 +95,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { setSelectedIds(new Set()) }, [mailboxId, vboxId, viewMode]) - // Debounce della ricerca useEffect(() => { const timer = setTimeout(() => { setDebouncedSearch(searchInput) @@ -112,7 +114,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? mailboxesData?.items.find((m) => m.id === mailboxId) : undefined - // ── Virtual Box corrente (per breadcrumb) ──────────────────────────────────── const { data: myVboxes = [] } = useQuery({ queryKey: ['virtual-boxes', 'my'], queryFn: () => virtualBoxesApi.myVirtualBoxes(), @@ -141,6 +142,7 @@ export function InboxPage({ viewMode }: InboxPageProps) { is_read: isReadFilter, is_starred: isStarredFilter, is_archived: false, + is_trashed: false, } case 'sent': return { @@ -148,17 +150,25 @@ export function InboxPage({ viewMode }: InboxPageProps) { direction: 'outbound' as const, is_starred: isStarredFilter, is_archived: false, + is_trashed: false, } case 'starred': return { ...base, is_starred: true, is_archived: false, + is_trashed: false, } case 'archived': return { ...base, is_archived: true, + is_trashed: false, + } + case 'trash': + return { + ...base, + is_trashed: true, } } })() @@ -178,7 +188,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { const total = messagesData?.total || 0 const totalPages = Math.ceil(total / PAGE_SIZE) - // ── Invalida query messaggi dopo operazioni ────────────────────────────────── const invalidateMessages = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['messages'] }) }, [queryClient]) @@ -200,6 +209,25 @@ export function InboxPage({ viewMode }: InboxPageProps) { }, }) + // ── Segna come DA leggere (unread) ─────────────────────────────────────────── + const markUnreadMutation = useMutation({ + mutationFn: (id: string) => messagesApi.markUnread(id), + onSuccess: (updatedMsg) => { + queryClient.setQueryData( + ['messages', queryFilters], + (old: { items: MessageResponse[]; total: number } | undefined) => { + if (!old) return old + return { + ...old, + items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)), + } + }, + ) + toast.success('Messaggio segnato come da leggere') + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + // ── Toggle stella singolo ──────────────────────────────────────────────────── const toggleStarMutation = useMutation({ mutationFn: ({ id, starred }: { id: string; starred: boolean }) => @@ -209,7 +237,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { ['messages', queryFilters], (old: { items: MessageResponse[]; total: number } | undefined) => { if (!old) return old - // In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti if (viewMode === 'starred' && !updatedMsg.is_starred) { return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 } } @@ -226,7 +253,6 @@ export function InboxPage({ viewMode }: InboxPageProps) { mutationFn: ({ id, archived }: { id: string; archived: boolean }) => archived ? messagesApi.archive(id) : messagesApi.unarchive(id), onSuccess: (updatedMsg, { archived }) => { - // Rimuove il messaggio dalla lista corrente (ha cambiato "stanza") queryClient.setQueryData( ['messages', queryFilters], (old: { items: MessageResponse[]; total: number } | undefined) => { @@ -240,6 +266,24 @@ export function InboxPage({ viewMode }: InboxPageProps) { onError: (error) => toast.error(getErrorMessage(error)), }) + // ── Cestino/Ripristina singolo ──────────────────────────────────────────────── + const trashMutation = useMutation({ + mutationFn: ({ id, trashed }: { id: string; trashed: boolean }) => + trashed ? messagesApi.trash(id) : messagesApi.untrash(id), + onSuccess: (updatedMsg, { trashed }) => { + queryClient.setQueryData( + ['messages', queryFilters], + (old: { items: MessageResponse[]; total: number } | undefined) => { + if (!old) return old + return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 } + }, + ) + toast.success(trashed ? 'Messaggio spostato nel cestino' : 'Messaggio ripristinato dal cestino') + invalidateMessages() + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + // ── Azioni bulk ────────────────────────────────────────────────────────────── const bulkMutation = useMutation({ mutationFn: messagesApi.bulkUpdate, @@ -247,10 +291,13 @@ export function InboxPage({ viewMode }: InboxPageProps) { invalidateMessages() setSelectedIds(new Set()) const n = result.updated - if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`) + if (payload.is_read === false) toast.success(`${n} ${n === 1 ? 'messaggio segnato' : 'messaggi segnati'} come da leggere`) + else if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`) else if (payload.is_starred === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} dai preferiti`) else if (payload.is_archived === true) toast.success(`${n} ${n === 1 ? 'messaggio archiviato' : 'messaggi archiviati'}`) else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`) + else if (payload.is_trashed === true) toast.success(`${n} ${n === 1 ? 'messaggio spostato nel cestino' : 'messaggi spostati nel cestino'}`) + else if (payload.is_trashed === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato dal cestino' : 'messaggi ripristinati dal cestino'}`) }, onError: (error) => toast.error(getErrorMessage(error)), }) @@ -280,6 +327,8 @@ export function InboxPage({ viewMode }: InboxPageProps) { }) } + const handleBulkMarkUnread = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_read: false }) const handleBulkStar = () => bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true }) const handleBulkUnstar = () => @@ -288,6 +337,10 @@ export function InboxPage({ viewMode }: InboxPageProps) { bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true }) const handleBulkUnarchive = () => bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false }) + const handleBulkTrash = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true }) + const handleBulkUntrash = () => + bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false }) // ── Selezione ──────────────────────────────────────────────────────────────── const handleToggleSelect = (id: string, e: React.MouseEvent) => { @@ -336,7 +389,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? 'Posta Inviata' : viewMode === 'starred' ? 'Preferiti' - : 'Archiviati' + : viewMode === 'archived' + ? 'Archiviati' + : 'Cestino' const FolderIcon = viewMode === 'inbox' ? Inbox @@ -344,7 +399,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? Send : viewMode === 'starred' ? Star - : Archive + : viewMode === 'archived' + ? Archive + : Trash2 const selectedCount = selectedIds.size const allSelected = messages.length > 0 && selectedCount === messages.length @@ -396,10 +453,12 @@ export function InboxPage({ viewMode }: InboxPageProps) { Aggiorna - + {viewMode !== 'trash' && ( + + )} @@ -443,10 +502,9 @@ export function InboxPage({ viewMode }: InboxPageProps) { - {/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */} + {/* ── Barra azioni bulk ── */} {someSelected && (
- {/* Contatore + deseleziona */}
+ )} + + {/* Stella */} + {viewMode !== 'starred' && viewMode !== 'trash' && ( + {/* Cestino / Ripristina cestino (solo modalita' casella) */} + {isMailboxMode && viewMode !== 'trash' && ( + + )} + + {isMailboxMode && viewMode === 'trash' && ( + + )} + + {/* Tag */} + {viewMode !== 'trash' && ( + + )}
)} @@ -558,17 +659,18 @@ export function InboxPage({ viewMode }: InboxPageProps) { {debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined ? 'Prova a modificare i filtri di ricerca' : viewMode === 'inbox' - ? 'La posta in arrivo è vuota' + ? 'La posta in arrivo e vuota' : viewMode === 'sent' ? 'Nessun messaggio inviato' : viewMode === 'starred' ? 'Nessun messaggio nei preferiti' - : 'Nessun messaggio archiviato'} + : viewMode === 'archived' + ? 'Nessun messaggio archiviato' + : 'Il cestino e vuoto'}

) : (
- {/* Riga "seleziona tutto" */} {messages.length > 0 && (
- {/* Tag badges */} {message.labels && message.labels.length > 0 && (
e.stopPropagation()}> @@ -784,9 +899,9 @@ function MessageRow({ )}
- {/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */} + {/* ── Azioni rapide (visibili su hover) ── */}
- {/* Pulsante stella (rapido, su hover o se stellata) */} + {/* Stella */} - {/* Pulsante archivia/ripristina (rapido, su hover) */} - + {/* Segna come da leggere (solo in modalita' casella, solo messaggi gia' letti) */} + {isMailboxMode && message.is_read && viewMode !== 'trash' && ( + + )} + + {/* Archivia/Ripristina (non nel cestino) */} + {viewMode !== 'trash' && ( + + )} + + {/* Cestino / Ripristina dal cestino (solo in modalita' casella) */} + {isMailboxMode && ( + + )} {/* Indicatore allegati */} {message.has_attachments && ( diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index c927521..c4dfb7a 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -12,6 +12,9 @@ import { Mail, Send, Tag, + Trash2, + RotateCcw, + MailX, } from 'lucide-react' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -29,7 +32,6 @@ export function MessageDetailPage() { const navigate = useNavigate() const queryClient = useQueryClient() - // Dialog tag const [showTagSelector, setShowTagSelector] = useState(false) // Carica messaggio @@ -62,7 +64,6 @@ export function MessageDetailPage() { mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred), onSuccess: (updated) => { queryClient.setQueryData(['message', id], updated) - // Invalida le query messaggi per aggiornare le viste Preferiti queryClient.invalidateQueries({ queryKey: ['messages'] }) toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti') }, @@ -80,15 +81,6 @@ export function MessageDetailPage() { onError: (error) => toast.error(getErrorMessage(error)), }) - // Download allegato autenticato - const handleDownloadAttachment = async (att: { id: string; filename: string }) => { - try { - await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename) - } catch (error) { - toast.error(getErrorMessage(error)) - } - } - // Ripristina dall'archivio const unarchiveMutation = useMutation({ mutationFn: () => messagesApi.unarchive(id!), @@ -100,17 +92,59 @@ export function MessageDetailPage() { onError: (error) => toast.error(getErrorMessage(error)), }) + // Segna come da leggere + const markUnreadMutation = useMutation({ + mutationFn: () => messagesApi.markUnread(id!), + onSuccess: (updated) => { + queryClient.setQueryData(['message', id], updated) + queryClient.invalidateQueries({ queryKey: ['messages'] }) + toast.success('Messaggio segnato come da leggere') + navigate(-1) + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Sposta nel cestino + const trashMutation = useMutation({ + mutationFn: () => messagesApi.trash(id!), + onSuccess: (updated) => { + queryClient.setQueryData(['message', id], updated) + queryClient.invalidateQueries({ queryKey: ['messages'] }) + toast.success('Messaggio spostato nel cestino') + navigate(-1) + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Ripristina dal cestino + const untrashMutation = useMutation({ + mutationFn: () => messagesApi.untrash(id!), + onSuccess: (updated) => { + queryClient.setQueryData(['message', id], updated) + queryClient.invalidateQueries({ queryKey: ['messages'] }) + toast.success('Messaggio ripristinato dal cestino') + }, + onError: (error) => toast.error(getErrorMessage(error)), + }) + + // Download allegato autenticato + const handleDownloadAttachment = async (att: { id: string; filename: string }) => { + try { + await messagesApi.downloadAttachment(message?.id ?? id!, att.id, att.filename) + } catch (error) { + toast.error(getErrorMessage(error)) + } + } + // Imposta tag del messaggio const setLabelsMutation = useMutation({ mutationFn: (labelIds: string[]) => labelsApi.setMessageLabels(id!, { label_ids: labelIds }), onSuccess: (updatedLabels) => { - // Aggiorna la cache del messaggio con i nuovi label queryClient.setQueryData(['message', id], (old: typeof message) => { if (!old) return old return { ...old, labels: updatedLabels } }) - // Invalida la lista messaggi per aggiornare i badge nella inbox queryClient.invalidateQueries({ queryKey: ['messages'] }) setShowTagSelector(false) toast.success('Tag aggiornati') @@ -118,7 +152,7 @@ export function MessageDetailPage() { onError: (error) => toast.error(getErrorMessage(error)), }) - // Rimuove singolo tag (click su × nel badge) + // Rimuove singolo tag const removeLabelMutation = useMutation({ mutationFn: (labelId: string) => labelsApi.removeMessageLabels(id!, { label_ids: [labelId] }), @@ -197,8 +231,21 @@ export function MessageDetailPage() { /> - {/* Archivia (se non ancora archiviato) */} - {!message.is_archived && ( + {/* Segna come da leggere (solo se gia' letto) */} + {message.is_read && !message.is_trashed && ( + + )} + + {/* Archivia (se non ancora archiviato e non nel cestino) */} + {!message.is_archived && !message.is_trashed && ( + )} + + {/* Ripristina dal cestino (se nel cestino) */} + {message.is_trashed && ( + + )} + + {/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */} + {message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
+ {/* Banner "Nel Cestino" */} + {message.is_trashed && ( +
+
+ + Questo messaggio si trova nel cestino. +
+ +
+ )} + {/* Banner "Archiviato" */} - {message.is_archived && ( + {message.is_archived && !message.is_trashed && (
@@ -421,7 +515,7 @@ export function MessageDetailPage() {
- Questo è un messaggio automatico di tipo{' '} + Questo e un messaggio automatico di tipo{' '} diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index 8ba1a63..69e8f72 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -260,6 +260,8 @@ export interface MessageResponse { is_starred: boolean is_archived: boolean archived_at: string | null + is_trashed: boolean + trashed_at: string | null raw_eml_path: string | null created_at: string updated_at: string From f5fb537fedb2ab87659da94aed9dd210a2093f46 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Wed, 25 Mar 2026 18:02:50 +0100 Subject: [PATCH 03/11] Visualizzazione ricevute --- backend/app/api/v1/messages.py | 133 ++++++++++++++++++ frontend/src/api/messages.api.ts | 20 +++ .../components/ReceiptTree/ReceiptTree.tsx | 45 +++++- .../pages/MessageDetail/MessageDetailPage.tsx | 32 +++++ 4 files changed, 225 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 289a8e0..137b3e2 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -526,6 +526,139 @@ async def download_attachment( raise NotFoundError("File non disponibile al momento") +@router.get("/{message_id}/download-package") +async def download_package( + message_id: uuid.UUID, + current_user: CurrentUser, + db: DB, +) -> StreamingResponse: + """ + Scarica un archivio ZIP con tutti i file originali della PEC. + + Per messaggi inbound: allegati del messaggio (postacert.eml, daticert.xml, ecc.) + e il raw EML originale. + + Per messaggi outbound: allegati del messaggio + raw EML di ogni ricevuta collegata + (accettazione, consegna, ecc.). + """ + import io + import zipfile as _zipfile + + from miniopy_async import Minio + + # Verifica accesso + message = await _resolve_message(message_id, current_user, db) + + client = Minio( + endpoint=settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_use_ssl, + ) + + buf = io.BytesIO() + + async def _read_minio(path: str) -> bytes: + try: + resp = await client.get_object(settings.minio_bucket, path) + data = await resp.content.read() + resp.close() + return data + except Exception: + return b"" + + with _zipfile.ZipFile(buf, mode="w", compression=_zipfile.ZIP_DEFLATED) as zf: + # ── Allegati del messaggio principale ────────────────────────────── + att_result = await db.execute( + select(Attachment) + .where(Attachment.message_id == message.id) + .order_by(Attachment.created_at) + ) + main_attachments = list(att_result.scalars().all()) + + for att in main_attachments: + data = await _read_minio(att.storage_path) + if data: + zf.writestr(att.filename, data) + + # ── Raw EML del messaggio principale ────────────────────────────── + if message.raw_eml_path: + data = await _read_minio(message.raw_eml_path) + if data: + # Nome file: messaggio_originale.eml oppure il basename del path + eml_name = message.raw_eml_path.rsplit("/", 1)[-1] + if not eml_name.endswith(".eml"): + eml_name = "messaggio_originale.eml" + # Evita duplicati con gli allegati gia' inseriti + existing = {info.filename for info in zf.infolist()} + if eml_name not in existing: + zf.writestr(eml_name, data) + + # ── Ricevute (solo per outbound) ─────────────────────────────────── + if message.direction == "outbound": + receipts_result = await db.execute( + select(Message) + .where(Message.parent_message_id == message.id) + .order_by(Message.received_at.asc().nullslast(), Message.created_at.asc()) + ) + receipts = list(receipts_result.scalars().all()) + + for receipt in receipts: + # Tipo ricevuta come prefisso cartella + pec_type = receipt.pec_type or "ricevuta" + folder = f"ricevute/{pec_type}" + + # Allegati della ricevuta + r_att_result = await db.execute( + select(Attachment) + .where(Attachment.message_id == receipt.id) + .order_by(Attachment.created_at) + ) + r_attachments = list(r_att_result.scalars().all()) + + for att in r_attachments: + data = await _read_minio(att.storage_path) + if data: + zip_path = f"{folder}/{att.filename}" + # Gestisce duplicati aggiungendo un contatore + existing = {info.filename for info in zf.infolist()} + final_path = zip_path + counter = 1 + while final_path in existing: + name, _, ext = att.filename.rpartition(".") + final_path = f"{folder}/{name}_{counter}.{ext}" if ext else f"{folder}/{att.filename}_{counter}" + counter += 1 + zf.writestr(final_path, data) + + # Raw EML della ricevuta + if receipt.raw_eml_path: + data = await _read_minio(receipt.raw_eml_path) + if data: + eml_name = receipt.raw_eml_path.rsplit("/", 1)[-1] + if not eml_name.endswith(".eml"): + eml_name = f"{pec_type}.eml" + zip_path = f"{folder}/{eml_name}" + existing = {info.filename for info in zf.infolist()} + if zip_path not in existing: + zf.writestr(zip_path, data) + + buf.seek(0) + zip_bytes = buf.getvalue() + + # Nome del file ZIP basato sull'oggetto della mail + safe_subject = (message.subject or "pec").replace("/", "_").replace("\\", "_")[:50] + zip_filename = f"pec_{safe_subject}.zip" + + return StreamingResponse( + iter([zip_bytes]), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_filename}"', + "Content-Length": str(len(zip_bytes)), + }, + ) + + @router.get("/{message_id}/receipts", response_model=list[MessageResponse]) async def list_receipts( message_id: uuid.UUID, diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index e30c106..a034d79 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -104,4 +104,24 @@ export const messagesApi = { getReceipts: (id: string) => apiClient.get(`/messages/${id}/receipts`).then((r) => r.data), + + /** + * Scarica il pacchetto ZIP completo della PEC (postacert.eml, daticert.xml, + * ricevute di accettazione/consegna per le mail outbound). + */ + downloadPackage: async (messageId: string, subject?: string | null): Promise => { + const response = await apiClient.get( + `/messages/${messageId}/download-package`, + { responseType: 'blob' }, + ) + const blobUrl = window.URL.createObjectURL(new Blob([response.data], { type: 'application/zip' })) + const anchor = document.createElement('a') + anchor.href = blobUrl + const safeSubject = (subject || 'pec').replace(/[/\\]/g, '_').slice(0, 50) + anchor.setAttribute('download', `pec_${safeSubject}.zip`) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(blobUrl) + }, } diff --git a/frontend/src/components/ReceiptTree/ReceiptTree.tsx b/frontend/src/components/ReceiptTree/ReceiptTree.tsx index 44f7e3a..601b89b 100644 --- a/frontend/src/components/ReceiptTree/ReceiptTree.tsx +++ b/frontend/src/components/ReceiptTree/ReceiptTree.tsx @@ -1,5 +1,6 @@ -import { ChevronDown, ChevronRight, Mail } from 'lucide-react' +import { ChevronDown, ChevronRight, Mail, ExternalLink } from 'lucide-react' import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { PecTypeBadge, PecStateBadge } from '@/components/PecBadge/PecBadge' import { formatDate } from '@/lib/utils' import type { MessageResponse } from '@/types/api.types' @@ -12,9 +13,11 @@ interface ReceiptTreeProps { /** * Visualizza la gerarchia delle ricevute PEC collegate a un messaggio. * Mostra in ordine cronologico: accettazione → consegna (o anomalia). + * Le ricevute sono cliccabili e navigano al dettaglio del messaggio ricevuta. */ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) { const [expanded, setExpanded] = useState(true) + const navigate = useNavigate() if (receipts.length === 0) { if (message.direction === 'outbound') { @@ -66,6 +69,7 @@ export function ReceiptTree({ message, receipts }: ReceiptTreeProps) { date={receipt.received_at || receipt.created_at} type={receipt.pec_type} messageId={receipt.id} + onClick={() => navigate(`/messages/${receipt.id}`)} /> ))}
@@ -81,25 +85,41 @@ interface ReceiptNodeProps { type?: MessageResponse['pec_type'] messageId?: string isRoot?: boolean + onClick?: () => void } -function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) { - return ( +function ReceiptNode({ label, date, state, type, isRoot, onClick }: ReceiptNodeProps) { + const isClickable = !!onClick + + const content = (
{/* Indicatore timeline */}
- {label} + + {label} + {state && !isRoot && } {type && type !== 'posta_certificata' && } + {isClickable && ( + + )}
{date && (

{formatDate(date)}

@@ -107,4 +127,19 @@ function ReceiptNode({ label, date, state, type, isRoot }: ReceiptNodeProps) {
) + + if (isClickable) { + return ( + + ) + } + + return
{content}
} diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index c4dfb7a..84ff5cc 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -15,6 +15,7 @@ import { Trash2, RotateCcw, MailX, + PackageOpen, } from 'lucide-react' import toast from 'react-hot-toast' import { Button } from '@/components/ui/Button' @@ -33,6 +34,7 @@ export function MessageDetailPage() { const queryClient = useQueryClient() const [showTagSelector, setShowTagSelector] = useState(false) + const [isDownloadingPackage, setIsDownloadingPackage] = useState(false) // Carica messaggio const { @@ -136,6 +138,20 @@ export function MessageDetailPage() { } } + // Download pacchetto completo ZIP + const handleDownloadPackage = async () => { + if (!message) return + setIsDownloadingPackage(true) + try { + await messagesApi.downloadPackage(message.id, message.subject) + toast.success('Pacchetto scaricato') + } catch (error) { + toast.error(getErrorMessage(error)) + } finally { + setIsDownloadingPackage(false) + } + } + // Imposta tag del messaggio const setLabelsMutation = useMutation({ mutationFn: (labelIds: string[]) => @@ -313,6 +329,22 @@ export function MessageDetailPage() { Rispondi )} + + {/* Scarica pacchetto completo ZIP (sempre visibile) */} +
From cbeedc2d2f9257891697738a8d09d209db4b3663 Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Wed, 25 Mar 2026 18:39:50 +0100 Subject: [PATCH 04/11] Semantic search --- ARCHITECTURE.md | 38 -- GapAnalysis.md | 148 +++++ .../alembic/versions/0008_full_text_search.py | 76 +++ backend/app/api/v1/messages.py | 52 +- backend/app/models/message.py | 9 +- backend/app/services/search_service.py | 139 +++++ frontend/src/App.tsx | 4 + frontend/src/api/messages.api.ts | 5 + frontend/src/components/Layout/Sidebar.tsx | 24 + frontend/src/pages/Inbox/InboxPage.tsx | 112 +++- frontend/src/pages/Search/SearchPage.tsx | 556 ++++++++++++++++++ worker/app/imap/sync.py | 7 + worker/app/jobs/index_message.py | 218 +++++++ worker/pyproject.toml | 4 + 14 files changed, 1336 insertions(+), 56 deletions(-) create mode 100644 GapAnalysis.md create mode 100644 backend/alembic/versions/0008_full_text_search.py create mode 100644 backend/app/services/search_service.py create mode 100644 frontend/src/pages/Search/SearchPage.tsx create mode 100644 worker/app/jobs/index_message.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e288c78..0941f76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -888,44 +888,6 @@ END $$; ## 4. Decisioni Architetturali ---- - -### ADR-001 – Multi-tenancy: Schema-per-tenant vs Row-level con tenant_id - -**Opzione A – Schema-per-tenant (un PostgreSQL schema per ogni organizzazione)** - -*Pro:* -- Isolamento totale dei dati: impossibile data leak cross-tenant per bug SQL -- Backup e restore per singolo tenant molto semplici -- Possibilità di migrare un tenant su un DB separato senza refactoring - -*Contro:* -- Alembic migrations vanno applicate a tutti gli schema (N schema × migrazione) -- Connection pooling complesso: PgBouncer deve gestire schema switching -- Difficile fare query aggregate cross-tenant (es. monitoraggio globale SaaS) -- Overhead operativo significativo oltre i 100 tenant - -**Opzione B – Row-level con `tenant_id` + PostgreSQL RLS** - -*Pro:* -- Un solo schema, migrations applicate una volta -- Query cross-tenant per operazioni di sistema (monitoring, billing) -- Molto più semplice da gestire con Alembic e ORM -- Scala bene fino a decine di migliaia di tenant - -*Contro:* -- Bug nell'impostazione `current_tenant_id` può causare data leak → mitigato con RLS come secondo livello -- Restore di singolo tenant richiede WHERE clause su dump - -**Raccomandazione: Opzione B (row-level + RLS)** - -Per un SaaS B2B con caselle PEC, il numero di tenant nel medio termine è nell'ordine delle centinaia, non decine di migliaia. Il rischio principale (data leak cross-tenant) è mitigato da due strati indipendenti: -1. Applicativo: ogni query include `WHERE tenant_id = :current_tenant_id` -2. DB: PostgreSQL RLS come safety net, impostata tramite `SET LOCAL app.current_tenant_id = '...'` in ogni transazione - -Il vantaggio operativo (migrazioni semplici, query di monitoring, pool unico) supera i rischi, a condizione di avere test di integrazione che verificano il corretto isolamento. - ---- ### ADR-002 – Cifratura credenziali IMAP/SMTP a riposo diff --git a/GapAnalysis.md b/GapAnalysis.md new file mode 100644 index 0000000..7f53277 --- /dev/null +++ b/GapAnalysis.md @@ -0,0 +1,148 @@ +Report Gap Analysis – PEChub PEC Manager SaaS +Analisi condotta il 25/03/2026 sul codice sorgente (backend, worker, frontend). + +COSA E' IMPLEMENTATO E FUNZIONANTE +Infrastruttura e autenticazione + +Stack Docker completo (PostgreSQL, Redis, MinIO, Nginx, backend, worker, frontend) +Autenticazione JWT con refresh token silenzioso +2FA TOTP (setup + verifica) +Multi-tenancy row-level con RLS PostgreSQL +Cifratura credenziali IMAP/SMTP (AES-256-GCM in security.py) +Rate limiting su endpoint auth +WebSocket real-time per nuovi messaggi +CRUD completo: caselle, utenti, permessi, Virtual Box, notifiche, etichette +IMAP Sync Engine + +Pool asincrono con N coroutine IMAP, IDLE + polling fallback +Backoff esponenziale su disconnessione +Download EML grezzo su MinIO +Aggiornamento stato casella (error, sync_error_count) +Parser PEC + +Classificazione tipo messaggio da header X-Ricevuta/X-TipoRicevuta +Parsing MIME completo, estrazione allegati +EML-in-EML (ricevute annidate) +State machine outbound: sent → accepted → delivered / anomaly +Invio SMTP + +API POST /send con validazione e creazione send_job +Job send_pec con retry esponenziale (5 tentativi) +receipt_watcher: attesa ricevuta accettazione con alert anomalia a 24h +Upload raw EML inviato su MinIO +Frontend + +Inbox multi-casella con filtri, selezione multipla, azioni bulk +Posta inviata, Preferiti, Archiviati, Cestino +Dettaglio messaggio: corpo HTML/testo, allegati, ReceiptTree, download ZIP +Composizione PEC con RichTextEditor, To/Cc multipli, allegati +Gestione caselle, utenti, permessi, Virtual Box, notifiche, impostazioni +Pagina Multi-tenant (Super Admin) +Tag/etichette con colori su messaggi +Virtual Box con regole e assegnazioni utenti +COSA MANCA – PRIORITA' ALTA +1. Dispatch automatico notifiche (Sistema di notifiche incompleto al 60%) + +Il CRUD canali/regole/log e' implementato, ma manca tutto il lato dispatch +Non esiste worker/app/jobs/dispatch_notification.py +NotificationService non ha il metodo evaluate_rules(event_type, message) che valuta le regole e accoda i job +L'IMAP sync (sync.py) non chiama nulla al salvataggio di un nuovo messaggio +Il test canale webhook e email e' uno stub che restituisce sempre successo (solo Telegram ha invio reale) +La cifratura in notification_service.py usa base64 grezzo, non AES-256-GCM: i segreti (bot_token, webhook_secret, smtp_password) sono leggibili in chiaro nel DB +Canale WhatsApp: nessuna implementazione reale (stub completo) +Canale Email SMTP: nessuna implementazione reale (stub completo) +Risultato pratico: le notifiche sono configurabili ma non vengono mai inviate automaticamente +2. Ricerca avanzata full-text (Fase 5-B – completamente mancante) + +Non esiste backend/app/api/v1/search.py +Non esiste backend/app/services/search_service.py +Non esiste frontend/src/pages/Search/ ne' frontend/src/api/search.api.ts +La "ricerca" nell'InboxPage usa solo ILIKE su subject/from_address/body_text: e' lenta su volumi grandi e non cerca nel testo degli allegati +La colonna search_vector tsvector non e' nelle migrazioni Alembic attuali (0001–0007) +Non c'e' Apache Tika e non c'e' worker/app/jobs/index_message.py per l'estrazione testo da PDF/DOCX +3. Archiviazione Sostitutiva (Fase 6 – ~15% implementata) + +worker/app/archival/conservatore_client.py esiste (mock + produzione) ma non e' mai chiamato da nessun job reale +Mancano completamente: +worker/app/archival/sip_builder.py (generazione pacchetto SIP UNI SInCRO) +worker/app/archival/rdv_processor.py (parsing RdV XML) +worker/app/jobs/archive_batch.py (job selezione messaggi + upload SIP) +backend/app/api/v1/archival.py (endpoint GET /archival/batches, POST /archival/dip) +frontend/src/pages/Archival/ (pagina log versamenti, download RdV, richiesta DIP) +Il modello archival.py esiste ma la tabella archival_batches non e' nella migrazione corrente +La configurazione conservatore nelle impostazioni tenant e' pronta, ma il "pulsante" che avvia il versamento non esiste +4. Dashboard e Reportistica (Fase 7 – completamente mancante) + +Non esistono endpoint /reports/summary, /reports/export +Non esiste pagina Reports/Dashboard nel frontend (nessuna rotta in App.tsx) +Non c'e' generazione PDF (WeasyPrint) ne' export CSV +Non c'e' nessun grafico o KPI visibile (PEC ricevute/inviate oggi, anomalie, tasso consegna) +5. Audit Log – modello esistente, tutto il resto mancante + +Il modello audit_log.py e la tabella esistono +Non c'e' nessun endpoint API GET /audit-log per leggerlo +Non c'e' nessuna pagina frontend per la visualizzazione +Non e' chiaro se il backend registra effettivamente gli eventi (nessuna chiamata a AuditLog trovata nei servizi) +COSA MANCA – PRIORITA' MEDIA +6. Worker – job mancanti + +dispatch_notification.py – notifiche automatiche +archive_batch.py – versamenti verso conservatore +generate_report.py – export PDF/CSV +index_message.py – indicizzazione FTS allegati via Tika +7. Sicurezza – punti critici + +La cifratura dei segreti notifiche usa base64.b64encode() senza encryption reale: chiunque abbia accesso al DB puo' leggere bot_token Telegram, webhook secret, SMTP password in chiaro +Il CI/CD GitHub Actions e' disabilitato (ci.yml.bak): non c'e' lint automatico, test o build su PR +Non c'e' docker-compose.prod.yml (override produzione con configurazioni rafforzate) +Docs /docs, /redoc sono disabilitate in produzione ma non c'e' un meccanismo di secret scan +8. Invio PEC – funzionalita' mancanti + +Non c'e' Forward messaggio (la risposta e' parzialmente implementata in ComposePage ma non e' chiaro se funziona end-to-end) +Non c'e' endpoint per forzare un re-sync manuale di una casella (utile dopo un errore di connessione) +Non c'e' indicazione visiva del numero di messaggi non letti nella sidebar per casella +La barra ricerca nell'Inbox non ha filtri per data (da/a), stato PEC, tipo PEC +9. Ruolo Supervisor + +Il ruolo supervisor e' definito nell'enum DB e nella documentazione ma non ha logica differenziata dal operator nel codice: is_admin controlla solo admin/super_admin, tutto il resto e' trattato uguale +10. Gestione quote casella + +L'evento mailbox.quota_warning e' definito negli enum delle notifiche ma non e' mai generato dal worker (nessuna stima della quota IMAP) +COSA MANCA – PRIORITA' BASSA (Hardening / Go-Live) +11. Monitoring e osservabilita' + +Non c'e' infra/prometheus/ ne' infra/grafana/ (previsti in ARCHITECTURE.md ma non creati) +Non c'e' log aggregation (Loki/ELK) +Non ci sono metriche esposte dal backend (es. /metrics endpoint Prometheus) +12. Backup automatico + +Non c'e' script o cronjob per pg_dump automatico verso MinIO +13. Test coverage + +I test di integrazione esistenti coprono auth, users, send API +Non ci sono test per: messages API, permissions API, virtual_boxes, notifications, archival +Non c'e' copertura frontend (nessun test Vitest/Playwright presente) +14. GDPR + +Non c'e' endpoint DELETE /tenants/{id} per cancellazione completa dati tenant con audit trail +RIEPILOGO STATO PER FASE +Fase Descrizione Stato +1 Fondamenta + Auth + Multi-tenancy Completa +1-A Permessi granulari per casella Completa +2 IMAP Sync Engine Completa +3 Parser PEC e Tracking Ricevute Completa +4 Invio SMTP con retry Completa +5 Frontend base (inbox, compose, admin) Completa +5-A Virtual Box Completa +5-B Ricerca avanzata full-text Non iniziata +5-C Notifiche multi-canale Struttura pronta, dispatch mancante +6 Archiviazione sostitutiva ~15% (client mock presente, tutto il resto mancante) +7 Dashboard e Reportistica Non iniziata +8 Hardening, test, go-live Parziale (sicurezza base presente, monitoring/backup/CI mancanti) +PRIORITA' DI INTERVENTO CONSIGLIATA +Notifiche dispatch – e' la funzionalita' piu' vicina al completamento: la struttura dati e' pronta, manca solo il wiring tra IMAP sync, il servizio di valutazione regole e il job worker. Ha alto impatto operativo. +Ricerca avanzata – blocca l'usabilita' su volumi di posta significativi. La ricerca ILIKE attuale scala male. +Archiviazione sostitutiva – obbligatoria per la compliance normativa dei clienti PA/professionisti. +Fix sicurezza cifratura notifiche – critico: i segreti (bot token, webhook secret) sono attualmente non cifrati nel DB. +Audit log – necessario per compliance e per l'utilizzo enterprise. +Dashboard/Report – utile commercialmente ma non bloccante per l'operativita'. \ No newline at end of file diff --git a/backend/alembic/versions/0008_full_text_search.py b/backend/alembic/versions/0008_full_text_search.py new file mode 100644 index 0000000..a173f77 --- /dev/null +++ b/backend/alembic/versions/0008_full_text_search.py @@ -0,0 +1,76 @@ +"""add full text search vector to messages and extracted_text to attachments + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-03-25 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0008' +down_revision = '0007' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Aggiunge colonna search_vector a messages + op.add_column( + 'messages', + sa.Column('search_vector', postgresql.TSVECTOR(), nullable=True), + ) + + # 2. Aggiunge colonna extracted_text ad attachments (testo estratto da PDF/DOCX) + op.add_column( + 'attachments', + sa.Column('extracted_text', sa.Text(), nullable=True), + ) + + # 3. Indice GIN per ricerca full-text veloce + op.execute( + "CREATE INDEX idx_messages_fts ON messages USING gin(search_vector) " + "WHERE search_vector IS NOT NULL" + ) + + # 4. Funzione trigger che aggiorna search_vector quando cambiano i campi testuali + op.execute(""" + CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := + setweight(to_tsvector('italian', coalesce(NEW.subject, '')), 'A') || + setweight(to_tsvector('simple', coalesce(NEW.from_address, '')), 'B') || + setweight(to_tsvector('simple', + coalesce(array_to_string(NEW.to_addresses, ' '), '')), 'B') || + setweight(to_tsvector('italian', coalesce(NEW.body_text, '')), 'C'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) + + # 5. Crea trigger (si attiva su INSERT e UPDATE dei campi rilevanti) + op.execute(""" + CREATE TRIGGER trg_messages_search_vector + BEFORE INSERT OR UPDATE OF subject, from_address, to_addresses, body_text + ON messages + FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update(); + """) + + # 6. Backfill: popola search_vector per i messaggi esistenti + op.execute(""" + UPDATE messages SET search_vector = + setweight(to_tsvector('italian', coalesce(subject, '')), 'A') || + setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') || + setweight(to_tsvector('simple', + coalesce(array_to_string(to_addresses, ' '), '')), 'B') || + setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') + WHERE search_vector IS NULL + """) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS trg_messages_search_vector ON messages") + op.execute("DROP FUNCTION IF EXISTS messages_search_vector_update()") + op.execute("DROP INDEX IF EXISTS idx_messages_fts") + op.drop_column('attachments', 'extracted_text') + op.drop_column('messages', 'search_vector') diff --git a/backend/app/api/v1/messages.py b/backend/app/api/v1/messages.py index 137b3e2..f003ec7 100644 --- a/backend/app/api/v1/messages.py +++ b/backend/app/api/v1/messages.py @@ -26,6 +26,8 @@ from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.services.search_service import SearchService + from app.config import get_settings from app.core.exceptions import ForbiddenError, NotFoundError from app.database import get_db @@ -184,8 +186,11 @@ async def list_messages( is_starred: Optional[bool] = Query(None), is_archived: Optional[bool] = Query(False), is_trashed: Optional[bool] = Query(False), - search: Optional[str] = Query(None, max_length=200), + search: Optional[str] = Query(None, max_length=500), pec_type: Optional[str] = Query(None), + # Filtri data (ISO 8601, es. 2026-01-01T00:00:00Z) + date_from: Optional[datetime] = Query(None, description="Data minima (received_at o sent_at)"), + date_to: Optional[datetime] = Query(None, description="Data massima (received_at o sent_at)"), # Paginazione page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), @@ -195,7 +200,8 @@ async def list_messages( - `is_archived=False` (default) esclude i messaggi archiviati. - `is_trashed=False` (default) esclude i messaggi nel cestino. - - `search` cerca su subject, from_address, to_addresses. + - `search` usa ricerca full-text (tsvector) con fallback ILIKE. + - `date_from` / `date_to` filtrano per data ricezione o invio. - `vbox_id` filtra per Virtual Box assegnata all'utente corrente. """ # Determinare le caselle visibili (normale check permessi) @@ -284,16 +290,30 @@ async def list_messages( if is_trashed is not None: q = q.where(Message.is_trashed == is_trashed) + # ── Full-text search (FTS con fallback ILIKE per messaggi non indicizzati) ─── if search: - term = f"%{search}%" + from sqlalchemy import case as sa_case + + tsquery = func.websearch_to_tsquery("italian", search) + term_like = f"%{search}%" q = q.where( or_( - Message.subject.ilike(term), - Message.from_address.ilike(term), - Message.body_text.ilike(term), + Message.search_vector.op("@@")(tsquery), + # Fallback per messaggi non ancora indicizzati dal worker + Message.search_vector.is_(None) & or_( + Message.subject.ilike(term_like), + Message.from_address.ilike(term_like), + Message.body_text.ilike(term_like), + ), ) ) + # ── Filtri data ─────────────────────────────────────────────────────────── + if date_from: + q = q.where(or_(Message.received_at >= date_from, Message.sent_at >= date_from)) + if date_to: + q = q.where(or_(Message.received_at <= date_to, Message.sent_at <= date_to)) + # Applica le regole della Virtual Box (AND tra le regole) for rule in vbox_rules: q = _apply_vbox_rule(q, rule.field, rule.operator, rule.value) @@ -302,13 +322,23 @@ async def list_messages( count_q = select(func.count()).select_from(q.subquery()) total = (await db.execute(count_q)).scalar_one() - # Ordinamento e paginazione + # Ordinamento: se c'e' una ricerca, ordina per rilevanza FTS, poi data + if search: + from sqlalchemy import case as sa_case + + tsquery_ord = func.websearch_to_tsquery("italian", search) + rank_expr = sa_case( + (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery_ord)), + else_=0.0, + ) + order_clauses = [rank_expr.desc(), Message.received_at.desc().nullslast(), Message.created_at.desc()] + else: + order_clauses = [Message.received_at.desc().nullslast(), Message.created_at.desc()] + + # Paginazione q = ( q.options(selectinload(Message.labels)) - .order_by( - Message.received_at.desc().nullslast(), - Message.created_at.desc(), - ) + .order_by(*order_clauses) .offset((page - 1) * page_size) .limit(page_size) ) diff --git a/backend/app/models/message.py b/backend/app/models/message.py index b037b01..63506d0 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -4,6 +4,7 @@ Modelli Message, Attachment, SendJob. import uuid from datetime import datetime +from typing import Any from sqlalchemy import ( ARRAY, @@ -18,7 +19,7 @@ from sqlalchemy import ( Text, func, ) -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import TSVECTOR, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -96,6 +97,9 @@ class Message(Base): raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) + # Full-text search vector (aggiornato da trigger DB + worker per allegati) + search_vector: Mapped[Any | None] = mapped_column(TSVECTOR(), nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) @@ -126,6 +130,7 @@ class Message(Base): postgresql_where="parent_message_id IS NOT NULL", ), Index("idx_messages_imap_uid", "mailbox_id", "imap_uid"), + Index("idx_messages_fts", "search_vector", postgresql_using="gin"), ) def __repr__(self) -> str: @@ -149,6 +154,8 @@ class Attachment(Base): size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True) storage_path: Mapped[str] = mapped_column(Text, nullable=False) checksum_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + # Testo estratto dal worker (solo PDF e DOCX) per la ricerca full-text + extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 0000000..9b9baf6 --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,139 @@ +""" +Servizio di ricerca full-text per i messaggi PEC. + +Utilizza i vettori tsvector di PostgreSQL per ricerche veloci su: + - oggetto (peso A) + - mittente / destinatari (peso B) + - corpo del messaggio (peso C) + - testo estratto dagli allegati PDF/DOCX (peso D) + +Se search_vector e' NULL (messaggio non ancora indicizzato dal worker), +cade back automaticamente a ILIKE sulle colonne base. +""" + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import case, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.message import Message + + +class SearchService: + """Incapsula la logica di ricerca full-text sui messaggi.""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def search_messages( + self, + tenant_id: uuid.UUID, + search_term: str, + visible_mailbox_ids: Optional[list[uuid.UUID]], + mailbox_id: Optional[uuid.UUID] = None, + direction: Optional[str] = None, + state: Optional[str] = None, + pec_type: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + is_archived: Optional[bool] = False, + is_trashed: Optional[bool] = False, + is_starred: Optional[bool] = None, + is_read: Optional[bool] = None, + page: int = 1, + page_size: int = 50, + ) -> tuple[list[Message], int]: + """ + Ricerca full-text nei messaggi. + + Logica: + 1. Messaggi con search_vector non NULL → usa @@ operator + ts_rank + 2. Messaggi con search_vector NULL → fallback ILIKE (non ancora indicizzati) + 3. Applica tutti i filtri aggiuntivi (data, stato, tipo, direzione, ecc.) + 4. Ordina per rilevanza FTS desc, poi per data desc + """ + q = select(Message).where( + Message.tenant_id == tenant_id, + Message.parent_message_id.is_(None), + ) + + # Restrizione caselle visibili (permessi) + if visible_mailbox_ids is not None: + if not visible_mailbox_ids: + return [], 0 + q = q.where(Message.mailbox_id.in_(visible_mailbox_ids)) + + # Filtri opzionali + if mailbox_id: + q = q.where(Message.mailbox_id == mailbox_id) + if direction: + q = q.where(Message.direction == direction) + if state: + q = q.where(Message.state == state) + if pec_type: + q = q.where(Message.pec_type == pec_type) + if is_archived is not None: + q = q.where(Message.is_archived == is_archived) + if is_trashed is not None: + q = q.where(Message.is_trashed == is_trashed) + if is_starred is not None: + q = q.where(Message.is_starred == is_starred) + if is_read is not None: + q = q.where(Message.is_read == is_read) + + # Filtri data: cerca sia su received_at che su sent_at + if date_from: + q = q.where( + or_( + Message.received_at >= date_from, + Message.sent_at >= date_from, + ) + ) + if date_to: + q = q.where( + or_( + Message.received_at <= date_to, + Message.sent_at <= date_to, + ) + ) + + # Full-text search con fallback ILIKE + tsquery = func.websearch_to_tsquery("italian", search_term) + term_like = f"%{search_term}%" + + fts_condition = Message.search_vector.op("@@")(tsquery) + ilike_fallback = Message.search_vector.is_(None) & or_( + Message.subject.ilike(term_like), + Message.from_address.ilike(term_like), + Message.body_text.ilike(term_like), + ) + + q = q.where(or_(fts_condition, ilike_fallback)) + + # Conteggio totale (senza paginazione) + count_q = select(func.count()).select_from(q.subquery()) + total: int = (await self.db.execute(count_q)).scalar_one() + + # Ordinamento per rilevanza FTS, poi data + rank_expr = case( + (Message.search_vector.isnot(None), func.ts_rank(Message.search_vector, tsquery)), + else_=0.0, + ) + + q = ( + q.options(selectinload(Message.labels)) + .order_by( + rank_expr.desc(), + Message.received_at.desc().nullslast(), + Message.created_at.desc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) + ) + + result = await self.db.execute(q) + items = list(result.scalars().all()) + return items, total diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eda1ddb..b0599fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage' import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage' import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage' +import { SearchPage } from '@/pages/Search/SearchPage' /** * Routing principale dell'applicazione PEChub. @@ -76,6 +77,9 @@ export default function App() { {/* Super Admin – Gestione Multi-Tenant */} } /> + {/* Ricerca avanzata full-text */} + } /> + {/* Profilo utente */} } /> diff --git a/frontend/src/api/messages.api.ts b/frontend/src/api/messages.api.ts index a034d79..b7d08be 100644 --- a/frontend/src/api/messages.api.ts +++ b/frontend/src/api/messages.api.ts @@ -13,11 +13,16 @@ export interface MessageFilters { mailbox_id?: string direction?: 'inbound' | 'outbound' state?: string + pec_type?: string is_read?: boolean is_starred?: boolean is_archived?: boolean is_trashed?: boolean search?: string + /** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */ + date_from?: string + /** Data massima nel formato ISO 8601 */ + date_to?: string } export interface MessageBulkUpdatePayload { diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index 827f72f..6a12575 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -50,6 +50,7 @@ import { Archive, Building2, Trash2, + Search, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -343,6 +344,29 @@ export function Sidebar() {
)} + {/* ── Ricerca avanzata ── */} +
+
+
+ + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-blue-600 text-white' + : 'text-gray-300 hover:bg-gray-700 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Ricerca avanzata' : undefined} + > + + {!collapsed && Ricerca} + +
+
+ {/* ── Nuova PEC ── */}
diff --git a/frontend/src/pages/Inbox/InboxPage.tsx b/frontend/src/pages/Inbox/InboxPage.tsx index e28bc30..4dfcb36 100644 --- a/frontend/src/pages/Inbox/InboxPage.tsx +++ b/frontend/src/pages/Inbox/InboxPage.tsx @@ -36,6 +36,9 @@ import { Trash2, RotateCcw, MailX, + SlidersHorizontal, + ChevronDown, + ChevronUp, } from 'lucide-react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' @@ -80,6 +83,14 @@ export function InboxPage({ viewMode }: InboxPageProps) { const [page, setPage] = useState(1) const PAGE_SIZE = 50 + // ── Filtri avanzati ────────────────────────────────────────────────────────── + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [pecTypeFilter, setPecTypeFilter] = useState('') + + const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter].filter(Boolean).length + // ── Stato selezione ───────────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()) @@ -91,6 +102,10 @@ export function InboxPage({ viewMode }: InboxPageProps) { setDebouncedSearch('') setIsReadFilter(undefined) setIsStarredFilter(undefined) + setDateFrom('') + setDateTo('') + setPecTypeFilter('') + setShowAdvancedFilters(false) setPage(1) setSelectedIds(new Set()) }, [mailboxId, vboxId, viewMode]) @@ -125,19 +140,27 @@ export function InboxPage({ viewMode }: InboxPageProps) { ? myVboxes.find((v) => v.id === vboxId) : undefined + // ── Reset pagina per filtri avanzati ───────────────────────────────────────── + useEffect(() => { + setPage(1) + }, [dateFrom, dateTo, pecTypeFilter]) + // ── Query messaggi ─────────────────────────────────────────────────────────── const queryFilters = (() => { - const base = { + const advancedBase = { vbox_id: vboxId, mailbox_id: mailboxId, search: debouncedSearch || undefined, + pec_type: pecTypeFilter || undefined, + date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined, + date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined, page, page_size: PAGE_SIZE, } switch (viewMode) { case 'inbox': return { - ...base, + ...advancedBase, direction: 'inbound' as const, is_read: isReadFilter, is_starred: isStarredFilter, @@ -146,7 +169,7 @@ export function InboxPage({ viewMode }: InboxPageProps) { } case 'sent': return { - ...base, + ...advancedBase, direction: 'outbound' as const, is_starred: isStarredFilter, is_archived: false, @@ -154,20 +177,20 @@ export function InboxPage({ viewMode }: InboxPageProps) { } case 'starred': return { - ...base, + ...advancedBase, is_starred: true, is_archived: false, is_trashed: false, } case 'archived': return { - ...base, + ...advancedBase, is_archived: true, is_trashed: false, } case 'trash': return { - ...base, + ...advancedBase, is_trashed: true, } } @@ -499,7 +522,84 @@ export function InboxPage({ viewMode }: InboxPageProps) { Preferiti )} + + {/* Pulsante filtri avanzati */} +
+ + {/* ── Pannello filtri avanzati ── */} + {showAdvancedFilters && ( +
+ {/* Data da */} +
+ + setDateFrom(e.target.value)} + /> +
+ + {/* Data a */} +
+ + setDateTo(e.target.value)} + /> +
+ + {/* Tipo PEC */} +
+ + +
+ + {/* Reset filtri */} + {activeAdvancedFiltersCount > 0 && ( +
+ +
+ )} +
+ )}
{/* ── Barra azioni bulk ── */} diff --git a/frontend/src/pages/Search/SearchPage.tsx b/frontend/src/pages/Search/SearchPage.tsx new file mode 100644 index 0000000..d6d639c --- /dev/null +++ b/frontend/src/pages/Search/SearchPage.tsx @@ -0,0 +1,556 @@ +/** + * SearchPage – ricerca full-text avanzata tra tutti i messaggi PEC. + * + * Funzionalita': + * - Barra di ricerca full-text (usa FTS su search_vector PostgreSQL) + * - Pannello filtri avanzati collassabile: + * - Data da / Data a + * - Direzione (in arrivo / inviata) + * - Stato PEC + * - Tipo PEC + * - Casella specifica + * - Lista risultati ordinata per rilevanza + * - Paginazione + */ +import { useState, useEffect, useCallback } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { + Search, + SlidersHorizontal, + ChevronDown, + ChevronUp, + X, + Inbox, + Send, + MailOpen, + Mail, + ChevronLeft, + ChevronRight, +} from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { PecStateBadge } from '@/components/PecBadge/PecBadge' +import { TagBadgeList } from '@/components/TagManager/TagBadge' +import { messagesApi } from '@/api/messages.api' +import { mailboxesApi } from '@/api/mailboxes.api' +import { formatRelative, truncate } from '@/lib/utils' +import { cn } from '@/lib/utils' +import type { MessageResponse } from '@/types/api.types' + +// ─── Costanti ───────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 50 + +const PEC_STATES = [ + { value: '', label: 'Tutti gli stati' }, + { value: 'received', label: 'Ricevuta' }, + { value: 'sent', label: 'Inviata' }, + { value: 'accepted', label: 'Accettata' }, + { value: 'delivered', label: 'Consegnata' }, + { value: 'anomaly', label: 'Anomalia' }, + { value: 'failed', label: 'Fallita' }, +] + +const PEC_TYPES = [ + { value: '', label: 'Tutti i tipi' }, + { value: 'posta_certificata', label: 'Posta certificata' }, + { value: 'accettazione', label: 'Accettazione' }, + { value: 'avvenuta_consegna', label: 'Avvenuta consegna' }, + { value: 'mancata_consegna', label: 'Mancata consegna' }, + { value: 'non_accettazione', label: 'Non accettazione' }, + { value: 'presa_in_carico', label: 'Presa in carico' }, + { value: 'errore_consegna', label: 'Errore consegna' }, + { value: 'preavviso_mancata_consegna', label: 'Preavviso mancata consegna' }, + { value: 'rilevazione_virus', label: 'Rilevazione virus' }, +] + +const DIRECTIONS = [ + { value: '', label: 'Tutte le direzioni' }, + { value: 'inbound', label: 'In arrivo' }, + { value: 'outbound', label: 'Inviata' }, +] + +// ─── Componente principale ──────────────────────────────────────────────────── + +export function SearchPage() { + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + + // Legge i parametri dall'URL (permette di condividere/bookmark la ricerca) + const [searchInput, setSearchInput] = useState(searchParams.get('q') || '') + const [committedSearch, setCommittedSearch] = useState(searchParams.get('q') || '') + const [showFilters, setShowFilters] = useState(false) + const [page, setPage] = useState(1) + + // Filtri avanzati + const [dateFrom, setDateFrom] = useState(searchParams.get('date_from') || '') + const [dateTo, setDateTo] = useState(searchParams.get('date_to') || '') + const [direction, setDirection] = useState(searchParams.get('direction') || '') + const [state, setState] = useState(searchParams.get('state') || '') + const [pecType, setPecType] = useState(searchParams.get('pec_type') || '') + const [mailboxId, setMailboxId] = useState(searchParams.get('mailbox_id') || '') + + // Caselle disponibili per il filtro + const { data: mailboxesData } = useQuery({ + queryKey: ['mailboxes'], + queryFn: () => mailboxesApi.list(), + staleTime: 5 * 60 * 1000, + }) + const mailboxes = mailboxesData?.items ?? [] + + // Numero di filtri attivi + const activeFiltersCount = [dateFrom, dateTo, direction, state, pecType, mailboxId].filter(Boolean).length + + // Sincronizza l'URL quando cambiano i filtri + useEffect(() => { + const params: Record = {} + if (committedSearch) params.q = committedSearch + if (dateFrom) params.date_from = dateFrom + if (dateTo) params.date_to = dateTo + if (direction) params.direction = direction + if (state) params.state = state + if (pecType) params.pec_type = pecType + if (mailboxId) params.mailbox_id = mailboxId + setSearchParams(params, { replace: true }) + }, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId, setSearchParams]) + + // Reset pagina quando cambiano i filtri + useEffect(() => { + setPage(1) + }, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId]) + + // Costruisce i filtri per l'API + const filters = { + search: committedSearch || undefined, + date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined, + date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined, + direction: direction as 'inbound' | 'outbound' | undefined || undefined, + state: state || undefined, + pec_type: pecType || undefined, + mailbox_id: mailboxId || undefined, + is_archived: undefined as boolean | undefined, + is_trashed: false, + page, + page_size: PAGE_SIZE, + } + + // Query messaggi + const { + data: messagesData, + isLoading, + isFetching, + } = useQuery({ + queryKey: ['search', filters], + queryFn: () => messagesApi.list(filters), + enabled: !!(committedSearch || dateFrom || dateTo || direction || state || pecType || mailboxId), + }) + + const messages = messagesData?.items || [] + const total = messagesData?.total || 0 + const totalPages = Math.ceil(total / PAGE_SIZE) + const hasQuery = !!(committedSearch || activeFiltersCount > 0) + + const handleSearch = useCallback(() => { + setCommittedSearch(searchInput.trim()) + setPage(1) + }, [searchInput]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch() + } + + const handleClearAll = () => { + setSearchInput('') + setCommittedSearch('') + setDateFrom('') + setDateTo('') + setDirection('') + setState('') + setPecType('') + setMailboxId('') + setPage(1) + } + + const handleMessageClick = (message: MessageResponse) => { + navigate(`/messages/${message.id}`) + } + + return ( +
+ {/* ── Header ricerca ── */} +
+
+

+ + Ricerca avanzata +

+

+ Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX +

+
+ + {/* Barra di ricerca principale */} +
+
+ + setSearchInput(e.target.value)} + onKeyDown={handleKeyDown} + /> + {searchInput && ( + + )} +
+ + +
+ + {/* Pannello filtri avanzati */} + {showFilters && ( +
+
+ {/* Data da */} +
+ + setDateFrom(e.target.value)} + /> +
+ + {/* Data a */} +
+ + setDateTo(e.target.value)} + /> +
+ + {/* Direzione */} +
+ + +
+ + {/* Stato PEC */} +
+ + +
+ + {/* Tipo PEC */} +
+ + +
+ + {/* Casella */} + {mailboxes.length > 0 && ( +
+ + +
+ )} +
+ + {/* Pulsante reset filtri */} + {activeFiltersCount > 0 && ( +
+ +
+ )} +
+ )} +
+ + {/* ── Risultati ── */} +
+ {!hasQuery ? ( +
+ +

Inizia a cercare

+

+ Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi +

+
+ ) : isLoading || isFetching ? ( +
+
+
+

Ricerca in corso…

+
+
+ ) : messages.length === 0 ? ( +
+ +

Nessun risultato

+

+ Prova a modificare i termini di ricerca o i filtri +

+ {hasQuery && ( + + )} +
+ ) : ( + <> + {/* Intestazione risultati */} +
+

+ {total}{' '} + {total === 1 ? 'risultato trovato' : 'risultati trovati'} + {committedSearch && ( + <> per "{committedSearch}" + )} +

+ {(isFetching) && ( +
+ )} +
+ + {/* Lista risultati */} +
+ {messages.map((message) => ( + m.id === message.mailbox_id)?.email_address + } + onClick={() => handleMessageClick(message)} + /> + ))} +
+ + )} +
+ + {/* ── Paginazione ── */} + {totalPages > 1 && ( +
+

+ Pagina {page} di {totalPages} ({total} risultati) +

+
+ + +
+
+ )} +
+ ) +} + +// ─── Riga singolo risultato ─────────────────────────────────────────────────── + +interface SearchResultRowProps { + message: MessageResponse + searchTerm: string + mailboxName?: string + onClick: () => void +} + +function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchResultRowProps) { + const isUnread = !message.is_read && message.direction === 'inbound' + + // Evidenzia il termine cercato nel testo + const highlight = (text: string | null | undefined, term: string): React.ReactNode => { + if (!text || !term) return text || '' + const idx = text.toLowerCase().indexOf(term.toLowerCase()) + if (idx === -1) return truncate(text, 150) + const start = Math.max(0, idx - 60) + const end = Math.min(text.length, idx + term.length + 90) + const snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '') + const snipIdx = snippet.toLowerCase().indexOf(term.toLowerCase()) + if (snipIdx === -1) return snippet + return ( + <> + {snippet.slice(0, snipIdx)} + + {snippet.slice(snipIdx, snipIdx + term.length)} + + {snippet.slice(snipIdx + term.length)} + + ) + } + + return ( +
+ {/* Icona direzione */} +
+ {message.direction === 'inbound' ? ( + isUnread ? ( + + ) : ( + + ) + ) : ( + + )} +
+ + {/* Contenuto */} +
+ {/* Riga 1: mittente + badge casella + stato + data */} +
+
+ + {message.direction === 'inbound' + ? (message.from_address || 'Mittente sconosciuto') + : (message.to_addresses?.[0] || 'Destinatario sconosciuto')} + + {mailboxName && ( + + {mailboxName} + + )} +
+
+ + + {formatRelative(message.received_at || message.sent_at || message.created_at)} + +
+
+ + {/* Riga 2: oggetto */} +
+ {isUnread && } +

+ {searchTerm + ? highlight(message.subject || '(nessun oggetto)', searchTerm) + : truncate(message.subject || '(nessun oggetto)', 100)} +

+
+ + {/* Riga 3: snippet corpo */} + {message.body_text && ( +

+ {searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)} +

+ )} + + {/* Tag */} + {message.labels && message.labels.length > 0 && ( +
+ +
+ )} +
+ + {/* Indicatore allegati */} + {message.has_attachments && ( + + )} +
+ ) +} diff --git a/worker/app/imap/sync.py b/worker/app/imap/sync.py index ca21e12..2346f63 100644 --- a/worker/app/imap/sync.py +++ b/worker/app/imap/sync.py @@ -35,6 +35,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings +from app.jobs.index_message import index_message from app.models import Attachment, Mailbox, Message from app.parsers.eml_parser import parse_eml from app.parsers.pec_parser import apply_outbound_transition, classify_pec_message @@ -635,6 +636,12 @@ async def _save_message( f"direction={direction!r} pec_type={pec_class.pec_type!r} " f"subject={message.subject!r} allegati={len(parsed.attachments)}" ) + + # ── Indicizzazione full-text (non bloccante, non interrompe la sync) ───── + # Chiamata dopo il flush degli allegati: index_message puo' leggere + # sia il messaggio che gli allegati dalla sessione corrente. + await index_message(message.id, db) + return True diff --git a/worker/app/jobs/index_message.py b/worker/app/jobs/index_message.py new file mode 100644 index 0000000..9a7ed3c --- /dev/null +++ b/worker/app/jobs/index_message.py @@ -0,0 +1,218 @@ +""" +Indicizzazione full-text dei messaggi PEC. + +Responsabilita': + 1. Scarica gli allegati PDF e DOCX da MinIO + 2. Estrae il testo con pypdf (PDF) e python-docx (DOCX) + 3. Aggiorna la colonna extracted_text in attachments + 4. Aggiorna la colonna search_vector in messages includendo il testo degli allegati + +Viene chiamato alla fine di _save_message in sync.py, in modo non bloccante: +un'eccezione qui non interrompe la sincronizzazione del messaggio. +""" + +import io +import logging +import uuid + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +# Dimensione massima del testo estratto per allegato (caratteri) +MAX_EXTRACTED_TEXT_LEN = 50_000 +# Dimensione massima del testo aggregato degli allegati per il search_vector +MAX_COMBINED_TEXT_LEN = 200_000 + + +# ─── Estrazione testo ───────────────────────────────────────────────────────── + +def _extract_pdf_text(content: bytes) -> str: + """Estrae testo da un PDF usando pypdf.""" + try: + import pypdf # type: ignore[import] + + reader = pypdf.PdfReader(io.BytesIO(content)) + parts: list[str] = [] + for page in reader.pages: + try: + txt = page.extract_text() + if txt: + parts.append(txt) + except Exception: + continue + return " ".join(parts) + except ImportError: + logger.warning("pypdf non installato: impossibile estrarre testo da PDF") + return "" + except Exception as e: + logger.debug(f"Errore estrazione testo PDF: {e}") + return "" + + +def _extract_docx_text(content: bytes) -> str: + """Estrae testo da un DOCX usando python-docx.""" + try: + import docx # type: ignore[import] + + doc = docx.Document(io.BytesIO(content)) + parts = [para.text for para in doc.paragraphs if para.text and para.text.strip()] + return " ".join(parts) + except ImportError: + logger.warning("python-docx non installato: impossibile estrarre testo da DOCX") + return "" + except Exception as e: + logger.debug(f"Errore estrazione testo DOCX: {e}") + return "" + + +def _is_pdf(content_type: str | None, filename: str | None) -> bool: + ct = (content_type or "").lower() + fn = (filename or "").lower() + return ct == "application/pdf" or fn.endswith(".pdf") + + +def _is_docx(content_type: str | None, filename: str | None) -> bool: + ct = (content_type or "").lower() + fn = (filename or "").lower() + return ct in ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + "application/vnd.ms-word", + ) or fn.endswith((".docx", ".doc")) + + +# ─── Job principale ─────────────────────────────────────────────────────────── + +async def index_message( + message_id: uuid.UUID, + db: AsyncSession, +) -> None: + """ + Indicizza un messaggio per la ricerca full-text. + + Non solleva eccezioni: tutti gli errori vengono loggati ma non propagati, + per non interrompere il flusso di sincronizzazione. + """ + try: + await _do_index_message(message_id, db) + except Exception as e: + logger.error( + f"Errore indicizzazione messaggio {message_id}: {e}", + exc_info=True, + ) + + +async def _do_index_message( + message_id: uuid.UUID, + db: AsyncSession, +) -> None: + """Logica interna di indicizzazione (puo' sollevare eccezioni).""" + from app.config import get_settings + from app.models import Attachment, Message + + settings = get_settings() + + # ── Carica il messaggio ─────────────────────────────────────────────────── + msg_result = await db.execute( + select(Message).where(Message.id == message_id) + ) + message = msg_result.scalar_one_or_none() + if not message: + logger.warning(f"index_message: messaggio {message_id} non trovato in DB") + return + + # ── Carica gli allegati ─────────────────────────────────────────────────── + att_result = await db.execute( + select(Attachment).where(Attachment.message_id == message_id) + ) + attachments = list(att_result.scalars().all()) + + if not attachments: + logger.debug(f"Messaggio {message_id}: nessun allegato, skip indicizzazione allegati") + return + + # ── Crea client MinIO ───────────────────────────────────────────────────── + try: + from miniopy_async import Minio # type: ignore[import] + + minio = Minio( + endpoint=settings.minio_endpoint, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_use_ssl, + ) + except Exception as e: + logger.warning(f"Impossibile creare client MinIO per indicizzazione {message_id}: {e}") + return + + bucket = settings.minio_bucket + attachment_texts: list[str] = [] + indexed_count = 0 + + for att in attachments: + # Se gia' indicizzato, usa il testo cached + if att.extracted_text is not None: + attachment_texts.append(att.extracted_text) + continue + + # Controlla se e' un PDF o DOCX + if not (_is_pdf(att.content_type, att.filename) or _is_docx(att.content_type, att.filename)): + continue + + # Scarica da MinIO + try: + response = await minio.get_object(bucket, att.storage_path) + content = await response.content.read() + response.close() + except Exception as e: + logger.warning( + f"Impossibile scaricare allegato {att.id} " + f"({att.filename!r}) da MinIO: {e}" + ) + continue + + # Estrai testo + if _is_pdf(att.content_type, att.filename): + extracted = _extract_pdf_text(content) + else: + extracted = _extract_docx_text(content) + + if not extracted or not extracted.strip(): + continue + + # Limita la dimensione e salva + att.extracted_text = extracted[:MAX_EXTRACTED_TEXT_LEN] + attachment_texts.append(att.extracted_text) + indexed_count += 1 + + # ── Aggiorna search_vector includendo il testo degli allegati ───────────── + if attachment_texts: + combined = " ".join(attachment_texts)[:MAX_COMBINED_TEXT_LEN] + + await db.execute( + text(""" + UPDATE messages + SET search_vector = + setweight(to_tsvector('italian', coalesce(subject, '')), 'A') || + setweight(to_tsvector('simple', coalesce(from_address, '')), 'B') || + setweight(to_tsvector('simple', + coalesce(array_to_string(to_addresses, ' '), '')), 'B') || + setweight(to_tsvector('italian', coalesce(body_text, '')), 'C') || + setweight(to_tsvector('italian', :att_text), 'D') + WHERE id = :message_id + """), + {"att_text": combined, "message_id": str(message_id)}, + ) + + await db.flush() + + logger.info( + f"Indicizzazione completata: messaggio {message_id}, " + f"{indexed_count} allegati indicizzati su {len(attachments)} totali" + ) + else: + logger.debug( + f"Messaggio {message_id}: nessun allegato PDF/DOCX con testo estraibile" + ) diff --git a/worker/pyproject.toml b/worker/pyproject.toml index c31231c..8681ae5 100644 --- a/worker/pyproject.toml +++ b/worker/pyproject.toml @@ -41,6 +41,10 @@ dependencies = [ # Utilities "python-dotenv>=1.0.0", "email-validator>=2.2.0", + + # Full-text search: estrazione testo da allegati PDF e DOCX + "pypdf>=4.0.0", + "python-docx>=1.1.0", ] [project.optional-dependencies] From bb2060c1ae54d1d2551c62e5f7e986f8f09c3e7c Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Fri, 27 Mar 2026 13:54:07 +0100 Subject: [PATCH 05/11] OCR + reportistica --- GapAnalysis.md | 7 - backend/Dockerfile | 4 + backend/app/api/v1/reports.py | 114 ++ backend/app/api/v1/settings.py | 300 ++++- backend/app/main.py | 3 +- backend/app/schemas/reports.py | 75 ++ backend/app/schemas/tenant_settings.py | 55 +- backend/app/services/indexing_service.py | 1226 ++++++++++++++++++ backend/app/services/report_service.py | 594 +++++++++ backend/pyproject.toml | 12 + frontend/node_modules/.package-lock.json | 347 ++++- frontend/package-lock.json | 350 ++++- frontend/package.json | 1 + frontend/src/App.tsx | 4 + frontend/src/api/reports.api.ts | 99 ++ frontend/src/api/settings.api.ts | 136 +- frontend/src/components/Layout/Sidebar.tsx | 21 +- frontend/src/pages/Reports/ReportsPage.tsx | 528 ++++++++ frontend/src/pages/Settings/SettingsPage.tsx | 1087 +++++++++++++--- worker/Dockerfile | 4 + worker/app/imap/sync.py | 14 +- worker/app/jobs/index_message.py | 578 ++++++++- worker/app/models.py | 2 + worker/app/parsers/eml_parser.py | 39 +- worker/pyproject.toml | 11 +- worker/scripts/fix_receipt_body.py | 129 ++ 26 files changed, 5503 insertions(+), 237 deletions(-) create mode 100644 backend/app/api/v1/reports.py create mode 100644 backend/app/schemas/reports.py create mode 100644 backend/app/services/indexing_service.py create mode 100644 backend/app/services/report_service.py create mode 100644 frontend/src/api/reports.api.ts create mode 100644 frontend/src/pages/Reports/ReportsPage.tsx create mode 100644 worker/scripts/fix_receipt_body.py 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 ── */}