diff --git a/backend/alembic/versions/0020_add_conservatore_tenant_slug.py b/backend/alembic/versions/0020_add_conservatore_tenant_slug.py new file mode 100644 index 0000000..96404cc --- /dev/null +++ b/backend/alembic/versions/0020_add_conservatore_tenant_slug.py @@ -0,0 +1,36 @@ +"""add conservatore_tenant_slug to tenant_settings + +Revision ID: 0020 +Revises: 0019 +Create Date: 2026-06-18 + +Aggiunge la colonna conservatore_tenant_slug a tenant_settings, +necessaria per i provider che usano autenticazione multi-tenant JWT +(es. Aeterna di Idra Informatica) dove il login richiede anche +un tenant_slug oltre a email e password. +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "0020" +down_revision = "0019" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "tenant_settings", + sa.Column( + "conservatore_tenant_slug", + sa.Text(), + nullable=True, + comment="Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna)", + ), + ) + + +def downgrade() -> None: + op.drop_column("tenant_settings", "conservatore_tenant_slug") diff --git a/backend/alembic/versions/0021_add_rem_support.py b/backend/alembic/versions/0021_add_rem_support.py new file mode 100644 index 0000000..412fcf2 --- /dev/null +++ b/backend/alembic/versions/0021_add_rem_support.py @@ -0,0 +1,80 @@ +"""add rem support to mailboxes and messages + +Revision ID: 0021 +Revises: 0020 +Create Date: 2026-06-18 + +Aggiunge campi per supporto REM (Registered Electronic Mail europea, ETSI EN 319 532-4): + - mailboxes.protocol_type VARCHAR(10) DEFAULT 'pec_it' + Indica il protocollo della casella: 'pec_it' (PEC italiana) o 'rem_eu' (REM europea) + - mailboxes.rem_provider VARCHAR(100) nullable + Nome del provider REM europeo (es. 'docutel', 'anodet', 'de-mail') + - messages.protocol_type VARCHAR(10) DEFAULT 'pec_it' + Protocollo del messaggio, copiato dalla casella al momento della ricezione + - messages.rem_evidence_type VARCHAR(100) nullable + Tipo evidenza REM grezzo (es. 'SubmissionAcceptance', 'DeliveryInformation') + Valorizzato solo per messaggi REM; None per PEC italiane. +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "0021" +down_revision = "0020" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── Tabella mailboxes ───────────────────────────────────────────────────── + op.add_column( + "mailboxes", + sa.Column( + "protocol_type", + sa.String(10), + nullable=False, + server_default="pec_it", + comment="Tipo protocollo: pec_it (PEC italiana) | rem_eu (REM europea ETSI EN 319 532-4)", + ), + ) + op.add_column( + "mailboxes", + sa.Column( + "rem_provider", + sa.String(100), + nullable=True, + comment="Nome provider REM europeo (es. docutel, anodet, de-mail)", + ), + ) + + # ── Tabella messages ────────────────────────────────────────────────────── + op.add_column( + "messages", + sa.Column( + "protocol_type", + sa.String(10), + nullable=False, + server_default="pec_it", + comment="Tipo protocollo del messaggio: pec_it | rem_eu", + ), + ) + op.add_column( + "messages", + sa.Column( + "rem_evidence_type", + sa.String(100), + nullable=True, + comment=( + "Tipo evidenza REM grezzo (es. SubmissionAcceptance, DeliveryInformation). " + "Valorizzato solo per messaggi REM (protocol_type = 'rem_eu')." + ), + ), + ) + + +def downgrade() -> None: + op.drop_column("mailboxes", "rem_provider") + op.drop_column("mailboxes", "protocol_type") + op.drop_column("messages", "rem_evidence_type") + op.drop_column("messages", "protocol_type") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index b3501ba..506da0f 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -15,6 +15,7 @@ Solo admin e super_admin possono accedere. Il super_admin puo' operare su qualsiasi tenant tramite query param ?tenant_id=. """ +import time import uuid from typing import Annotated, Optional @@ -23,6 +24,7 @@ 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 ( + ConservatoreTestResult, IndexingJobStatus, IndexingStats, StartReindexRequest, @@ -59,15 +61,21 @@ def _resolve_tenant_id( summary="Legge le impostazioni del tenant", description=( "Restituisce la configurazione operativa del tenant: " - "modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore." + "modalita' archiviazione (mock/produzione), endpoint e stato credenziali conservatore. " + "Il super_admin puo' specificare ?tenant_id= per leggere le impostazioni di un tenant arbitrario." ), ) async def get_settings( current_user: AdminUser, db: DB, + tenant_id: Optional[uuid.UUID] = Query( + default=None, + description="(solo super_admin) UUID del tenant su cui operare", + ), ) -> TenantSettingsResponse: + target_tenant_id = _resolve_tenant_id(current_user, tenant_id) service = TenantSettingsService(db) - settings = await service.get_or_create(current_user.tenant_id) + settings = await service.get_or_create(target_tenant_id) return TenantSettingsService.to_response(settings) @@ -78,21 +86,176 @@ async def get_settings( description=( "Aggiorna la configurazione operativa del tenant. " "Tutti i campi sono opzionali (semantica PATCH). " - "Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato." + "Il passaggio a modalita' 'production' richiede un endpoint conservatore configurato. " + "Il super_admin puo' specificare ?tenant_id= per aggiornare le impostazioni di un tenant arbitrario." ), ) async def update_settings( body: TenantSettingsUpdate, current_user: AdminUser, db: DB, + tenant_id: Optional[uuid.UUID] = Query( + default=None, + description="(solo super_admin) UUID del tenant su cui operare", + ), ) -> TenantSettingsResponse: + target_tenant_id = _resolve_tenant_id(current_user, tenant_id) service = TenantSettingsService(db) - settings = await service.update(current_user.tenant_id, body) + settings = await service.update(target_tenant_id, body) await db.commit() await db.refresh(settings) return TenantSettingsService.to_response(settings) +# ─── Test connessione conservatore ─────────────────────────────────────────── + +@router.post( + "/test-conservatore", + response_model=ConservatoreTestResult, + summary="Testa la connessione al conservatore configurato", + description=( + "Verifica che le credenziali salvate per il conservatore siano valide " + "effettuando una chiamata di autenticazione reale. " + "Supporta Aeterna (JWT) e conservatori generici (HTTP Basic). " + "Non modifica alcun dato, non invia pacchetti." + ), +) +async def test_conservatore_connection( + current_user: AdminUser, + db: DB, + tenant_id: Optional[uuid.UUID] = Query( + default=None, + description="(solo super_admin) UUID del tenant su cui operare", + ), +) -> ConservatoreTestResult: + target_tenant_id = _resolve_tenant_id(current_user, tenant_id) + service = TenantSettingsService(db) + creds = await service.get_conservatore_credentials(target_tenant_id) + + if creds.get("mode") != "production": + return ConservatoreTestResult( + success=False, + message="La modalita' di archiviazione e' impostata su 'mock'. " + "Configura l'endpoint e le credenziali, poi imposta la modalita' su 'produzione'.", + ) + + endpoint = creds.get("endpoint") + username = creds.get("username") + password = creds.get("password") + tenant_slug = creds.get("tenant_slug") + conservatore_id = creds.get("conservatore_id", "") + + if not endpoint or not username or not password: + return ConservatoreTestResult( + success=False, + message="Credenziali o endpoint non configurati. " + "Compila tutti i campi obbligatori prima di testare la connessione.", + ) + + # Rileva Aeterna da conservatore_id o URL + is_aeterna = ( + (conservatore_id or "").lower() == "aeterna" + or "aeterna" in (endpoint or "").lower() + or "idrainformatica" in (endpoint or "").lower() + ) + + t_start = time.monotonic() + + if is_aeterna: + # Test Aeterna: POST /api/v1/auth/login + GET /api/v1/auth/me + if not tenant_slug: + return ConservatoreTestResult( + success=False, + message="Provider Aeterna richiede il campo 'Tenant Slug'. Configuralo nelle impostazioni.", + ) + try: + import httpx + async with httpx.AsyncClient(timeout=15) as client: + resp_login = await client.post( + f"{endpoint.rstrip('/')}/api/v1/auth/login", + json={ + "email": username, + "password": password, + "tenant_slug": tenant_slug, + }, + ) + + latency_ms = int((time.monotonic() - t_start) * 1000) + + if resp_login.status_code != 200: + return ConservatoreTestResult( + success=False, + message=f"Login Aeterna fallito (HTTP {resp_login.status_code}): {resp_login.text[:200]}", + latency_ms=latency_ms, + ) + + login_data = resp_login.json() + token = login_data.get("access_token") + + async with httpx.AsyncClient(timeout=10) as client: + resp_me = await client.get( + f"{endpoint.rstrip('/')}/api/v1/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + + latency_ms = int((time.monotonic() - t_start) * 1000) + me = resp_me.json() if resp_me.status_code == 200 else {} + + return ConservatoreTestResult( + success=resp_me.status_code == 200, + message=( + f"Connessione ad Aeterna riuscita (utente: {me.get('email', '?')})" + if resp_me.status_code == 200 + else f"Login riuscito ma /me ha risposto HTTP {resp_me.status_code}" + ), + latency_ms=latency_ms, + provider_info={ + "platform": "Aeterna", + "tenant_slug": tenant_slug, + "user_email": me.get("email"), + "permissions_count": len(me.get("permissions", [])), + } if me else None, + ) + except Exception as e: + return ConservatoreTestResult( + success=False, + message=f"Errore connessione ad Aeterna: {e}", + latency_ms=int((time.monotonic() - t_start) * 1000), + ) + + else: + # Test generico: HTTP Basic HEAD o GET sull'endpoint + import base64 + import httpx + auth_str = base64.b64encode(f"{username}:{password}".encode()).decode() + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get( + endpoint, + headers={"Authorization": f"Basic {auth_str}"}, + ) + latency_ms = int((time.monotonic() - t_start) * 1000) + + if response.status_code < 500: + return ConservatoreTestResult( + success=True, + message=f"Endpoint raggiungibile (HTTP {response.status_code})", + latency_ms=latency_ms, + ) + else: + return ConservatoreTestResult( + success=False, + message=f"Endpoint ha risposto con errore HTTP {response.status_code}", + latency_ms=latency_ms, + ) + except Exception as e: + return ConservatoreTestResult( + success=False, + message=f"Errore connessione: {e}", + latency_ms=int((time.monotonic() - t_start) * 1000), + ) + + # ─── Indicizzazione full-text ───────────────────────────────────────────────── @router.get( diff --git a/backend/app/models/mailbox.py b/backend/app/models/mailbox.py index e05cf32..23f4c54 100644 --- a/backend/app/models/mailbox.py +++ b/backend/app/models/mailbox.py @@ -65,6 +65,12 @@ class Mailbox(Base): sync_error_msg: Mapped[str | None] = mapped_column(Text, nullable=True) sync_error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Protocollo (Feature N8 – REM europea) + # 'pec_it' → PEC italiana (default) + # 'rem_eu' → REM europea (ETSI EN 319 532-4) + protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it") + rem_provider: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id"), nullable=True ) diff --git a/backend/app/models/message.py b/backend/app/models/message.py index 0693e76..39f9ee1 100644 --- a/backend/app/models/message.py +++ b/backend/app/models/message.py @@ -115,6 +115,12 @@ class Message(Base): risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True) confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, nullable=True) + # Protocollo e REM europea (Feature N8) + # protocol_type: 'pec_it' (default) o 'rem_eu' + # rem_evidence_type: valore grezzo dell'header X-REM-Evidence-Type + protocol_type: Mapped[str] = mapped_column(String(10), nullable=False, default="pec_it") + rem_evidence_type: Mapped[str | None] = mapped_column(String(100), nullable=True) + raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) # Full-text search vector (aggiornato da trigger DB + worker per allegati) diff --git a/backend/app/models/tenant_settings.py b/backend/app/models/tenant_settings.py index acf15bf..55dfef7 100644 --- a/backend/app/models/tenant_settings.py +++ b/backend/app/models/tenant_settings.py @@ -51,6 +51,9 @@ class TenantSettings(Base): conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True) conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True) + # Slug del tenant sul sistema del conservatore (es. 'pechub' per Aeterna) + conservatore_tenant_slug: Mapped[str | None] = mapped_column(Text, nullable=True) + # Note operative opzionali archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/schemas/mailbox.py b/backend/app/schemas/mailbox.py index a4d1400..4f2e661 100644 --- a/backend/app/schemas/mailbox.py +++ b/backend/app/schemas/mailbox.py @@ -19,6 +19,17 @@ class MailboxCreateRequest(BaseModel): display_name: str | None = Field(None, max_length=255, description="Nome visualizzato") provider: str | None = Field(None, max_length=100, description="Provider PEC (aruba, namirial...)") + # Protocollo (Feature N8 – REM europea) + protocol_type: Literal["pec_it", "rem_eu"] = Field( + "pec_it", + description="Tipo protocollo: pec_it (PEC italiana, default) | rem_eu (REM europea ETSI EN 319 532-4)", + ) + rem_provider: str | None = Field( + None, + max_length=100, + description="Nome provider REM europeo (es. docutel, anodet, de-mail). Solo per rem_eu.", + ) + # Credenziali IMAP (in chiaro, cifrate prima della persistenza) imap_host: str = Field(..., min_length=1, max_length=255, description="Host IMAP") imap_port: int = Field(993, ge=1, le=65535, description="Porta IMAP") @@ -48,6 +59,10 @@ class MailboxUpdateRequest(BaseModel): provider: str | None = Field(None, max_length=100) status: Literal["active", "paused"] | None = None + # Aggiornamento protocollo (Feature N8) + protocol_type: Literal["pec_it", "rem_eu"] | None = None + rem_provider: str | None = Field(None, max_length=100) + # Aggiornamento credenziali IMAP (opzionale) imap_host: str | None = Field(None, min_length=1, max_length=255) imap_port: int | None = Field(None, ge=1, le=65535) @@ -84,6 +99,10 @@ class MailboxResponse(BaseModel): smtp_port: int smtp_use_tls: bool + # Protocollo (Feature N8) + protocol_type: str + rem_provider: str | None + # Stato sync status: str last_sync_at: datetime | None diff --git a/backend/app/schemas/tenant_settings.py b/backend/app/schemas/tenant_settings.py index 0556c9f..aa5fdd7 100644 --- a/backend/app/schemas/tenant_settings.py +++ b/backend/app/schemas/tenant_settings.py @@ -28,6 +28,7 @@ class TenantSettingsResponse(BaseModel): archival_mode: ArchivalMode conservatore_id: str conservatore_endpoint: str | None + conservatore_tenant_slug: str | None conservatore_username_configured: bool # TRUE se la username è già salvata conservatore_password_configured: bool # TRUE se la password è già salvata archival_notes: str | None @@ -56,6 +57,9 @@ class TenantSettingsUpdate(BaseModel): # URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock) conservatore_endpoint: str | None = None + # Slug tenant sul sistema del conservatore (es. 'pechub' per Aeterna) + conservatore_tenant_slug: str | None = None + # Credenziali in chiaro: vengono cifrate prima del salvataggio. # Valore stringa vuota ("") = cancella la credenziale. conservatore_username: str | None = None @@ -103,6 +107,14 @@ class IndexingJobStatus(BaseModel): error: Optional[str] = None +class ConservatoreTestResult(BaseModel): + """Risposta POST /settings/test-conservatore.""" + success: bool + message: str + latency_ms: int | None = None + provider_info: dict | None = None + + class StartReindexRequest(BaseModel): """Body per POST /settings/indexing/reindex.""" diff --git a/backend/app/services/tenant_settings_service.py b/backend/app/services/tenant_settings_service.py index f7c881c..da73fda 100644 --- a/backend/app/services/tenant_settings_service.py +++ b/backend/app/services/tenant_settings_service.py @@ -73,6 +73,9 @@ class TenantSettingsService: if data.conservatore_endpoint is not None: settings.conservatore_endpoint = data.conservatore_endpoint or None + if data.conservatore_tenant_slug is not None: + settings.conservatore_tenant_slug = data.conservatore_tenant_slug or None + if data.archival_notes is not None: settings.archival_notes = data.archival_notes or None @@ -118,6 +121,7 @@ class TenantSettingsService: archival_mode=settings.archival_mode, # type: ignore[arg-type] conservatore_id=settings.conservatore_id, conservatore_endpoint=settings.conservatore_endpoint, + conservatore_tenant_slug=settings.conservatore_tenant_slug, conservatore_username_configured=settings.conservatore_username_enc is not None, conservatore_password_configured=settings.conservatore_password_enc is not None, archival_notes=settings.archival_notes, @@ -139,6 +143,7 @@ class TenantSettingsService: "mode": settings.archival_mode, "conservatore_id": settings.conservatore_id, "endpoint": settings.conservatore_endpoint, + "tenant_slug": settings.conservatore_tenant_slug, "username": ( decrypt_credential(settings.conservatore_username_enc) if settings.conservatore_username_enc diff --git a/docs/aeterna-api.md b/docs/aeterna-api.md new file mode 100644 index 0000000..511413f --- /dev/null +++ b/docs/aeterna-api.md @@ -0,0 +1,538 @@ +# Integrazione Aeterna – Archiviazione Sostitutiva PecHub + +Documento tecnico sull'integrazione di PecHub con Aeterna, +piattaforma di conservazione digitale conforme E-ARK gestita da Idra Informatica. + +--- + +## Indice + +1. [Panoramica del provider](#1-panoramica-del-provider) +2. [Architettura Aeterna](#2-architettura-aeterna) +3. [Autenticazione JWT](#3-autenticazione-jwt) +4. [Ingest – Upload SIP](#4-ingest--upload-sip) +5. [Formato SIP BagIt (RFC 8493)](#5-formato-sip-bagit-rfc-8493) +6. [Polling stato ingest](#6-polling-stato-ingest) +7. [Disseminazione (DIP)](#7-disseminazione-dip) +8. [Mapping stati Aeterna → PecHub](#8-mapping-stati-aeterna--pechub) +9. [Configurazione in PecHub](#9-configurazione-in-pechub) +10. [Esempi curl completi](#10-esempi-curl-completi) +11. [Note operative](#11-note-operative) + +--- + +## 1. Panoramica del provider + +| Campo | Valore | +|-------------------|-----------------------------------------------| +| Provider | Idra Informatica srl | +| Piattaforma | Aeterna v0.1.0 | +| URL applicazione | https://aeterna.idrainformatica.it | +| Endpoint API | https://api.aeterna.idrainformatica.it | +| Documentazione | https://api.aeterna.idrainformatica.it/docs | +| OpenAPI JSON | https://api.aeterna.idrainformatica.it/openapi.json | +| Standard | E-ARK CSIP 2.1.0, BagIt RFC 8493, PREMIS 3.0, METS 1.12 | + +### Credenziali tenant PecHub + +| Campo | Valore | +|-------------|---------------------------------| +| Org. name | pechub | +| Tenant slug | pechub | +| Username | matteo@idrainformatica.it | +| Password | (cifrata nel DB PecHub) | +| Tenant ID | 366d3d51-b25d-46fc-8f9c-9bc28d902620 | + +--- + +## 2. Architettura Aeterna + +Aeterna e' una piattaforma multi-tenant FastAPI (Python). +Ogni organizzazione (tenant) ha: +- Container MinIO dedicato per lo storage isolato +- Collection Apache Solr dedicata per la ricerca full-text +- RBAC (Role-Based Access Control) per-tenant + +Il ciclo di vita di un documento archiviale in Aeterna: + +``` +SIP (upload) → AIP (ingest pipeline) → DIP (disseminazione) + | | | + BagIt ZIP PREMIS events ZIP scaricabile + multipart METS 1.12 generato + form-data validazione E-ARK +``` + +--- + +## 3. Autenticazione JWT + +Aeterna usa JWT Bearer token, NON HTTP Basic. +Il token ha durata 3600 secondi (1 ora). Esiste un refresh token. + +### Login + +``` +POST /api/v1/auth/login +Content-Type: application/json +``` + +Body: +```json +{ + "email": "matteo@idrainformatica.it", + "password": "...", + "tenant_slug": "pechub" +} +``` + +Risposta 200: +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "bearer", + "expires_in": 3600, + "user": { + "id": "e3cac60b-d942-4590-94fe-932c0e14e836", + "email": "matteo@idrainformatica.it", + "full_name": "Matteo Giustini", + "is_platform_admin": false, + "tenant_id": "366d3d51-b25d-46fc-8f9c-9bc28d902620", + "permissions": [ + "ingest:submit", "ingest:manage", "packages:read", + "packages:create", "dissemination:read", "dissemination:download", + "preservation:manage", "audit:read", "settings:manage", ... + ] + } +} +``` + +Utilizzo del token nelle richieste successive: +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIs... +``` + +### Refresh token + +``` +POST /api/v1/auth/refresh +Content-Type: application/json + +{"refresh_token": "eyJhbGciOiJIUzI1NiIs..."} +``` + +### Verifica identita' + +``` +GET /api/v1/auth/me +Authorization: Bearer +``` + +--- + +## 4. Ingest – Upload SIP + +L'endpoint di ingest accetta pacchetti SIP in due formati: +- **E-ARK CSIP v2.2.0** (ZIP con METS.xml in root) +- **BagIt RFC 8493** (ZIP con bagit.txt + data/) — **formato scelto per PecHub** + +### Endpoint + +``` +POST /api/v1/ingest/upload +Authorization: Bearer +Content-Type: multipart/form-data +``` + +### Campi form-data + +| Campo | Tipo | Obbligatorio | Descrizione | +|---------------------|--------|:------------:|--------------------------------------| +| `file` | file | si | ZIP SIP (E-ARK CSIP o BagIt) | +| `title` | string | si | Titolo del pacchetto | +| `description` | string | no | Descrizione | +| `creator` | string | no | Nome del produttore | +| `submission_agreement` | string | no | ID o URL dell'accordo di versamento | +| `ead3_file` | file | no | EAD3 finding aid XML (GAP-09) | +| `eac_cpf_file` | file | no | EAC-CPF authority record XML | + +### Risposta 202 Accepted + +```json +{ + "package_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "pid": "urn:pechub:aip:a1b2c3d4", + "status": "UPLOADED", + "task_id": "celery-task-uuid", + "message": "SIP uploaded successfully. Processing started.", + "submitted_at": "2026-06-18T09:00:00Z" +} +``` + +Il `package_id` e' l'identificatore da usare per polling e DIP. + +--- + +## 5. Formato SIP BagIt (RFC 8493) + +PecHub costruisce pacchetti BagIt in memoria (`build_bagit_sip` in `conservatore_client.py`). + +### Struttura ZIP generata + +``` +pechub-pec-{message_id}/ + bagit.txt # Dichiarazione BagIt (obbligatorio) + bag-info.txt # Metadati descrittivi (opzionale) + manifest-sha256.txt # Checksum SHA-256 dei file in data/ + data/ + {message_id}.eml # Messaggio PEC grezzo +``` + +### Contenuto bagit.txt + +``` +BagIt-Version: 1.0 +Tag-File-Character-Encoding: UTF-8 +``` + +### Contenuto bag-info.txt (esempio) + +``` +Bag-Software-Agent: PecHub Archival Module +Bagging-Date: 2026-06-18 +External-Identifier: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +Source-Organization: PecHub +Description: PEC oggetto del messaggio (max 500 char) +Contact-Email: mittente@pec.it +External-Description: PEC a destinatario@pec.it +Bag-Group-Identifier: 2026-06-18 +``` + +### Contenuto manifest-sha256.txt + +``` +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 data/a1b2c3d4-e5f6.eml +``` + +### Rilevamento automatico BagIt da Aeterna + +Aeterna rileva automaticamente il formato BagIt dalla presenza di `bagit.txt` +nella root del bag all'interno dello ZIP. Non e' necessario specificare il formato. +La pipeline verifica i checksum del manifest prima dell'ingest. + +--- + +## 6. Polling stato ingest + +Dopo l'upload, la pipeline di Aeterna processa il pacchetto in modo asincrono. + +### Endpoint status + +``` +GET /api/v1/ingest/{package_id}/status +Authorization: Bearer +``` + +### Risposta + +```json +{ + "package_id": "a1b2c3d4-...", + "pid": "urn:pechub:aip:a1b2c3d4", + "status": "PROCESSING", + "task_id": "celery-task-uuid", + "pipeline_stage": "format_identification", + "progress_pct": 40, + "steps": [ + {"name": "validation", "status": "completed", ...}, + {"name": "format_identification", "status": "running", ...}, + {"name": "virus_scan", "status": "pending", ...} + ], + "is_valid": null, + "validation_errors": 0, + "error_message": null, + "submitted_at": "2026-06-18T09:00:00Z", + "completed_at": null +} +``` + +### Stati possibili + +| Status Aeterna | Significato | Stato PecHub | +|----------------|------------------------------------------|--------------| +| `UPLOADED` | SIP ricevuto, elaborazione non iniziata | `uploading` | +| `VALIDATING` | Validazione E-ARK/BagIt in corso | `uploading` | +| `PROCESSING` | Pipeline ingest in corso | `uploading` | +| `INGESTING` | AIP in fase di creazione | `uploading` | +| `ACTIVE` | AIP attivo, conservazione completata | `confirmed` | +| `FAILED` | Pipeline fallita con errori | `failed` | +| `REJECTED` | Pacchetto non conforme agli standard | `rejected` | +| `DELETED` | Soft-delete | `rejected` | + +### Report completo + +``` +GET /api/v1/ingest/{package_id}/report +Authorization: Bearer +``` + +--- + +## 7. Disseminazione (DIP) + +### Richiesta generazione DIP + +``` +POST /api/v1/packages/{package_id}/disseminate +Authorization: Bearer +Content-Type: application/json + +{"note": "Richiesto da PecHub Archival Module"} +``` + +Risposta 202: +```json +{ + "id": "dip-uuid", + "pid": "urn:pechub:dip:...", + "status": "PROCESSING", + "size_bytes": null, + "is_available": false +} +``` + +### Verifica stato DIP + +``` +GET /api/v1/packages/{package_id}/dip +Authorization: Bearer +``` + +Quando `is_available: true`, il DIP e' scaricabile. + +### Download DIP + +``` +GET /api/v1/packages/{package_id}/dip/download +Authorization: Bearer +``` + +Risposta: stream ZIP del DIP. + +--- + +## 8. Mapping stati Aeterna → PecHub + +```python +_STATUS_MAP = { + "UPLOADED": "uploading", + "VALIDATING": "uploading", + "PROCESSING": "uploading", + "INGESTING": "uploading", + "ACTIVE": "confirmed", # conservazione completata + "FAILED": "failed", + "REJECTED": "rejected", + "DELETED": "rejected", +} +``` + +Il `versamento_id` in PecHub corrisponde al `package_id` di Aeterna (UUID v4). + +--- + +## 9. Configurazione in PecHub + +### Impostazioni tenant + +Dalla pagina **Impostazioni → Archiviazione Sostitutiva**: + +| Campo | Valore per Aeterna | +|-------------------------|---------------------------------------------| +| Modalita' | Produzione | +| Identificativo conservatore | `aeterna` | +| URL endpoint API | `https://api.aeterna.idrainformatica.it` | +| Tenant Slug | `pechub` | +| Username | `matteo@idrainformatica.it` | +| Password | (da fornire, viene cifrata AES-256-GCM) | + +### Riconoscimento automatico del provider + +Il factory `ConservatoreClient.from_tenant_credentials()` rileva Aeterna se: +- `conservatore_id == "aeterna"` (case-insensitive), OPPURE +- `"aeterna"` e' presente nell'URL endpoint, OPPURE +- `"idrainformatica"` e' presente nell'URL endpoint + +Se rilevato come Aeterna, usa `AeternaConservatoreClient` (JWT + BagIt). +Altrimenti usa `ProductionConservatoreClient` (HTTP Basic, standard AgID legacy). + +### Test connessione + +Dopo aver configurato e salvato le impostazioni, usare il pulsante +**"Testa connessione"** in Impostazioni → Archiviazione Sostitutiva. + +Oppure via API: +``` +POST /api/v1/settings/test-conservatore +Authorization: Bearer +``` + +Risposta: +```json +{ + "success": true, + "message": "Connessione ad Aeterna riuscita (utente: matteo@idrainformatica.it)", + "latency_ms": 342, + "provider_info": { + "platform": "Aeterna", + "tenant_slug": "pechub", + "user_email": "matteo@idrainformatica.it", + "permissions_count": 20 + } +} +``` + +--- + +## 10. Esempi curl completi + +### Login + +```bash +TOKEN=$(curl -s -X POST https://api.aeterna.idrainformatica.it/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"matteo@idrainformatica.it","password":"...","tenant_slug":"pechub"}' \ + | jq -r .access_token) +echo "Token: $TOKEN" +``` + +### Upload SIP BagIt + +```bash +# Crea un BagIt minimo di test +mkdir -p /tmp/testbag/data +echo "Hello PEC" > /tmp/testbag/data/test.eml +SHA=$(sha256sum /tmp/testbag/data/test.eml | awk '{print $1}') + +cat > /tmp/testbag/bagit.txt < /tmp/testbag/manifest-sha256.txt < | null +} + // ─── Tipi indicizzazione full-text ───────────────────────────────────────── export interface IndexingStats { @@ -76,20 +87,35 @@ export interface IndexingJobStatus { export const settingsApi = { /** - * Recupera le impostazioni del tenant corrente. + * Recupera le impostazioni del tenant. * Se non esistono, il backend le crea con i valori di default (mock). + * @param tenantId - (solo super_admin) UUID del tenant target */ - get: async (): Promise => { - const { data } = await apiClient.get('/settings') + get: async (tenantId?: string): Promise => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.get('/settings', { params }) return data }, /** * Aggiorna le impostazioni del tenant. * Solo i campi forniti vengono modificati (semantica PATCH). + * @param tenantId - (solo super_admin) UUID del tenant target */ - update: async (payload: TenantSettingsUpdate): Promise => { - const { data } = await apiClient.put('/settings', payload) + update: async (payload: TenantSettingsUpdate, tenantId?: string): Promise => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.put('/settings', payload, { params }) + return data + }, + + /** + * Testa la connessione al conservatore configurato. + * Effettua una chiamata reale (login + verifica identità) senza modificare dati. + * @param tenantId - (solo super_admin) UUID del tenant target + */ + testConservatore: async (tenantId?: string): Promise => { + const params = tenantId ? { tenant_id: tenantId } : undefined + const { data } = await apiClient.post('/settings/test-conservatore', undefined, { params }) return data }, diff --git a/frontend/src/pages/Mailboxes/MailboxesPage.tsx b/frontend/src/pages/Mailboxes/MailboxesPage.tsx index 955ff4a..a477362 100644 --- a/frontend/src/pages/Mailboxes/MailboxesPage.tsx +++ b/frontend/src/pages/Mailboxes/MailboxesPage.tsx @@ -10,9 +10,10 @@ import { CheckCircle, Clock, RefreshCw, + Globe, } from 'lucide-react' import toast from 'react-hot-toast' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' @@ -174,6 +175,14 @@ export function MailboxesPage() { {mailbox.provider} )} + {/* Badge REM europea (Feature N8) */} + {mailbox.protocol_type === 'rem_eu' && ( + + + REM EU + {mailbox.rem_provider && ` · ${mailbox.rem_provider}`} + + )}

{mailbox.email_address}

@@ -278,6 +287,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo register, handleSubmit, reset, + control, formState: { errors }, } = useForm({ defaultValues: editingMailbox @@ -285,6 +295,8 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo email_address: editingMailbox.email_address, display_name: editingMailbox.display_name || '', provider: editingMailbox.provider || '', + protocol_type: editingMailbox.protocol_type || 'pec_it', + rem_provider: editingMailbox.rem_provider || '', imap_host: editingMailbox.imap_host, imap_port: editingMailbox.imap_port, imap_user: editingMailbox.email_address, @@ -295,6 +307,7 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo smtp_use_tls: editingMailbox.smtp_use_tls, } : { + protocol_type: 'pec_it', imap_port: 993, smtp_port: 465, imap_use_ssl: true, @@ -302,6 +315,10 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo }, }) + // Osserva il campo protocol_type per mostrare/nascondere rem_provider + const selectedProtocol = useWatch({ control, name: 'protocol_type' }) + const isRemEu = selectedProtocol === 'rem_eu' + const createMutation = useMutation({ mutationFn: mailboxesApi.create, onSuccess: () => { @@ -363,6 +380,41 @@ function MailboxFormDialog({ open, onClose, editingMailbox, onSaved }: MailboxFo
+ {/* Protocollo (Feature N8 – REM europea) */} +
+

+ + Protocollo +

+
+
+ + +
+ {isRemEu && ( +
+ + +
+ )} +
+ {isRemEu && ( +

+ Casella configurata per REM europea (ETSI EN 319 532-4). I messaggi in arrivo + con header X-REM-* verranno classificati automaticamente con il parser REM. +

+ )} +
+ {/* Separatore IMAP */}

Configurazione IMAP (ricezione)

diff --git a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx index 3896b99..9a68263 100644 --- a/frontend/src/pages/MessageDetail/MessageDetailPage.tsx +++ b/frontend/src/pages/MessageDetail/MessageDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { @@ -309,8 +309,22 @@ function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messa queryFn: () => labelsApi.list(), }) - // Le label tassonomiche del messaggio sono quelle con parent_id != null - const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null) + // Le label tassonomiche del messaggio: nodi figli (parent_id != null) PIU' i nodi + // radice che hanno almeno un figlio nell'albero (identificati come parent_id di + // qualche altra label). Questo permette di visualizzare anche gli Ambiti (livello 0) + // quando sono assegnati direttamente a un messaggio. + const taxonomyIdSet = useMemo(() => { + const ids = new Set() + allLabels.forEach((l: LabelResponse) => { + if (l.parent_id !== null) { + ids.add(l.id) // nodo figlio -> tassonomico + ids.add(l.parent_id) // suo genitore radice -> anch'esso tassonomico + } + }) + return ids + }, [allLabels]) + + const taxonomyLabels = messageLabels.filter((l) => taxonomyIdSet.has(l.id)) // Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione" function buildPath(labelId: string): string { diff --git a/frontend/src/pages/Settings/SettingsPage.tsx b/frontend/src/pages/Settings/SettingsPage.tsx index 150972b..1a6e86f 100644 --- a/frontend/src/pages/Settings/SettingsPage.tsx +++ b/frontend/src/pages/Settings/SettingsPage.tsx @@ -37,10 +37,13 @@ import { import { useAuth } from '@/hooks/useAuth' import { useAuthStore } from '@/store/auth.store' import { usersApi } from '@/api/users.api' +import { tenantsApi } from '@/api/tenants.api' +import type { TenantResponse } from '@/types/api.types' import { settingsApi, type TenantSettingsResponse, type ArchivalMode, + type ConservatoreTestResult, type IndexingStats, type IndexingJobStatus, type ReindexMode, @@ -973,32 +976,53 @@ export function SettingsPage() { const [loadingArchival, setLoadingArchival] = useState(false) const [archivalExpanded, setArchivalExpanded] = useState(false) + // Selector tenant per super_admin + const [tenantList, setTenantList] = useState([]) + const [selectedTenantId, setSelectedTenantId] = useState(undefined) + // Form archiviazione const [archivalMode, setArchivalMode] = useState('mock') const [conservatoreId, setConservatoreId] = useState('') const [conservatoreEndpoint, setConservatoreEndpoint] = useState('') + const [conservatoreTenantSlug, setConservatoreTenantSlug] = useState('') const [conservatoreUsername, setConservatoreUsername] = useState('') const [conservatorePassword, setConservatorePassword] = useState('') const [archivalNotes, setArchivalNotes] = useState('') const [showPassword, setShowPassword] = useState(false) const [savingArchival, setSavingArchival] = useState(false) const [showProductionConfirm, setShowProductionConfirm] = useState(false) + const [testingConnection, setTestingConnection] = useState(false) + const [testResult, setTestResult] = useState(null) + + /* ── Carica lista tenant (solo super_admin) ── */ + useEffect(() => { + if (isSuperAdmin) { + tenantsApi.list().then(setTenantList).catch(() => { + // Silenzioso: la lista e' un extra di comodita' + }) + } + }, [isSuperAdmin]) /* ── Carica impostazioni archiviazione ── */ useEffect(() => { if (isAdmin) { - loadArchivalSettings() + loadArchivalSettings(selectedTenantId) } - }, [isAdmin]) + }, [isAdmin, selectedTenantId]) - const loadArchivalSettings = async () => { + const loadArchivalSettings = async (tenantId?: string) => { setLoadingArchival(true) + // Resetta credenziali ogni volta che si cambia tenant + setConservatoreUsername('') + setConservatorePassword('') + setTestResult(null) try { - const data = await settingsApi.get() + const data = await settingsApi.get(tenantId) setArchivalSettings(data) setArchivalMode(data.archival_mode) setConservatoreId(data.conservatore_id) setConservatoreEndpoint(data.conservatore_endpoint ?? '') + setConservatoreTenantSlug(data.conservatore_tenant_slug ?? '') setArchivalNotes(data.archival_notes ?? '') } catch { toast.error('Errore durante il caricamento delle impostazioni di archiviazione') @@ -1050,6 +1074,27 @@ export function SettingsPage() { } } + /* ── Test connessione conservatore ── */ + const handleTestConnection = async () => { + setTestingConnection(true) + setTestResult(null) + try { + const result = await settingsApi.testConservatore(selectedTenantId) + setTestResult(result) + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } }) + ?.response?.data?.detail + setTestResult({ + success: false, + message: msg ?? 'Errore durante il test di connessione', + latency_ms: null, + provider_info: null, + }) + } finally { + setTestingConnection(false) + } + } + /* ── Cambio modalita' archiviazione ── */ const handleModeToggle = (newMode: ArchivalMode) => { if (newMode === 'production' && archivalMode === 'mock') { @@ -1073,21 +1118,24 @@ export function SettingsPage() { archival_mode: archivalMode, conservatore_id: conservatoreId || undefined, conservatore_endpoint: conservatoreEndpoint || undefined, + conservatore_tenant_slug: conservatoreTenantSlug || undefined, archival_notes: archivalNotes || undefined, } if (conservatoreUsername) payload.conservatore_username = conservatoreUsername if (conservatorePassword) payload.conservatore_password = conservatorePassword - const updated = await settingsApi.update(payload) + const updated = await settingsApi.update(payload, selectedTenantId) setArchivalSettings(updated) setArchivalMode(updated.archival_mode) setConservatoreId(updated.conservatore_id) setConservatoreEndpoint(updated.conservatore_endpoint ?? '') + setConservatoreTenantSlug(updated.conservatore_tenant_slug ?? '') setArchivalNotes(updated.archival_notes ?? '') setConservatoreUsername('') setConservatorePassword('') setShowProductionConfirm(false) + setTestResult(null) toast.success( updated.archival_mode === 'production' @@ -1231,6 +1279,41 @@ export function SettingsPage() { {archivalExpanded && (
+ + {/* ── Selector tenant (solo super_admin) ── */} + {isSuperAdmin && tenantList.length > 0 && ( +
+
+ + + Gestione per tenant (Super Admin) + +
+ + {selectedTenantId && ( +

+ Stai modificando le impostazioni del tenant selezionato. + Le modifiche vengono salvate separatamente per ogni tenant. +

+ )} +
+ )} + {loadingArchival ? (

Caricamento impostazioni... @@ -1334,7 +1417,21 @@ export function SettingsPage() { id="conservatore_endpoint" value={conservatoreEndpoint} onChange={(e) => setConservatoreEndpoint(e.target.value)} - placeholder="https://conservatore.provider.it/api/v1" + placeholder="https://api.aeterna.idrainformatica.it" + /> +

+ +
+ + setConservatoreTenantSlug(e.target.value)} + placeholder="es. pechub" + autoComplete="off" />
@@ -1398,6 +1495,7 @@ export function SettingsPage() { />
+ {/* ── Riepilogo configurazione salvata ── */} {archivalSettings && (
Endpoint: {archivalSettings.conservatore_endpoint} )} + {archivalSettings.conservatore_tenant_slug && ( +
  • Tenant Slug: {archivalSettings.conservatore_tenant_slug}
  • + )}
  • Credenziali: { archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured ? Configurate @@ -1420,7 +1521,51 @@ export function SettingsPage() {
  • )} -
    + {/* ── Risultato test connessione ── */} + {testResult && ( +
    + {testResult.success + ? + : + } +
    +

    {testResult.success ? 'Connessione riuscita' : 'Connessione fallita'}

    +

    {testResult.message}

    + {testResult.latency_ms !== null && ( +

    Latenza: {testResult.latency_ms} ms

    + )} + {testResult.provider_info && testResult.success && ( +
    + {Object.entries(testResult.provider_info).map(([k, v]) => ( +
    + {k}: + {String(v)} +
    + ))} +
    + )} +
    +
    + )} + + {/* ── Azioni ── */} +
    +