Fascicoli+Tassonomia+permessi

This commit is contained in:
2026-06-17 21:47:46 +02:00
parent e31676d22e
commit 3fd3c72f06
42 changed files with 4554 additions and 99 deletions
@@ -0,0 +1,115 @@
"""
Migrazione 0016: tabelle fascicoli e fascicolo_messages.
Fascicolazione / Dossier — raggruppa piu' comunicazioni PEC in una pratica.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0016"
down_revision = "0015"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ─── Tabella fascicoli ─────────────────────────────────────────────────────
# L'ENUM fascicolo_stato viene creato automaticamente da SQLAlchemy
# come parte di op.create_table (no op.execute separato)
op.create_table(
"fascicoli",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"tenant_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("titolo", sa.String(255), nullable=False),
sa.Column("numero_pratica", sa.String(100), nullable=True),
sa.Column(
"stato",
sa.Enum("aperto", "chiuso", "archiviato", name="fascicolo_stato"),
nullable=False,
server_default="aperto",
),
sa.Column("categoria", sa.String(100), nullable=True),
sa.Column(
"responsabile_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("scadenza", sa.DateTime(timezone=True), nullable=True),
sa.Column("note", sa.Text, nullable=True),
sa.Column(
"created_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("idx_fascicoli_tenant", "fascicoli", ["tenant_id"])
op.create_index("idx_fascicoli_stato", "fascicoli", ["stato"])
op.create_index("idx_fascicoli_responsabile", "fascicoli", ["responsabile_id"])
# ─── Tabella fascicolo_messages (N:M) ──────────────────────────────────────
op.create_table(
"fascicolo_messages",
sa.Column(
"fascicolo_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("fascicoli.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"message_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"added_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"added_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"idx_fascicolo_messages_fascicolo", "fascicolo_messages", ["fascicolo_id"]
)
op.create_index(
"idx_fascicolo_messages_message", "fascicolo_messages", ["message_id"]
)
def downgrade() -> None:
op.drop_index("idx_fascicolo_messages_message", table_name="fascicolo_messages")
op.drop_index("idx_fascicolo_messages_fascicolo", table_name="fascicolo_messages")
op.drop_table("fascicolo_messages")
op.drop_index("idx_fascicoli_responsabile", table_name="fascicoli")
op.drop_index("idx_fascicoli_stato", table_name="fascicoli")
op.drop_index("idx_fascicoli_tenant", table_name="fascicoli")
op.drop_table("fascicoli")
op.execute("DROP TYPE fascicolo_stato")
@@ -0,0 +1,78 @@
"""
Migrazione 0017: Tassonomia di Classificazione Multi-livello (Feature N2).
Estende la tabella `labels` con:
- parent_id: FK self-referenziale (nullable) per struttura ad albero
- description: testo descrittivo opzionale
Struttura tassonomica:
Livello 0 (radice) = Ambito (Area Aziendale) — parent_id IS NULL
Livello 1 = Processo — parent_id = ID Ambito
Livello 2 (foglia) = Classificazione — parent_id = ID Processo
I vincoli di unicità vengono sostituiti con indici parziali per supportare
nomi identici a diversi livelli dell'albero.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Aggiunge parent_id (FK self-referenziale nullable) ─────────────────────
op.add_column(
"labels",
sa.Column(
"parent_id",
postgresql.UUID(as_uuid=True),
nullable=True,
),
)
op.create_foreign_key(
"fk_labels_parent",
"labels",
"labels",
["parent_id"],
["id"],
ondelete="CASCADE",
)
# ── Aggiunge description ────────────────────────────────────────────────────
op.add_column(
"labels",
sa.Column("description", sa.Text(), nullable=True),
)
# ── Indice su parent_id per query gerarchiche ───────────────────────────────
op.create_index("idx_labels_parent", "labels", ["parent_id"])
# ── Sostituisce il vincolo di unicità con indici parziali ───────────────────
# Il vecchio vincolo (tenant_id, name) non supporta nomi uguali a livelli diversi
op.drop_constraint("uq_label_name_tenant", "labels")
# Nodi radice: nome unico per tenant (parent_id IS NULL)
op.execute(
"CREATE UNIQUE INDEX uq_label_name_root "
"ON labels (tenant_id, name) WHERE parent_id IS NULL"
)
# Nodi non-radice: nome unico per (tenant, parent) — stesso nome ammesso sotto parent diversi
op.execute(
"CREATE UNIQUE INDEX uq_label_name_parent "
"ON labels (tenant_id, name, parent_id) WHERE parent_id IS NOT NULL"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_label_name_parent")
op.execute("DROP INDEX IF EXISTS uq_label_name_root")
op.create_unique_constraint("uq_label_name_tenant", "labels", ["tenant_id", "name"])
op.drop_index("idx_labels_parent", table_name="labels")
op.drop_constraint("fk_labels_parent", "labels", type_="foreignkey")
op.drop_column("labels", "description")
op.drop_column("labels", "parent_id")
@@ -0,0 +1,80 @@
"""
Migrazione 0018: Livello di Rischio e Riservatezza per Comunicazione (Feature N3).
Aggiunge alla tabella `messages`:
- risk_level ENUM(low, medium, high, critical) nullable
- confidentiality ENUM(public, internal, confidential, secret) nullable
Entrambi i campi sono nullable: rimangono NULL finche' non vengono
impostati manualmente o da una regola di smistamento (set_risk_level /
set_confidentiality).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Crea i nuovi ENUM types ─────────────────────────────────────────────────
risk_level_enum = postgresql.ENUM(
"low", "medium", "high", "critical",
name="risk_level",
create_type=True,
)
confidentiality_enum = postgresql.ENUM(
"public", "internal", "confidential", "secret",
name="confidentiality_level",
create_type=True,
)
risk_level_enum.create(op.get_bind(), checkfirst=True)
confidentiality_enum.create(op.get_bind(), checkfirst=True)
# ── Aggiunge le colonne a messages ──────────────────────────────────────────
op.add_column(
"messages",
sa.Column(
"risk_level",
sa.Enum("low", "medium", "high", "critical", name="risk_level", create_type=False),
nullable=True,
),
)
op.add_column(
"messages",
sa.Column(
"confidentiality",
sa.Enum("public", "internal", "confidential", "secret", name="confidentiality_level", create_type=False),
nullable=True,
),
)
# ── Indici per filtrare rapidamente per rischio/riservatezza ────────────────
op.create_index(
"idx_messages_risk_level",
"messages",
["tenant_id", "risk_level"],
postgresql_where=sa.text("risk_level IS NOT NULL"),
)
op.create_index(
"idx_messages_confidentiality",
"messages",
["tenant_id", "confidentiality"],
postgresql_where=sa.text("confidentiality IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("idx_messages_confidentiality", table_name="messages")
op.drop_index("idx_messages_risk_level", table_name="messages")
op.drop_column("messages", "confidentiality")
op.drop_column("messages", "risk_level")
risk_level_enum = postgresql.ENUM(name="risk_level")
confidentiality_enum = postgresql.ENUM(name="confidentiality_level")
risk_level_enum.drop(op.get_bind(), checkfirst=True)
confidentiality_enum.drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,77 @@
"""
Migrazione 0019: Tabella permission_presets (sottoruoli/template permessi).
Aggiunge la tabella `permission_presets` che consente ad admin e supervisor
di definire template nominati di permessi riutilizzabili (es. "Operatore Archivio",
"Operatore Invio", ecc.) da applicare agli operatori sulle caselle PEC.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"permission_presets",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
nullable=False,
),
sa.Column(
"tenant_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("can_read", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("can_send", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("can_manage", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("can_conserve", sa.Boolean(), nullable=False, server_default="false"),
sa.Column(
"created_by",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
# Unicita' nome per tenant
op.create_unique_constraint(
"uq_preset_name_tenant",
"permission_presets",
["tenant_id", "name"],
)
# Indici
op.create_index("idx_preset_tenant", "permission_presets", ["tenant_id"])
op.create_index("idx_preset_created_by", "permission_presets", ["created_by"])
def downgrade() -> None:
op.drop_index("idx_preset_created_by", table_name="permission_presets")
op.drop_index("idx_preset_tenant", table_name="permission_presets")
op.drop_constraint("uq_preset_name_tenant", "permission_presets", type_="unique")
op.drop_table("permission_presets")
+225
View File
@@ -0,0 +1,225 @@
"""
Router Fascicoli — fascicolazione pratiche.
Endpoint:
- GET /fascicoli lista fascicoli (con filtri)
- POST /fascicoli crea un fascicolo
- GET /fascicoli/{id} dettaglio fascicolo
- PATCH /fascicoli/{id} modifica fascicolo
- DELETE /fascicoli/{id} elimina fascicolo (solo admin)
- GET /fascicoli/{id}/messages messaggi del fascicolo
- POST /fascicoli/{id}/messages aggiungi messaggi al fascicolo
- DELETE /fascicoli/{id}/messages rimuovi messaggi dal fascicolo
- GET /messages/{message_id}/fascicoli fascicoli di un messaggio
Permessi:
- Tutti gli utenti autenticati possono creare fascicoli.
- PATCH: creatore o admin.
- DELETE fascicolo: solo admin.
- Operazioni su messaggi: utente con accesso al tenant.
"""
import uuid
from typing import Optional
from fastapi import APIRouter, Query, status
from sqlalchemy import select
from app.core.exceptions import ForbiddenError, NotFoundError
from app.dependencies import AdminUser, CurrentUser, DB
from app.models.message import Message
from app.schemas.fascicolo import (
FascicoloAddMessagesRequest,
FascicoloCreate,
FascicoloMessageItem,
FascicoloRemoveMessagesRequest,
FascicoloResponse,
FascicoloUpdate,
MessageFascicoloSummary,
)
from app.services.fascicolo_service import FascicoloService
router = APIRouter(tags=["Fascicoli"])
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _to_response(fascicolo, message_count: int) -> FascicoloResponse:
resp = FascicoloResponse.model_validate(fascicolo)
resp.message_count = int(message_count)
return resp
# ─── CRUD Fascicolo ───────────────────────────────────────────────────────────
@router.get("/fascicoli", response_model=list[FascicoloResponse])
async def list_fascicoli(
current_user: CurrentUser,
db: DB,
stato: Optional[str] = Query(None, pattern=r"^(aperto|chiuso|archiviato)$"),
responsabile_id: Optional[uuid.UUID] = Query(None),
search: Optional[str] = Query(None, max_length=200),
) -> list[FascicoloResponse]:
"""Elenca i fascicoli del tenant con filtri opzionali."""
svc = FascicoloService(db)
rows = await svc.list_fascicoli(
current_user.tenant_id,
stato=stato,
responsabile_id=responsabile_id,
search=search,
)
return [_to_response(f, cnt) for f, cnt in rows]
@router.post("/fascicoli", response_model=FascicoloResponse, status_code=status.HTTP_201_CREATED)
async def create_fascicolo(
data: FascicoloCreate,
current_user: CurrentUser,
db: DB,
) -> FascicoloResponse:
"""Crea un nuovo fascicolo."""
svc = FascicoloService(db)
fascicolo, cnt = await svc.create_fascicolo(
current_user.tenant_id, data, created_by=current_user.id
)
return _to_response(fascicolo, cnt)
@router.get("/fascicoli/{fascicolo_id}", response_model=FascicoloResponse)
async def get_fascicolo(
fascicolo_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> FascicoloResponse:
"""Restituisce il dettaglio di un fascicolo."""
svc = FascicoloService(db)
fascicolo, cnt = await svc.get_fascicolo(current_user.tenant_id, fascicolo_id)
return _to_response(fascicolo, cnt)
@router.patch("/fascicoli/{fascicolo_id}", response_model=FascicoloResponse)
async def update_fascicolo(
fascicolo_id: uuid.UUID,
data: FascicoloUpdate,
current_user: CurrentUser,
db: DB,
) -> FascicoloResponse:
"""
Modifica un fascicolo.
Solo il creatore o un amministratore possono modificarlo.
"""
svc = FascicoloService(db)
fascicolo, cnt = await svc.update_fascicolo(
current_user.tenant_id,
fascicolo_id,
data,
current_user_id=current_user.id,
is_admin=current_user.is_admin,
)
return _to_response(fascicolo, cnt)
@router.delete("/fascicoli/{fascicolo_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_fascicolo(
fascicolo_id: uuid.UUID,
current_user: AdminUser,
db: DB,
) -> None:
"""Elimina un fascicolo (solo admin). I messaggi non vengono eliminati."""
svc = FascicoloService(db)
await svc.delete_fascicolo(current_user.tenant_id, fascicolo_id)
# ─── Messaggi del fascicolo ───────────────────────────────────────────────────
@router.get("/fascicoli/{fascicolo_id}/messages", response_model=list[FascicoloMessageItem])
async def get_fascicolo_messages(
fascicolo_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[FascicoloMessageItem]:
"""Restituisce i messaggi collegati al fascicolo."""
svc = FascicoloService(db)
rows = await svc.get_fascicolo_messages(current_user.tenant_id, fascicolo_id)
items = []
for msg, added_at in rows:
item = FascicoloMessageItem(
id=msg.id,
subject=msg.subject,
from_address=msg.from_address,
to_addresses=msg.to_addresses,
direction=msg.direction,
pec_type=msg.pec_type,
state=msg.state,
mailbox_id=msg.mailbox_id,
received_at=msg.received_at,
sent_at=msg.sent_at,
created_at=msg.created_at,
added_at=added_at,
)
items.append(item)
return items
@router.post("/fascicoli/{fascicolo_id}/messages", response_model=dict)
async def add_messages_to_fascicolo(
fascicolo_id: uuid.UUID,
data: FascicoloAddMessagesRequest,
current_user: CurrentUser,
db: DB,
) -> dict:
"""
Aggiunge messaggi al fascicolo.
Ignora i messaggi non del tenant o gia' presenti.
"""
svc = FascicoloService(db)
added = await svc.add_messages(
current_user.tenant_id,
fascicolo_id,
data.message_ids,
added_by=current_user.id,
)
return {"added": added}
@router.delete("/fascicoli/{fascicolo_id}/messages", response_model=dict)
async def remove_messages_from_fascicolo(
fascicolo_id: uuid.UUID,
data: FascicoloRemoveMessagesRequest,
current_user: CurrentUser,
db: DB,
) -> dict:
"""Rimuove messaggi dal fascicolo (non li elimina dalla posta)."""
svc = FascicoloService(db)
removed = await svc.remove_messages(
current_user.tenant_id,
fascicolo_id,
data.message_ids,
)
return {"removed": removed}
# ─── Fascicoli di un messaggio ────────────────────────────────────────────────
@router.get("/messages/{message_id}/fascicoli", response_model=list[MessageFascicoloSummary])
async def get_message_fascicoli(
message_id: uuid.UUID,
current_user: CurrentUser,
db: DB,
) -> list[MessageFascicoloSummary]:
"""Restituisce i fascicoli a cui appartiene un messaggio."""
# Verifica che il messaggio esista nel tenant
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.tenant_id == current_user.tenant_id,
)
)
if not result.scalar_one_or_none():
raise NotFoundError(f"Messaggio {message_id} non trovato")
svc = FascicoloService(db)
fascicoli = await svc.get_message_fascicoli(current_user.tenant_id, message_id)
return [MessageFascicoloSummary.model_validate(f) for f in fascicoli]
+22 -4
View File
@@ -1,9 +1,10 @@
""" """
Router tag (Label). Router tag (Label) con supporto tassonomia gerarchica (Feature N2).
Endpoint: Endpoint:
- GET /labels elenca i tag del tenant - GET /labels elenca i tag del tenant (flat)
- POST /labels crea un nuovo tag (admin) - GET /labels/tree albero tassonomico (Ambito > Processo > Classificazione)
- POST /labels crea un nuovo tag / nodo tassonomico (admin)
- PATCH /labels/{id} modifica un tag (admin) - PATCH /labels/{id} modifica un tag (admin)
- DELETE /labels/{id} elimina un tag (admin) - DELETE /labels/{id} elimina un tag (admin)
@@ -31,6 +32,7 @@ from app.models.message import Message
from app.schemas.label import ( from app.schemas.label import (
LabelCreate, LabelCreate,
LabelResponse, LabelResponse,
LabelTreeResponse,
LabelUpdate, LabelUpdate,
MessageBulkLabelRequest, MessageBulkLabelRequest,
MessageBulkLabelResponse, MessageBulkLabelResponse,
@@ -77,12 +79,28 @@ async def list_labels(
current_user: CurrentUser, current_user: CurrentUser,
db: DB, db: DB,
) -> list[LabelResponse]: ) -> list[LabelResponse]:
"""Elenca tutti i tag del tenant corrente.""" """Elenca tutti i tag del tenant corrente (lista flat, include nodi tassonomici)."""
svc = LabelService(db) svc = LabelService(db)
labels = await svc.list_labels(current_user.tenant_id) labels = await svc.list_labels(current_user.tenant_id)
return [LabelResponse.model_validate(l) for l in labels] return [LabelResponse.model_validate(l) for l in labels]
@router.get("/labels/tree", response_model=list[LabelTreeResponse])
async def get_label_tree(
current_user: CurrentUser,
db: DB,
) -> list[LabelTreeResponse]:
"""
Restituisce la tassonomia come albero annidato.
Struttura: [ Ambito { children: [ Processo { children: [ Classificazione ] } ] } ]
I nodi radice (parent_id=NULL) possono essere sia Ambiti tassonomici che label piatte.
"""
svc = LabelService(db)
return await svc.get_label_tree(current_user.tenant_id)
@router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED) @router.post("/labels", response_model=LabelResponse, status_code=status.HTTP_201_CREATED)
async def create_label( async def create_label(
data: LabelCreate, data: LabelCreate,
+8
View File
@@ -595,6 +595,14 @@ async def update_message(
elif not data.is_conserved: elif not data.is_conserved:
message.conserved_at = None message.conserved_at = None
# Rischio e Riservatezza (Feature N3) — stringa vuota resetta a NULL
_VALID_RISK = {"low", "medium", "high", "critical"}
_VALID_CONF = {"public", "internal", "confidential", "secret"}
if data.risk_level is not None:
message.risk_level = data.risk_level if data.risk_level in _VALID_RISK else None
if data.confidentiality is not None:
message.confidentiality = data.confidentiality if data.confidentiality in _VALID_CONF else None
# Registra un evento di audit per ogni flag modificato # Registra un evento di audit per ogni flag modificato
for field, (action_true, action_false) in _FLAG_ACTIONS.items(): for field, (action_true, action_false) in _FLAG_ACTIONS.items():
value = getattr(data, field, None) value = getattr(data, field, None)
+114
View File
@@ -0,0 +1,114 @@
"""
Router preset permessi (sottoruoli nominati).
Endpoint:
GET /api/v1/permission-presets → lista preset del tenant
POST /api/v1/permission-presets → crea preset
GET /api/v1/permission-presets/{id} → dettaglio preset
PUT /api/v1/permission-presets/{id} → aggiorna preset
DELETE /api/v1/permission-presets/{id} → elimina preset
Accesso: admin e supervisor.
"""
import uuid
from fastapi import APIRouter
from app.dependencies import DB, SupervisorOrAdminUser
from app.schemas.permission_preset import (
PermissionPresetCreate,
PermissionPresetResponse,
PermissionPresetUpdate,
)
from app.services.permission_preset_service import PermissionPresetService
router = APIRouter(prefix="/permission-presets", tags=["Preset permessi"])
@router.get(
"",
response_model=list[PermissionPresetResponse],
summary="Lista preset permessi del tenant",
)
async def list_presets(
current_user: SupervisorOrAdminUser,
db: DB,
) -> list[PermissionPresetResponse]:
service = PermissionPresetService(db)
presets = await service.list_presets(current_user.tenant_id)
return [PermissionPresetResponse.model_validate(p) for p in presets]
@router.post(
"",
response_model=PermissionPresetResponse,
status_code=201,
summary="Crea un nuovo preset di permessi",
)
async def create_preset(
body: PermissionPresetCreate,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionPresetResponse:
service = PermissionPresetService(db)
preset = await service.create_preset(
tenant_id=current_user.tenant_id,
data=body,
created_by=current_user,
)
return PermissionPresetResponse.model_validate(preset)
@router.get(
"/{preset_id}",
response_model=PermissionPresetResponse,
summary="Dettaglio preset",
)
async def get_preset(
preset_id: uuid.UUID,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionPresetResponse:
service = PermissionPresetService(db)
preset = await service.get_preset(preset_id, current_user.tenant_id)
return PermissionPresetResponse.model_validate(preset)
@router.put(
"/{preset_id}",
response_model=PermissionPresetResponse,
summary="Aggiorna un preset",
)
async def update_preset(
preset_id: uuid.UUID,
body: PermissionPresetUpdate,
current_user: SupervisorOrAdminUser,
db: DB,
) -> PermissionPresetResponse:
service = PermissionPresetService(db)
preset = await service.update_preset(
preset_id=preset_id,
tenant_id=current_user.tenant_id,
data=body,
updated_by=current_user,
)
return PermissionPresetResponse.model_validate(preset)
@router.delete(
"/{preset_id}",
status_code=204,
summary="Elimina un preset",
)
async def delete_preset(
preset_id: uuid.UUID,
current_user: SupervisorOrAdminUser,
db: DB,
) -> None:
service = PermissionPresetService(db)
await service.delete_preset(
preset_id=preset_id,
tenant_id=current_user.tenant_id,
deleted_by=current_user,
)
+5 -5
View File
@@ -12,7 +12,7 @@ import uuid
from fastapi import APIRouter from fastapi import APIRouter
from app.dependencies import AdminUser, CurrentUser, DB from app.dependencies import AdminUser, CurrentUser, DB, SupervisorOrAdminUser
from app.schemas.permission import ( from app.schemas.permission import (
MailboxUserPermissionResponse, MailboxUserPermissionResponse,
PermissionGrantRequest, PermissionGrantRequest,
@@ -35,7 +35,7 @@ async def grant_permission(
mailbox_id: uuid.UUID, mailbox_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
body: PermissionGrantRequest, body: PermissionGrantRequest,
current_user: AdminUser, current_user: SupervisorOrAdminUser,
db: DB, db: DB,
) -> PermissionResponse: ) -> PermissionResponse:
service = PermissionService(db) service = PermissionService(db)
@@ -57,7 +57,7 @@ async def grant_permission(
async def revoke_permission( async def revoke_permission(
mailbox_id: uuid.UUID, mailbox_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
current_user: AdminUser, current_user: SupervisorOrAdminUser,
db: DB, db: DB,
) -> None: ) -> None:
service = PermissionService(db) service = PermissionService(db)
@@ -75,7 +75,7 @@ async def revoke_permission(
) )
async def list_mailbox_users( async def list_mailbox_users(
mailbox_id: uuid.UUID, mailbox_id: uuid.UUID,
current_user: AdminUser, current_user: SupervisorOrAdminUser,
db: DB, db: DB,
) -> list[MailboxUserPermissionResponse]: ) -> list[MailboxUserPermissionResponse]:
service = PermissionService(db) service = PermissionService(db)
@@ -90,7 +90,7 @@ async def list_mailbox_users(
) )
async def list_user_mailboxes( async def list_user_mailboxes(
user_id: uuid.UUID, user_id: uuid.UUID,
current_user: AdminUser, current_user: SupervisorOrAdminUser,
db: DB, db: DB,
) -> list[UserMailboxPermissionResponse]: ) -> list[UserMailboxPermissionResponse]:
service = PermissionService(db) service = PermissionService(db)
+3 -1
View File
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.api.v1 import audit_log, auth, contacts, deadlines, labels, mailboxes, messages, notifications, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws from app.api.v1 import audit_log, auth, contacts, deadlines, fascicoli, labels, mailboxes, messages, notifications, permission_presets, permissions, reports, routing_rules, send, signatures, tenants, templates, users, virtual_boxes, ws
from app.api.v1 import settings as settings_router from app.api.v1 import settings as settings_router
from app.config import get_settings from app.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
@@ -88,6 +88,7 @@ app.include_router(auth.router, prefix=API_PREFIX)
app.include_router(users.router, prefix=API_PREFIX) app.include_router(users.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX) app.include_router(tenants.router, prefix=API_PREFIX)
app.include_router(permissions.router, prefix=API_PREFIX) app.include_router(permissions.router, prefix=API_PREFIX)
app.include_router(permission_presets.router, prefix=API_PREFIX)
app.include_router(mailboxes.router, prefix=API_PREFIX) app.include_router(mailboxes.router, prefix=API_PREFIX)
app.include_router(messages.router, prefix=API_PREFIX) app.include_router(messages.router, prefix=API_PREFIX)
app.include_router(send.router, prefix=API_PREFIX) app.include_router(send.router, prefix=API_PREFIX)
@@ -103,6 +104,7 @@ app.include_router(routing_rules.router, prefix=API_PREFIX)
app.include_router(contacts.router, prefix=API_PREFIX) app.include_router(contacts.router, prefix=API_PREFIX)
app.include_router(deadlines.router, prefix=API_PREFIX) app.include_router(deadlines.router, prefix=API_PREFIX)
app.include_router(signatures.router, prefix=API_PREFIX) app.include_router(signatures.router, prefix=API_PREFIX)
app.include_router(fascicoli.router, prefix=API_PREFIX)
# ─── Health check ───────────────────────────────────────────────────────────── # ─── Health check ─────────────────────────────────────────────────────────────
+1
View File
@@ -14,3 +14,4 @@ from app.models.template import MessageTemplate # noqa: F401
from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401 from app.models.routing_rule import RoutingRule, RoutingRuleCondition, RoutingRuleAction # noqa: F401
from app.models.pec_contact import PecContact # noqa: F401 from app.models.pec_contact import PecContact # noqa: F401
from app.models.signature import Signature, SignatureAssignment # noqa: F401 from app.models.signature import Signature, SignatureAssignment # noqa: F401
from app.models.fascicolo import Fascicolo, FascicoloMessage # noqa: F401
+96
View File
@@ -0,0 +1,96 @@
"""
Modelli Fascicolo e FascicoloMessage — fascicolazione pratiche.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Index, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
FascicoloStato = Enum(
"aperto", "chiuso", "archiviato",
name="fascicolo_stato",
create_type=False,
)
class Fascicolo(Base):
__tablename__ = "fascicoli"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
titolo: Mapped[str] = mapped_column(String(255), nullable=False)
numero_pratica: Mapped[str | None] = mapped_column(String(100), nullable=True)
stato: Mapped[str] = mapped_column(FascicoloStato, nullable=False, default="aperto")
categoria: Mapped[str | None] = mapped_column(String(100), nullable=True)
responsabile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
scadenza: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
fascicolo_messages: Mapped[list["FascicoloMessage"]] = relationship(
"FascicoloMessage", back_populates="fascicolo", cascade="all, delete-orphan"
)
messages: Mapped[list] = relationship(
"Message",
secondary="fascicolo_messages",
lazy="select",
viewonly=True,
)
__table_args__ = (
Index("idx_fascicoli_tenant", "tenant_id"),
Index("idx_fascicoli_stato", "stato"),
Index("idx_fascicoli_responsabile", "responsabile_id"),
)
def __repr__(self) -> str:
return f"<Fascicolo {self.id} {self.titolo!r} {self.stato!r}>"
class FascicoloMessage(Base):
__tablename__ = "fascicolo_messages"
fascicolo_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("fascicoli.id", ondelete="CASCADE"),
primary_key=True,
)
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("messages.id", ondelete="CASCADE"),
primary_key=True,
)
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
added_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Relazioni
fascicolo: Mapped["Fascicolo"] = relationship("Fascicolo", back_populates="fascicolo_messages")
__table_args__ = (
Index("idx_fascicolo_messages_fascicolo", "fascicolo_id"),
Index("idx_fascicolo_messages_message", "message_id"),
)
+21 -4
View File
@@ -1,10 +1,17 @@
""" """
Modelli Label e MessageLabel tagging messaggi. Modelli Label e MessageLabel tagging messaggi con supporto tassonomia gerarchica.
Struttura ad albero (Feature N2 Tassonomia di Classificazione Multi-livello):
parent_id = NULL → Livello 0: Ambito (Area Aziendale)
parent_id = ID ambito → Livello 1: Processo
parent_id = ID processo → Livello 2: Classificazione (foglia)
Le label senza parent_id sono label "piatte" classiche (comportamento pre-esistente).
""" """
import uuid import uuid
from sqlalchemy import CHAR, ForeignKey, Index, String, UniqueConstraint from sqlalchemy import CHAR, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -20,15 +27,25 @@ class Label(Base):
tenant_id: Mapped[uuid.UUID] = mapped_column( tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
) )
# Tassonomia: se parent_id è None è un nodo radice (Ambito) o label piatta
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("labels.id", ondelete="CASCADE"),
nullable=True,
)
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB color: Mapped[str | None] = mapped_column(CHAR(7), nullable=True) # hex #RRGGBB
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Nota: i vincoli di unicità sono gestiti da indici parziali nel DB:
# uq_label_name_root UNIQUE (tenant_id, name) WHERE parent_id IS NULL
# uq_label_name_parent UNIQUE (tenant_id, name, parent_id) WHERE parent_id IS NOT NULL
__table_args__ = ( __table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_label_name_tenant"), Index("idx_labels_parent", "parent_id"),
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Label {self.name!r}>" return f"<Label {self.name!r} parent={self.parent_id}>"
class MessageLabel(Base): class MessageLabel(Base):
+10
View File
@@ -24,6 +24,12 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
RiskLevel = Enum("low", "medium", "high", "critical", name="risk_level", create_type=False)
ConfidentialityLevel = Enum(
"public", "internal", "confidential", "secret",
name="confidentiality_level",
create_type=False,
)
PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False) PecDirection = Enum("inbound", "outbound", name="pec_direction", create_type=False)
PecState = Enum( PecState = Enum(
"draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received", "draft", "queued", "sent", "accepted", "delivered", "anomaly", "failed", "received",
@@ -105,6 +111,10 @@ class Message(Base):
is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_conserved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) conserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Rischio e Riservatezza (Feature N3)
risk_level: Mapped[str | None] = mapped_column(RiskLevel, nullable=True)
confidentiality: Mapped[str | None] = mapped_column(ConfidentialityLevel, nullable=True)
raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True) raw_eml_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Full-text search vector (aggiornato da trigger DB + worker per allegati) # Full-text search vector (aggiornato da trigger DB + worker per allegati)
+72
View File
@@ -0,0 +1,72 @@
"""
Modello PermissionPreset template riutilizzabili di permessi per casella.
Permette ad admin e supervisor di creare sottoruoli nominati (es. "Operatore Archivio")
con combinazioni predefinite di can_read/can_send/can_manage/can_conserve.
I preset sono per-tenant e vengono applicati opzionalmente al momento
dell'assegnazione di un operatore a una casella.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PermissionPreset(Base):
__tablename__ = "permission_presets"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
can_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_send: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_manage: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_conserve: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relazioni
creator: Mapped["User"] = relationship( # noqa: F821
"User", foreign_keys=[created_by]
)
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_preset_name_tenant"),
Index("idx_preset_tenant", "tenant_id"),
Index("idx_preset_created_by", "created_by"),
)
def __repr__(self) -> str:
return (
f"<PermissionPreset name={self.name!r} tenant={self.tenant_id} "
f"read={self.can_read} send={self.can_send} "
f"manage={self.can_manage} conserve={self.can_conserve}>"
)
+92
View File
@@ -0,0 +1,92 @@
"""
Schemi Pydantic per Fascicolo (fascicolazione pratiche).
"""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ─── Schemi base ──────────────────────────────────────────────────────────────
class FascicoloCreate(BaseModel):
titolo: str = Field(..., min_length=1, max_length=255)
numero_pratica: Optional[str] = Field(None, max_length=100)
stato: Optional[str] = Field("aperto", pattern=r"^(aperto|chiuso|archiviato)$")
categoria: Optional[str] = Field(None, max_length=100)
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
class FascicoloUpdate(BaseModel):
titolo: Optional[str] = Field(None, min_length=1, max_length=255)
numero_pratica: Optional[str] = Field(None, max_length=100)
stato: Optional[str] = Field(None, pattern=r"^(aperto|chiuso|archiviato)$")
categoria: Optional[str] = Field(None, max_length=100)
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
class FascicoloResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
titolo: str
numero_pratica: Optional[str] = None
stato: str
categoria: Optional[str] = None
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
created_by: Optional[uuid.UUID] = None
created_at: datetime
updated_at: datetime
message_count: int = 0
model_config = {"from_attributes": True}
# ─── Messaggi nel fascicolo ───────────────────────────────────────────────────
class FascicoloMessageItem(BaseModel):
"""Riepilogo di un messaggio nel fascicolo."""
id: uuid.UUID
subject: Optional[str] = None
from_address: Optional[str] = None
to_addresses: Optional[list[str]] = None
direction: str
pec_type: str
state: str
mailbox_id: uuid.UUID
received_at: Optional[datetime] = None
sent_at: Optional[datetime] = None
created_at: datetime
added_at: datetime
model_config = {"from_attributes": True}
# ─── Operazioni sui messaggi del fascicolo ────────────────────────────────────
class FascicoloAddMessagesRequest(BaseModel):
message_ids: list[uuid.UUID] = Field(..., min_length=1)
class FascicoloRemoveMessagesRequest(BaseModel):
message_ids: list[uuid.UUID] = Field(..., min_length=1)
# ─── Lista fascicoli di un messaggio ─────────────────────────────────────────
class MessageFascicoloSummary(BaseModel):
"""Fascicolo sintetico per la vista nel dettaglio messaggio."""
id: uuid.UUID
titolo: str
numero_pratica: Optional[str] = None
stato: str
categoria: Optional[str] = None
model_config = {"from_attributes": True}
+32
View File
@@ -1,5 +1,6 @@
""" """
Schemi Pydantic per Label (tag) e operazioni correlate. Schemi Pydantic per Label (tag) e operazioni correlate.
Esteso con supporto tassonomia gerarchica (Feature N2).
""" """
import uuid import uuid
@@ -8,14 +9,21 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ─── CRUD Label ───────────────────────────────────────────────────────────────
class LabelCreate(BaseModel): class LabelCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
# Tassonomia: se valorizzato, questo nodo diventa figlio del parent indicato
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
class LabelUpdate(BaseModel): class LabelUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
class LabelResponse(BaseModel): class LabelResponse(BaseModel):
@@ -23,10 +31,34 @@ class LabelResponse(BaseModel):
tenant_id: uuid.UUID tenant_id: uuid.UUID
name: str name: str
color: Optional[str] = None color: Optional[str] = None
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class LabelTreeResponse(BaseModel):
"""
Label con figli annidati per la vista ad albero della tassonomia.
Struttura restituita da GET /labels/tree:
[ Ambito1 { children: [ Processo1 { children: [ Classificazione1, ... ] } ] } ]
"""
id: uuid.UUID
tenant_id: uuid.UUID
name: str
color: Optional[str] = None
parent_id: Optional[uuid.UUID] = None
description: Optional[str] = None
children: list["LabelTreeResponse"] = []
model_config = {"from_attributes": True}
# Necessario per il tipo ricorsivo
LabelTreeResponse.model_rebuild()
# ─── Richieste per assegnazione tag a messaggi ──────────────────────────────── # ─── Richieste per assegnazione tag a messaggi ────────────────────────────────
class MessageLabelSetRequest(BaseModel): class MessageLabelSetRequest(BaseModel):
+8
View File
@@ -76,6 +76,9 @@ class MessageResponse(BaseModel):
pending_conservation_at: Optional[datetime] = None pending_conservation_at: Optional[datetime] = None
is_conserved: bool = False is_conserved: bool = False
conserved_at: Optional[datetime] = None conserved_at: Optional[datetime] = None
# Rischio e Riservatezza (Feature N3)
risk_level: Optional[str] = None
confidentiality: Optional[str] = None
raw_eml_path: Optional[str] = None raw_eml_path: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -116,6 +119,9 @@ class MessageUpdateRequest(BaseModel):
is_trashed: Optional[bool] = None is_trashed: Optional[bool] = None
is_pending_conservation: Optional[bool] = None is_pending_conservation: Optional[bool] = None
is_conserved: Optional[bool] = None is_conserved: Optional[bool] = None
# Rischio e Riservatezza (Feature N3) — None = non modificare; stringa vuota = reset a NULL
risk_level: Optional[str] = None
confidentiality: Optional[str] = None
class MessageBulkUpdateRequest(BaseModel): class MessageBulkUpdateRequest(BaseModel):
@@ -126,6 +132,8 @@ class MessageBulkUpdateRequest(BaseModel):
is_trashed: Optional[bool] = None is_trashed: Optional[bool] = None
is_pending_conservation: Optional[bool] = None is_pending_conservation: Optional[bool] = None
is_conserved: Optional[bool] = None is_conserved: Optional[bool] = None
risk_level: Optional[str] = None
confidentiality: Optional[str] = None
class MessageBulkUpdateResponse(BaseModel): class MessageBulkUpdateResponse(BaseModel):
+42
View File
@@ -0,0 +1,42 @@
"""
Schema Pydantic per i preset di permessi (sottoruoli nominati).
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class PermissionPresetCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Nome del preset")
description: str | None = Field(None, description="Descrizione opzionale")
can_read: bool = Field(True, description="Permesso lettura messaggi")
can_send: bool = Field(False, description="Permesso invio PEC")
can_manage: bool = Field(False, description="Permesso gestione casella")
can_conserve: bool = Field(False, description="Permesso conservazione documenti")
class PermissionPresetUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
can_read: bool | None = None
can_send: bool | None = None
can_manage: bool | None = None
can_conserve: bool | None = None
class PermissionPresetResponse(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID
name: str
description: str | None
can_read: bool
can_send: bool
can_manage: bool
can_conserve: bool
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+20 -2
View File
@@ -10,7 +10,16 @@ from pydantic import BaseModel, field_validator
# Valori validi per field nelle condizioni # Valori validi per field nelle condizioni
CONDITION_FIELDS = Literal[ CONDITION_FIELDS = Literal[
"from_address", "to_address", "subject", "mailbox_id", "pec_type" "from_address",
"to_address",
"subject",
"mailbox_id",
"pec_type",
# Tassonomia (N2): verifica se il messaggio ha gia' una specifica etichetta/nodo
"has_label",
# Rischio e Riservatezza (N3): verifica il livello gia' impostato
"risk_level",
"confidentiality",
] ]
# Operatori supportati # Operatori supportati
CONDITION_OPERATORS = Literal[ CONDITION_OPERATORS = Literal[
@@ -18,7 +27,16 @@ CONDITION_OPERATORS = Literal[
] ]
# Tipi di azione # Tipi di azione
ACTION_TYPES = Literal[ ACTION_TYPES = Literal[
"apply_label", "assign_vbox", "mark_read", "mark_starred", "notify_webhook" "apply_label",
"assign_vbox",
"mark_read",
"mark_starred",
"notify_webhook",
# Tassonomia (N2): applica un nodo tassonomico (Ambito/Processo/Classificazione)
"apply_taxonomy",
# Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza
"set_risk_level",
"set_confidentiality",
] ]
+270
View File
@@ -0,0 +1,270 @@
"""
Service per la gestione dei Fascicoli (fascicolazione pratiche).
"""
import uuid
from datetime import datetime
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.models.fascicolo import Fascicolo, FascicoloMessage
from app.models.message import Message
from app.models.user import User
from app.schemas.fascicolo import FascicoloCreate, FascicoloUpdate
class FascicoloService:
def __init__(self, db: AsyncSession):
self.db = db
# ─── CRUD Fascicolo ───────────────────────────────────────────────────────
async def list_fascicoli(
self,
tenant_id: uuid.UUID,
stato: str | None = None,
responsabile_id: uuid.UUID | None = None,
search: str | None = None,
) -> list[tuple[Fascicolo, int]]:
"""
Restituisce lista di (Fascicolo, message_count) con filtri opzionali.
"""
# Subquery per il conteggio messaggi
count_sub = (
select(
FascicoloMessage.fascicolo_id,
func.count(FascicoloMessage.message_id).label("cnt"),
)
.group_by(FascicoloMessage.fascicolo_id)
.subquery()
)
stmt = (
select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count"))
.outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id)
.where(Fascicolo.tenant_id == tenant_id)
.order_by(Fascicolo.updated_at.desc())
)
if stato:
stmt = stmt.where(Fascicolo.stato == stato)
if responsabile_id:
stmt = stmt.where(Fascicolo.responsabile_id == responsabile_id)
if search:
pattern = f"%{search}%"
stmt = stmt.where(
Fascicolo.titolo.ilike(pattern)
| Fascicolo.numero_pratica.ilike(pattern)
| Fascicolo.categoria.ilike(pattern)
)
result = await self.db.execute(stmt)
return list(result.all())
async def get_fascicolo(
self, tenant_id: uuid.UUID, fascicolo_id: uuid.UUID
) -> tuple[Fascicolo, int]:
"""Restituisce (Fascicolo, message_count) o solleva NotFoundError."""
count_sub = (
select(
FascicoloMessage.fascicolo_id,
func.count(FascicoloMessage.message_id).label("cnt"),
)
.group_by(FascicoloMessage.fascicolo_id)
.subquery()
)
result = await self.db.execute(
select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count"))
.outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id)
.where(
Fascicolo.id == fascicolo_id,
Fascicolo.tenant_id == tenant_id,
)
)
row = result.one_or_none()
if not row:
raise NotFoundError(f"Fascicolo {fascicolo_id} non trovato")
return row
async def create_fascicolo(
self,
tenant_id: uuid.UUID,
data: FascicoloCreate,
created_by: uuid.UUID,
) -> tuple[Fascicolo, int]:
fascicolo = Fascicolo(
id=uuid.uuid4(),
tenant_id=tenant_id,
titolo=data.titolo,
numero_pratica=data.numero_pratica,
stato=data.stato or "aperto",
categoria=data.categoria,
responsabile_id=data.responsabile_id,
scadenza=data.scadenza,
note=data.note,
created_by=created_by,
)
self.db.add(fascicolo)
await self.db.commit()
await self.db.refresh(fascicolo)
return fascicolo, 0
async def update_fascicolo(
self,
tenant_id: uuid.UUID,
fascicolo_id: uuid.UUID,
data: FascicoloUpdate,
current_user_id: uuid.UUID,
is_admin: bool,
) -> tuple[Fascicolo, int]:
fascicolo, count = await self.get_fascicolo(tenant_id, fascicolo_id)
# Solo admin o il creatore possono modificare
if not is_admin and fascicolo.created_by != current_user_id:
raise ForbiddenError("Solo il creatore o un amministratore puo' modificare questo fascicolo")
if data.titolo is not None:
fascicolo.titolo = data.titolo
if data.numero_pratica is not None:
fascicolo.numero_pratica = data.numero_pratica
if data.stato is not None:
fascicolo.stato = data.stato
if data.categoria is not None:
fascicolo.categoria = data.categoria
if data.responsabile_id is not None:
fascicolo.responsabile_id = data.responsabile_id
if data.scadenza is not None:
fascicolo.scadenza = data.scadenza
if data.note is not None:
fascicolo.note = data.note
fascicolo.updated_at = datetime.utcnow()
await self.db.commit()
await self.db.refresh(fascicolo)
return fascicolo, count
async def delete_fascicolo(
self,
tenant_id: uuid.UUID,
fascicolo_id: uuid.UUID,
) -> None:
fascicolo, _ = await self.get_fascicolo(tenant_id, fascicolo_id)
await self.db.delete(fascicolo)
await self.db.commit()
# ─── Messaggi nel fascicolo ───────────────────────────────────────────────
async def get_fascicolo_messages(
self,
tenant_id: uuid.UUID,
fascicolo_id: uuid.UUID,
) -> list[tuple[Message, datetime]]:
"""
Restituisce lista di (Message, added_at) ordinati per added_at desc.
"""
# Verifica fascicolo appartiene al tenant
await self.get_fascicolo(tenant_id, fascicolo_id)
result = await self.db.execute(
select(Message, FascicoloMessage.added_at)
.join(FascicoloMessage, Message.id == FascicoloMessage.message_id)
.where(
FascicoloMessage.fascicolo_id == fascicolo_id,
Message.tenant_id == tenant_id,
)
.order_by(FascicoloMessage.added_at.desc())
)
return list(result.all())
async def add_messages(
self,
tenant_id: uuid.UUID,
fascicolo_id: uuid.UUID,
message_ids: list[uuid.UUID],
added_by: uuid.UUID,
) -> int:
"""
Aggiunge messaggi al fascicolo. Ignora duplicati e messaggi non del tenant.
Restituisce il numero di messaggi aggiunti.
"""
await self.get_fascicolo(tenant_id, fascicolo_id)
# Verifica che i messaggi appartengano al tenant
msg_result = await self.db.execute(
select(Message.id).where(
Message.id.in_(message_ids),
Message.tenant_id == tenant_id,
)
)
valid_ids = list(msg_result.scalars().all())
if not valid_ids:
return 0
# Carica associazioni esistenti per evitare duplicati
existing_result = await self.db.execute(
select(FascicoloMessage.message_id).where(
FascicoloMessage.fascicolo_id == fascicolo_id,
FascicoloMessage.message_id.in_(valid_ids),
)
)
existing_ids = set(existing_result.scalars().all())
added = 0
for msg_id in valid_ids:
if msg_id not in existing_ids:
self.db.add(
FascicoloMessage(
fascicolo_id=fascicolo_id,
message_id=msg_id,
added_by=added_by,
)
)
added += 1
if added:
await self.db.commit()
return added
async def remove_messages(
self,
tenant_id: uuid.UUID,
fascicolo_id: uuid.UUID,
message_ids: list[uuid.UUID],
) -> int:
"""Rimuove messaggi dal fascicolo. Restituisce il numero rimosso."""
await self.get_fascicolo(tenant_id, fascicolo_id)
result = await self.db.execute(
delete(FascicoloMessage).where(
FascicoloMessage.fascicolo_id == fascicolo_id,
FascicoloMessage.message_id.in_(message_ids),
).returning(FascicoloMessage.message_id)
)
removed = len(result.fetchall())
if removed:
await self.db.commit()
return removed
# ─── Fascicoli di un messaggio ────────────────────────────────────────────
async def get_message_fascicoli(
self,
tenant_id: uuid.UUID,
message_id: uuid.UUID,
) -> list[Fascicolo]:
"""Restituisce i fascicoli a cui appartiene un messaggio."""
result = await self.db.execute(
select(Fascicolo)
.join(FascicoloMessage, Fascicolo.id == FascicoloMessage.fascicolo_id)
.where(
FascicoloMessage.message_id == message_id,
Fascicolo.tenant_id == tenant_id,
)
.order_by(Fascicolo.titolo)
)
return list(result.scalars().all())
+114 -29
View File
@@ -1,16 +1,21 @@
""" """
Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi. Service per la gestione delle Label (tag) e la loro assegnazione ai messaggi.
Esteso con supporto alla tassonomia gerarchica (Feature N2).
""" """
import uuid import uuid
from typing import Optional
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError from app.core.exceptions import NotFoundError, ValidationError
from app.models.label import Label, MessageLabel from app.models.label import Label, MessageLabel
from app.models.message import Message from app.models.message import Message
from app.schemas.label import LabelCreate, LabelUpdate from app.schemas.label import LabelCreate, LabelTreeResponse, LabelUpdate
# Profondità massima della tassonomia (0=Ambito, 1=Processo, 2=Classificazione)
MAX_TAXONOMY_DEPTH = 2
class LabelService: class LabelService:
@@ -20,6 +25,7 @@ class LabelService:
# ─── CRUD Label ─────────────────────────────────────────────────────────── # ─── CRUD Label ───────────────────────────────────────────────────────────
async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]: async def list_labels(self, tenant_id: uuid.UUID) -> list[Label]:
"""Lista flat di tutte le label del tenant (label piatte + nodi tassonomici)."""
result = await self.db.execute( result = await self.db.execute(
select(Label) select(Label)
.where(Label.tenant_id == tenant_id) .where(Label.tenant_id == tenant_id)
@@ -27,6 +33,56 @@ class LabelService:
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def list_root_labels(self, tenant_id: uuid.UUID) -> list[Label]:
"""Lista solo le label radice (parent_id IS NULL)."""
result = await self.db.execute(
select(Label)
.where(Label.tenant_id == tenant_id, Label.parent_id.is_(None))
.order_by(Label.name)
)
return list(result.scalars().all())
async def get_label_tree(self, tenant_id: uuid.UUID) -> list[LabelTreeResponse]:
"""
Restituisce la tassonomia come albero annidato.
Struttura: [ Ambito { children: [ Processo { children: [ Classificazione ] } ] } ]
Include anche label piatte (parent_id=None) come nodi radice.
"""
result = await self.db.execute(
select(Label)
.where(Label.tenant_id == tenant_id)
.order_by(Label.name)
)
all_labels = list(result.scalars().all())
# Costruisce un dizionario id -> LabelTreeResponse
nodes: dict[uuid.UUID, LabelTreeResponse] = {}
for lbl in all_labels:
nodes[lbl.id] = LabelTreeResponse(
id=lbl.id,
tenant_id=lbl.tenant_id,
name=lbl.name,
color=lbl.color,
parent_id=lbl.parent_id,
description=lbl.description,
children=[],
)
# Collega figli ai genitori e raccoglie le radici
roots: list[LabelTreeResponse] = []
for lbl in all_labels:
node = nodes[lbl.id]
if lbl.parent_id is None:
roots.append(node)
elif lbl.parent_id in nodes:
nodes[lbl.parent_id].children.append(node)
else:
# Orfano (parent rimosso): trattato come radice
roots.append(node)
return roots
async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label: async def get_label(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> Label:
result = await self.db.execute( result = await self.db.execute(
select(Label).where( select(Label).where(
@@ -39,21 +95,52 @@ class LabelService:
raise NotFoundError(f"Tag {label_id} non trovato") raise NotFoundError(f"Tag {label_id} non trovato")
return label return label
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label: async def _get_depth(self, tenant_id: uuid.UUID, label_id: uuid.UUID) -> int:
# Verifica unicità """
existing = await self.db.execute( Calcola la profondità di una label nell'albero tassonomico.
select(Label).where( 0 = radice (Ambito), 1 = Processo, 2 = Classificazione.
"""
depth = 0
current_id: Optional[uuid.UUID] = label_id
visited: set[uuid.UUID] = set()
while current_id and depth <= MAX_TAXONOMY_DEPTH + 1:
if current_id in visited:
break # Ciclo (non dovrebbe accadere ma sicurezza)
visited.add(current_id)
row = await self.db.execute(
select(Label.parent_id).where(
Label.id == current_id,
Label.tenant_id == tenant_id, Label.tenant_id == tenant_id,
Label.name == data.name,
) )
) )
if existing.scalar_one_or_none(): parent_id = row.scalar_one_or_none()
raise ConflictError(f"Tag '{data.name}' già esistente") if parent_id is None:
break
current_id = parent_id
depth += 1
return depth
async def create_label(self, tenant_id: uuid.UUID, data: LabelCreate) -> Label:
# Valida il parent (se specificato)
if data.parent_id is not None:
await self.get_label(tenant_id, data.parent_id) # 404 se non esiste
parent_depth = await self._get_depth(tenant_id, data.parent_id)
if parent_depth >= MAX_TAXONOMY_DEPTH:
raise ValidationError(
f"La tassonomia supporta al massimo {MAX_TAXONOMY_DEPTH + 1} livelli "
"(Ambito > Processo > Classificazione). "
"Il nodo padre ha gia' raggiunto la profondita' massima."
)
label = Label( label = Label(
tenant_id=tenant_id, tenant_id=tenant_id,
name=data.name, name=data.name,
color=data.color, color=data.color,
parent_id=data.parent_id,
description=data.description,
) )
self.db.add(label) self.db.add(label)
await self.db.commit() await self.db.commit()
@@ -66,21 +153,29 @@ class LabelService:
label = await self.get_label(tenant_id, label_id) label = await self.get_label(tenant_id, label_id)
if data.name is not None: if data.name is not None:
# Verifica unicità del nuovo nome
existing = await self.db.execute(
select(Label).where(
Label.tenant_id == tenant_id,
Label.name == data.name,
Label.id != label_id,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Tag '{data.name}' già esistente")
label.name = data.name label.name = data.name
if data.color is not None: if data.color is not None:
label.color = data.color label.color = data.color
if data.description is not None:
label.description = data.description
if data.parent_id is not None:
# Impedisce cicli: il nuovo parent non può essere il nodo stesso o un suo discendente
if data.parent_id == label_id:
raise ValidationError("Un nodo non puo' essere genitore di se stesso")
await self.get_label(tenant_id, data.parent_id)
parent_depth = await self._get_depth(tenant_id, data.parent_id)
if parent_depth >= MAX_TAXONOMY_DEPTH:
raise ValidationError(
"Il nodo padre ha gia' raggiunto la profondita' massima consentita."
)
label.parent_id = data.parent_id
elif data.parent_id is None and "parent_id" in (data.model_fields_set or set()):
# Esplicita rimozione del parent (promozione a radice)
label.parent_id = None
await self.db.commit() await self.db.commit()
await self.db.refresh(label) await self.db.refresh(label)
return label return label
@@ -113,7 +208,6 @@ class LabelService:
label_ids: list[uuid.UUID], label_ids: list[uuid.UUID],
) -> list[Label]: ) -> list[Label]:
"""Sostituisce tutti i tag di un messaggio con quelli indicati.""" """Sostituisce tutti i tag di un messaggio con quelli indicati."""
# Verifica che i label appartengano al tenant
valid_ids: set[uuid.UUID] = set() valid_ids: set[uuid.UUID] = set()
if label_ids: if label_ids:
result = await self.db.execute( result = await self.db.execute(
@@ -124,12 +218,9 @@ class LabelService:
) )
valid_ids = {lbl.id for lbl in result.scalars().all()} valid_ids = {lbl.id for lbl in result.scalars().all()}
# Rimuovi tutti i tag esistenti dal messaggio
await self.db.execute( await self.db.execute(
delete(MessageLabel).where(MessageLabel.message_id == message_id) delete(MessageLabel).where(MessageLabel.message_id == message_id)
) )
# Aggiungi i nuovi tag validi
for lbl_id in valid_ids: for lbl_id in valid_ids:
self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id)) self.db.add(MessageLabel(message_id=message_id, label_id=lbl_id))
@@ -146,7 +237,6 @@ class LabelService:
if not label_ids: if not label_ids:
return await self.get_message_labels(message_id, tenant_id) return await self.get_message_labels(message_id, tenant_id)
# Verifica appartenenza al tenant
result = await self.db.execute( result = await self.db.execute(
select(Label).where( select(Label).where(
Label.id.in_(label_ids), Label.id.in_(label_ids),
@@ -155,7 +245,6 @@ class LabelService:
) )
valid_labels = list(result.scalars().all()) valid_labels = list(result.scalars().all())
# Carica tag esistenti per evitare duplicati
existing_result = await self.db.execute( existing_result = await self.db.execute(
select(MessageLabel.label_id).where( select(MessageLabel.label_id).where(
MessageLabel.message_id == message_id MessageLabel.message_id == message_id
@@ -199,7 +288,6 @@ class LabelService:
if not label_ids or not message_ids: if not label_ids or not message_ids:
return 0 return 0
# Verifica label del tenant
lbl_result = await self.db.execute( lbl_result = await self.db.execute(
select(Label).where( select(Label).where(
Label.id.in_(label_ids), Label.id.in_(label_ids),
@@ -208,7 +296,6 @@ class LabelService:
) )
valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()] valid_label_ids = [lbl.id for lbl in lbl_result.scalars().all()]
# Verifica messaggi del tenant
msg_result = await self.db.execute( msg_result = await self.db.execute(
select(Message.id).where( select(Message.id).where(
Message.id.in_(message_ids), Message.id.in_(message_ids),
@@ -220,7 +307,6 @@ class LabelService:
if not valid_label_ids or not valid_message_ids: if not valid_label_ids or not valid_message_ids:
return 0 return 0
# Carica coppie esistenti per evitare duplicati
existing_result = await self.db.execute( existing_result = await self.db.execute(
select(MessageLabel).where( select(MessageLabel).where(
MessageLabel.message_id.in_(valid_message_ids), MessageLabel.message_id.in_(valid_message_ids),
@@ -249,7 +335,6 @@ class LabelService:
if not label_ids or not message_ids: if not label_ids or not message_ids:
return 0 return 0
# Verifica messaggi del tenant
msg_result = await self.db.execute( msg_result = await self.db.execute(
select(Message.id).where( select(Message.id).where(
Message.id.in_(message_ids), Message.id.in_(message_ids),
@@ -0,0 +1,125 @@
"""
Servizio CRUD per i preset di permessi (sottoruoli nominati).
Admin e supervisor possono creare, modificare ed eliminare preset per il proprio tenant.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, ForbiddenError, NotFoundError
from app.models.permission_preset import PermissionPreset
from app.models.user import User
from app.schemas.permission_preset import PermissionPresetCreate, PermissionPresetUpdate
class PermissionPresetService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
def _require_supervisor_or_admin(self, user: User) -> None:
if not user.is_supervisor_or_admin:
raise ForbiddenError("Solo amministratori e supervisori possono gestire i preset")
async def list_presets(self, tenant_id: uuid.UUID) -> list[PermissionPreset]:
"""Ritorna tutti i preset del tenant ordinati per nome."""
result = await self.db.execute(
select(PermissionPreset)
.where(PermissionPreset.tenant_id == tenant_id)
.order_by(PermissionPreset.name)
)
return list(result.scalars().all())
async def get_preset(self, preset_id: uuid.UUID, tenant_id: uuid.UUID) -> PermissionPreset:
"""Recupera un preset per ID verificando che appartenga al tenant."""
preset = await self.db.get(PermissionPreset, preset_id)
if not preset or preset.tenant_id != tenant_id:
raise NotFoundError("preset")
return preset
async def create_preset(
self,
tenant_id: uuid.UUID,
data: PermissionPresetCreate,
created_by: User,
) -> PermissionPreset:
"""Crea un nuovo preset. Il nome deve essere unico per tenant."""
self._require_supervisor_or_admin(created_by)
# Verifica unicita' nome
existing = await self.db.execute(
select(PermissionPreset).where(
PermissionPreset.tenant_id == tenant_id,
PermissionPreset.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Esiste gia' un preset con nome '{data.name}'")
preset = PermissionPreset(
tenant_id=tenant_id,
name=data.name,
description=data.description,
can_read=data.can_read,
can_send=data.can_send,
can_manage=data.can_manage,
can_conserve=data.can_conserve,
created_by=created_by.id,
)
self.db.add(preset)
await self.db.flush()
await self.db.refresh(preset)
return preset
async def update_preset(
self,
preset_id: uuid.UUID,
tenant_id: uuid.UUID,
data: PermissionPresetUpdate,
updated_by: User,
) -> PermissionPreset:
"""Aggiorna un preset esistente."""
self._require_supervisor_or_admin(updated_by)
preset = await self.get_preset(preset_id, tenant_id)
# Verifica unicita' nome se cambia
if data.name is not None and data.name != preset.name:
existing = await self.db.execute(
select(PermissionPreset).where(
PermissionPreset.tenant_id == tenant_id,
PermissionPreset.name == data.name,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Esiste gia' un preset con nome '{data.name}'")
preset.name = data.name
if data.description is not None:
preset.description = data.description
if data.can_read is not None:
preset.can_read = data.can_read
if data.can_send is not None:
preset.can_send = data.can_send
if data.can_manage is not None:
preset.can_manage = data.can_manage
if data.can_conserve is not None:
preset.can_conserve = data.can_conserve
await self.db.flush()
await self.db.refresh(preset)
return preset
async def delete_preset(
self,
preset_id: uuid.UUID,
tenant_id: uuid.UUID,
deleted_by: User,
) -> None:
"""Elimina un preset."""
self._require_supervisor_or_admin(deleted_by)
preset = await self.get_preset(preset_id, tenant_id)
await self.db.delete(preset)
await self.db.flush()
+5 -5
View File
@@ -142,10 +142,10 @@ class PermissionService:
) -> MailboxPermission: ) -> MailboxPermission:
""" """
Crea o aggiorna un permesso utente su una casella. Crea o aggiorna un permesso utente su una casella.
Solo admin può gestire i permessi. Admin e supervisor possono gestire i permessi.
""" """
if not granted_by.is_admin: if not granted_by.is_supervisor_or_admin:
raise ForbiddenError("Solo gli amministratori possono gestire i permessi") raise ForbiddenError("Solo amministratori e supervisori possono gestire i permessi")
# Verifica che casella e utente appartengano al tenant # Verifica che casella e utente appartengano al tenant
mailbox = await self.db.get(Mailbox, mailbox_id) mailbox = await self.db.get(Mailbox, mailbox_id)
@@ -190,8 +190,8 @@ class PermissionService:
user_id: uuid.UUID, user_id: uuid.UUID,
revoked_by: User, revoked_by: User,
) -> None: ) -> None:
if not revoked_by.is_admin: if not revoked_by.is_supervisor_or_admin:
raise ForbiddenError("Solo gli amministratori possono revocare i permessi") raise ForbiddenError("Solo amministratori e supervisori possono revocare i permessi")
result = await self.db.execute( result = await self.db.execute(
delete(MailboxPermission).where( delete(MailboxPermission).where(
@@ -190,11 +190,44 @@ class RoutingRuleService:
return False return False
for cond in conditions: for cond in conditions:
# has_label è una condizione speciale: verifica presenza di MessageLabel
if cond.field == "has_label":
if not await self._condition_has_label(message, cond.operator, cond.value):
return False
else:
field_value = self._get_field_value(message, cond.field) field_value = self._get_field_value(message, cond.field)
if not self._evaluate_condition(field_value, cond.operator, cond.value): if not self._evaluate_condition(field_value, cond.operator, cond.value):
return False return False
return True return True
async def _condition_has_label(
self, message: Message, operator: str, value: str
) -> bool:
"""
Verifica se il messaggio ha (o non ha) una specifica etichetta.
operator 'equals' / 'contains' → True se il messaggio HA la label con UUID=value
operator 'not_contains' → True se il messaggio NON HA la label
"""
try:
label_id = uuid.UUID(value)
except (ValueError, AttributeError):
return False
existing = await self.db.execute(
select(MessageLabel).where(
MessageLabel.message_id == message.id,
MessageLabel.label_id == label_id,
)
)
has_it = existing.scalar_one_or_none() is not None
if operator in ("equals", "contains", "starts_with", "ends_with"):
return has_it
elif operator == "not_contains":
return not has_it
return has_it
def _get_field_value(self, message: Message, field: str) -> str: def _get_field_value(self, message: Message, field: str) -> str:
"""Estrae il valore del campo dal messaggio come stringa per il confronto.""" """Estrae il valore del campo dal messaggio come stringa per il confronto."""
if field == "from_address": if field == "from_address":
@@ -207,6 +240,11 @@ class RoutingRuleService:
return str(message.mailbox_id) return str(message.mailbox_id)
elif field == "pec_type": elif field == "pec_type":
return message.pec_type or "" return message.pec_type or ""
# Rischio e Riservatezza (N3)
elif field == "risk_level":
return message.risk_level or ""
elif field == "confidentiality":
return message.confidentiality or ""
return "" return ""
def _evaluate_condition( def _evaluate_condition(
@@ -241,12 +279,25 @@ class RoutingRuleService:
try: try:
if action.action_type == "apply_label" and action.action_value: if action.action_type == "apply_label" and action.action_value:
await self._action_apply_label(message, uuid.UUID(action.action_value)) await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "apply_taxonomy" and action.action_value:
# Applica un nodo tassonomico: identico a apply_label
# Il nodo è una Label con parent_id valorizzato (Processo o Classificazione)
await self._action_apply_label(message, uuid.UUID(action.action_value))
elif action.action_type == "mark_read": elif action.action_type == "mark_read":
message.is_read = True message.is_read = True
elif action.action_type == "mark_starred": elif action.action_type == "mark_starred":
message.is_starred = True message.is_starred = True
elif action.action_type == "notify_webhook" and action.action_value: elif action.action_type == "notify_webhook" and action.action_value:
await self._action_notify_webhook(message, action.action_value) await self._action_notify_webhook(message, action.action_value)
# Rischio e Riservatezza (N3)
elif action.action_type == "set_risk_level" and action.action_value:
valid_levels = {"low", "medium", "high", "critical"}
if action.action_value in valid_levels:
message.risk_level = action.action_value
elif action.action_type == "set_confidentiality" and action.action_value:
valid_levels = {"public", "internal", "confidential", "secret"}
if action.action_value in valid_levels:
message.confidentiality = action.action_value
except Exception: except Exception:
# Le azioni non devono interrompere il flusso principale # Le azioni non devono interrompere il flusso principale
pass pass
+9
View File
@@ -19,6 +19,10 @@ import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
import { ContactsPage } from '@/pages/Contacts/ContactsPage' import { ContactsPage } from '@/pages/Contacts/ContactsPage'
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage' import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
import { SignaturesPage } from '@/pages/Signatures/SignaturesPage' import { SignaturesPage } from '@/pages/Signatures/SignaturesPage'
import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage'
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
/** /**
* Routing principale dell'applicazione PEChub. * Routing principale dell'applicazione PEChub.
@@ -82,6 +86,7 @@ export default function App() {
<Route path="/mailboxes" element={<MailboxesPage />} /> <Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} /> <Route path="/permissions" element={<PermissionsPage />} />
<Route path="/permission-presets" element={<PermissionPresetsPage />} />
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} /> <Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
<Route path="/notifications" element={<NotificationsPage />} /> <Route path="/notifications" element={<NotificationsPage />} />
@@ -103,6 +108,10 @@ export default function App() {
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} /> <Route path="/deadlines" element={<DeadlinesPage />} />
<Route path="/signatures" element={<SignaturesPage />} /> <Route path="/signatures" element={<SignaturesPage />} />
<Route path="/fascicoli" element={<FascicoliPage />} />
<Route path="/fascicoli/:id" element={<FascicoloDetailPage />} />
{/* Tassonomia di classificazione multi-livello (N2) */}
<Route path="/taxonomy" element={<TaxonomyPage />} />
{/* Profilo utente */} {/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
+120
View File
@@ -0,0 +1,120 @@
import apiClient from './client'
// ─── Tipi ─────────────────────────────────────────────────────────────────────
export interface FascicoloResponse {
id: string
tenant_id: string
titolo: string
numero_pratica: string | null
stato: 'aperto' | 'chiuso' | 'archiviato'
categoria: string | null
responsabile_id: string | null
scadenza: string | null
note: string | null
created_by: string | null
created_at: string
updated_at: string
message_count: number
}
export interface FascicoloCreate {
titolo: string
numero_pratica?: string | null
stato?: 'aperto' | 'chiuso' | 'archiviato'
categoria?: string | null
responsabile_id?: string | null
scadenza?: string | null
note?: string | null
}
export interface FascicoloUpdate {
titolo?: string
numero_pratica?: string | null
stato?: 'aperto' | 'chiuso' | 'archiviato'
categoria?: string | null
responsabile_id?: string | null
scadenza?: string | null
note?: string | null
}
export interface FascicoloMessageItem {
id: string
subject: string | null
from_address: string | null
to_addresses: string[] | null
direction: 'inbound' | 'outbound'
pec_type: string
state: string
mailbox_id: string
received_at: string | null
sent_at: string | null
created_at: string
added_at: string
}
export interface MessageFascicoloSummary {
id: string
titolo: string
numero_pratica: string | null
stato: 'aperto' | 'chiuso' | 'archiviato'
categoria: string | null
}
// ─── Client API ───────────────────────────────────────────────────────────────
export const fascicoliApi = {
/** Lista fascicoli con filtri opzionali */
list: (params?: {
stato?: string
responsabile_id?: string
search?: string
}) =>
apiClient
.get<FascicoloResponse[]>('/fascicoli', { params })
.then((r) => r.data),
/** Dettaglio fascicolo */
get: (id: string) =>
apiClient.get<FascicoloResponse>(`/fascicoli/${id}`).then((r) => r.data),
/** Crea fascicolo */
create: (data: FascicoloCreate) =>
apiClient.post<FascicoloResponse>('/fascicoli', data).then((r) => r.data),
/** Modifica fascicolo */
update: (id: string, data: FascicoloUpdate) =>
apiClient
.patch<FascicoloResponse>(`/fascicoli/${id}`, data)
.then((r) => r.data),
/** Elimina fascicolo */
delete: (id: string) =>
apiClient.delete(`/fascicoli/${id}`).then((r) => r.data),
/** Messaggi del fascicolo */
getMessages: (id: string) =>
apiClient
.get<FascicoloMessageItem[]>(`/fascicoli/${id}/messages`)
.then((r) => r.data),
/** Aggiungi messaggi al fascicolo */
addMessages: (id: string, message_ids: string[]) =>
apiClient
.post<{ added: number }>(`/fascicoli/${id}/messages`, { message_ids })
.then((r) => r.data),
/** Rimuovi messaggi dal fascicolo */
removeMessages: (id: string, message_ids: string[]) =>
apiClient
.delete<{ removed: number }>(`/fascicoli/${id}/messages`, {
data: { message_ids },
})
.then((r) => r.data),
/** Fascicoli a cui appartiene un messaggio */
getMessageFascicoli: (messageId: string) =>
apiClient
.get<MessageFascicoloSummary[]>(`/messages/${messageId}/fascicoli`)
.then((r) => r.data),
}
+5
View File
@@ -2,6 +2,7 @@ import apiClient from './client'
import type { import type {
LabelCreate, LabelCreate,
LabelResponse, LabelResponse,
LabelTreeResponse,
LabelUpdate, LabelUpdate,
MessageBulkLabelRequest, MessageBulkLabelRequest,
MessageBulkLabelResponse, MessageBulkLabelResponse,
@@ -16,6 +17,10 @@ export const labelsApi = {
list: () => list: () =>
apiClient.get<LabelResponse[]>('/labels').then((r) => r.data), apiClient.get<LabelResponse[]>('/labels').then((r) => r.data),
/** Restituisce la tassonomia come albero annidato (Ambito > Processo > Classificazione). */
getTree: () =>
apiClient.get<LabelTreeResponse[]>('/labels/tree').then((r) => r.data),
create: (data: LabelCreate) => create: (data: LabelCreate) =>
apiClient.post<LabelResponse>('/labels', data).then((r) => r.data), apiClient.post<LabelResponse>('/labels', data).then((r) => r.data),
+18
View File
@@ -39,6 +39,18 @@ export interface MessageBulkUpdatePayload {
is_conserved?: boolean is_conserved?: boolean
} }
export interface MessageUpdatePayload {
is_read?: boolean
is_starred?: boolean
is_archived?: boolean
is_trashed?: boolean
is_pending_conservation?: boolean
is_conserved?: boolean
/** Rischio e Riservatezza (N3) — stringa vuota per resettare a null */
risk_level?: string
confidentiality?: string
}
export interface MessageBulkUpdateResponse { export interface MessageBulkUpdateResponse {
updated: number updated: number
items: MessageResponse[] items: MessageResponse[]
@@ -96,6 +108,12 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: false }) .patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: false })
.then((r) => r.data), .then((r) => r.data),
/** Aggiorna uno o piu' campi del messaggio (PATCH generico) — include risk_level/confidentiality (N3) */
update: (id: string, payload: MessageUpdatePayload) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, payload)
.then((r) => r.data),
/** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */ /** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) => bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient apiClient
@@ -0,0 +1,38 @@
import apiClient from './client'
import type {
PermissionPresetCreate,
PermissionPresetResponse,
PermissionPresetUpdate,
} from '@/types/api.types'
export const permissionPresetsApi = {
/**
* Lista tutti i preset del tenant corrente.
*/
list: () =>
apiClient
.get<PermissionPresetResponse[]>('/permission-presets')
.then((r) => r.data),
/**
* Crea un nuovo preset.
*/
create: (data: PermissionPresetCreate) =>
apiClient
.post<PermissionPresetResponse>('/permission-presets', data)
.then((r) => r.data),
/**
* Aggiorna un preset esistente.
*/
update: (id: string, data: PermissionPresetUpdate) =>
apiClient
.put<PermissionPresetResponse>(`/permission-presets/${id}`, data)
.then((r) => r.data),
/**
* Elimina un preset.
*/
delete: (id: string) =>
apiClient.delete(`/permission-presets/${id}`),
}
+24 -2
View File
@@ -1,8 +1,30 @@
import apiClient from './client' import apiClient from './client'
export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type' export type ConditionField =
| 'from_address'
| 'to_address'
| 'subject'
| 'mailbox_id'
| 'pec_type'
/** Tassonomia (N2): verifica se il messaggio ha gia' una specifica etichetta (UUID come valore) */
| 'has_label'
/** Rischio e Riservatezza (N3): verifica il livello gia' impostato sul messaggio */
| 'risk_level'
| 'confidentiality'
export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains' export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
export type ActionType = 'apply_label' | 'assign_vbox' | 'mark_read' | 'mark_starred' | 'notify_webhook'
export type ActionType =
| 'apply_label'
| 'assign_vbox'
| 'mark_read'
| 'mark_starred'
| 'notify_webhook'
/** Tassonomia (N2): applica un nodo tassonomico (Ambito/Processo/Classificazione) */
| 'apply_taxonomy'
/** Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza */
| 'set_risk_level'
| 'set_confidentiality'
export interface RoutingRuleCondition { export interface RoutingRuleCondition {
id: string id: string
+32 -4
View File
@@ -59,6 +59,9 @@ import {
BookUser, BookUser,
Calendar, Calendar,
PenLine, PenLine,
FolderOpen,
Tags,
Sliders,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
@@ -414,6 +417,22 @@ export function Sidebar() {
<div> <div>
<div className="border-t border-gray-700 mx-4 mb-3" /> <div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2 space-y-0.5"> <div className="px-2 space-y-0.5">
<NavLink
to="/fascicoli"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Fascicoli' : undefined}
>
<FolderOpen className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Fascicoli</span>}
</NavLink>
<NavLink <NavLink
to="/deadlines" to="/deadlines"
className={({ isActive }) => className={({ isActive }) =>
@@ -515,8 +534,14 @@ export function Sidebar() {
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />} {collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2"> <div className="space-y-0.5 px-2">
{([
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/permission-presets', label: 'Preset Permessi', icon: Sliders },
] as const).map((item) => (
<NavLink <NavLink
to="/mailboxes" key={item.to}
to={item.to}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
@@ -526,11 +551,12 @@ export function Sidebar() {
collapsed && 'justify-center px-2', collapsed && 'justify-center px-2',
) )
} }
title={collapsed ? 'Caselle PEC' : undefined} title={collapsed ? item.label : undefined}
> >
<MailCheck className="h-5 w-5 flex-shrink-0" /> <item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Caselle PEC</span>} {!collapsed && <span>{item.label}</span>}
</NavLink> </NavLink>
))}
</div> </div>
</div> </div>
)} )}
@@ -553,8 +579,10 @@ export function Sidebar() {
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck }, { to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
{ to: '/users', label: 'Utenti', icon: Users }, { to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield }, { to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/permission-presets', label: 'Preset Permessi', icon: Sliders },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter }, { to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 }, { to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
{ to: '/taxonomy', label: 'Tassonomia', icon: Tags },
{ to: '/templates', label: 'Template messaggi', icon: FileText }, { to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine }, { to: '/signatures', label: 'Firme automatiche', icon: PenLine },
{ to: '/notifications', label: 'Notifiche', icon: Bell }, { to: '/notifications', label: 'Notifiche', icon: Bell },
@@ -0,0 +1,145 @@
/**
* Badge per il Livello di Rischio e la Riservatezza (Feature N3).
*
* RiskLevelBadge mostra il livello di rischio operativo
* ConfidentialityBadge mostra il grado di riservatezza
*/
import { ShieldAlert, Lock } from 'lucide-react'
// ─── Configurazione colori e label ───────────────────────────────────────────
const RISK_CONFIG: Record<string, { label: string; bgClass: string; textClass: string; borderClass: string }> = {
low: {
label: 'Basso',
bgClass: 'bg-green-50',
textClass: 'text-green-700',
borderClass: 'border-green-300',
},
medium: {
label: 'Medio',
bgClass: 'bg-yellow-50',
textClass: 'text-yellow-700',
borderClass: 'border-yellow-300',
},
high: {
label: 'Alto',
bgClass: 'bg-orange-50',
textClass: 'text-orange-700',
borderClass: 'border-orange-300',
},
critical: {
label: 'Critico',
bgClass: 'bg-red-50',
textClass: 'text-red-700',
borderClass: 'border-red-300',
},
}
const CONFIDENTIALITY_CONFIG: Record<string, { label: string; bgClass: string; textClass: string; borderClass: string }> = {
public: {
label: 'Pubblico',
bgClass: 'bg-gray-50',
textClass: 'text-gray-600',
borderClass: 'border-gray-300',
},
internal: {
label: 'Interno',
bgClass: 'bg-blue-50',
textClass: 'text-blue-700',
borderClass: 'border-blue-300',
},
confidential: {
label: 'Riservato',
bgClass: 'bg-purple-50',
textClass: 'text-purple-700',
borderClass: 'border-purple-300',
},
secret: {
label: 'Segreto',
bgClass: 'bg-red-100',
textClass: 'text-red-800',
borderClass: 'border-red-400',
},
}
// ─── Componenti ───────────────────────────────────────────────────────────────
interface RiskLevelBadgeProps {
level: string
/** Se true mostra solo il badge piccolo senza testo (per la lista messaggi) */
compact?: boolean
}
export function RiskLevelBadge({ level, compact = false }: RiskLevelBadgeProps) {
const config = RISK_CONFIG[level]
if (!config) return null
if (compact) {
return (
<span
title={`Rischio: ${config.label}`}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<ShieldAlert className="h-3 w-3" />
{config.label}
</span>
)
}
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<ShieldAlert className="h-3.5 w-3.5" />
Rischio: {config.label}
</span>
)
}
interface ConfidentialityBadgeProps {
level: string
compact?: boolean
}
export function ConfidentialityBadge({ level, compact = false }: ConfidentialityBadgeProps) {
const config = CONFIDENTIALITY_CONFIG[level]
if (!config) return null
if (compact) {
return (
<span
title={`Riservatezza: ${config.label}`}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<Lock className="h-3 w-3" />
{config.label}
</span>
)
}
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<Lock className="h-3.5 w-3.5" />
{config.label}
</span>
)
}
// ─── Costanti esportate per i form ────────────────────────────────────────────
export const RISK_LEVEL_OPTIONS = [
{ value: 'low', label: 'Basso' },
{ value: 'medium', label: 'Medio' },
{ value: 'high', label: 'Alto' },
{ value: 'critical', label: 'Critico' },
]
export const CONFIDENTIALITY_OPTIONS = [
{ value: 'public', label: 'Pubblico' },
{ value: 'internal', label: 'Interno' },
{ value: 'confidential', label: 'Riservato' },
{ value: 'secret', label: 'Segreto' },
]
@@ -0,0 +1,442 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
FolderOpen,
Plus,
Search,
X,
Pencil,
Trash2,
FolderCheck,
FolderArchive,
ChevronRight,
MessageSquare,
Calendar,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { fascicoliApi, type FascicoloResponse, type FascicoloCreate, type FascicoloUpdate } from '@/api/fascicoli.api'
import { formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth'
// ─── Badge stato ──────────────────────────────────────────────────────────────
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
const config: Record<string, { label: string; className: string; Icon: React.ComponentType<{ className?: string }> }> = {
aperto: {
label: 'Aperto',
className: 'bg-green-100 text-green-800 border border-green-200',
Icon: FolderOpen,
},
chiuso: {
label: 'Chiuso',
className: 'bg-gray-100 text-gray-700 border border-gray-200',
Icon: FolderCheck,
},
archiviato: {
label: 'Archiviato',
className: 'bg-amber-100 text-amber-800 border border-amber-200',
Icon: FolderArchive,
},
}
const { label, className, Icon } = config[stato] ?? config.aperto
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
<Icon className="h-3 w-3" />
{label}
</span>
)
}
// ─── Dialog crea / modifica fascicolo ─────────────────────────────────────────
interface FascicoloDialogProps {
fascicolo?: FascicoloResponse
onClose: () => void
}
function FascicoloDialog({ fascicolo, onClose }: FascicoloDialogProps) {
const queryClient = useQueryClient()
const isEdit = !!fascicolo
const [form, setForm] = useState<FascicoloCreate & { stato: 'aperto' | 'chiuso' | 'archiviato' }>({
titolo: fascicolo?.titolo ?? '',
numero_pratica: fascicolo?.numero_pratica ?? '',
stato: fascicolo?.stato ?? 'aperto',
categoria: fascicolo?.categoria ?? '',
scadenza: fascicolo?.scadenza ? fascicolo.scadenza.substring(0, 16) : '',
note: fascicolo?.note ?? '',
})
const createMutation = useMutation({
mutationFn: (data: FascicoloCreate) => fascicoliApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo creato')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: (data: FascicoloUpdate) => fascicoliApi.update(fascicolo!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo aggiornato')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSubmit = () => {
if (!form.titolo.trim()) {
toast.error('Il titolo e obbligatorio')
return
}
const payload = {
titolo: form.titolo.trim(),
numero_pratica: form.numero_pratica?.trim() || null,
stato: form.stato,
categoria: form.categoria?.trim() || null,
scadenza: form.scadenza ? new Date(form.scadenza).toISOString() : null,
note: form.note?.trim() || null,
}
if (isEdit) {
updateMutation.mutate(payload)
} else {
createMutation.mutate(payload)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-lg font-semibold flex items-center gap-2">
<FolderOpen className="h-5 w-5 text-primary" />
{isEdit ? 'Modifica fascicolo' : 'Nuovo fascicolo'}
</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4">
<div className="space-y-1">
<Label>Titolo *</Label>
<Input
value={form.titolo}
onChange={(e) => setForm((f) => ({ ...f, titolo: e.target.value }))}
placeholder="Es. Procedura espropriativa via Roma 12"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Numero pratica</Label>
<Input
value={form.numero_pratica ?? ''}
onChange={(e) => setForm((f) => ({ ...f, numero_pratica: e.target.value }))}
placeholder="Es. 2024/0042"
/>
</div>
<div className="space-y-1">
<Label>Stato</Label>
<select
value={form.stato}
onChange={(e) => setForm((f) => ({ ...f, stato: e.target.value as typeof form.stato }))}
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="aperto">Aperto</option>
<option value="chiuso">Chiuso</option>
<option value="archiviato">Archiviato</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Categoria</Label>
<Input
value={form.categoria ?? ''}
onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value }))}
placeholder="Es. Contenzioso, Contratti..."
/>
</div>
<div className="space-y-1">
<Label>Scadenza</Label>
<Input
type="datetime-local"
value={form.scadenza ?? ''}
onChange={(e) => setForm((f) => ({ ...f, scadenza: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<Label>Note</Label>
<textarea
value={form.note ?? ''}
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
placeholder="Descrizione, riferimenti normativi..."
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-5 py-4 border-t">
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={isPending}>
{isEdit ? 'Salva modifiche' : 'Crea fascicolo'}
</Button>
</div>
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function FascicoliPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [search, setSearch] = useState('')
const [filterStato, setFilterStato] = useState('')
const [showDialog, setShowDialog] = useState(false)
const [editFascicolo, setEditFascicolo] = useState<FascicoloResponse | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<FascicoloResponse | null>(null)
const { data: fascicoli = [], isLoading } = useQuery({
queryKey: ['fascicoli', filterStato, search],
queryFn: () =>
fascicoliApi.list({
stato: filterStato || undefined,
search: search || undefined,
}),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => fascicoliApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo eliminato')
setDeleteConfirm(null)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<FolderOpen className="h-5 w-5 text-primary" />
Fascicoli
</h1>
<p className="text-sm text-muted-foreground">
{fascicoli.length} {fascicoli.length === 1 ? 'fascicolo' : 'fascicoli'}
</p>
</div>
<Button onClick={() => { setEditFascicolo(null); setShowDialog(true) }}>
<Plus className="h-4 w-4 mr-1" />
Nuovo fascicolo
</Button>
</div>
{/* Filtri */}
<div className="border-b bg-background px-6 py-3 flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cerca per titolo, numero pratica..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
value={filterStato}
onChange={(e) => setFilterStato(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Tutti gli stati</option>
<option value="aperto">Aperti</option>
<option value="chiuso">Chiusi</option>
<option value="archiviato">Archiviati</option>
</select>
{(search || filterStato) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setSearch(''); setFilterStato('') }}
>
<X className="h-4 w-4 mr-1" />
Pulisci
</Button>
)}
</div>
{/* Contenuto */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : fascicoli.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<FolderOpen className="h-14 w-14 mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium">Nessun fascicolo trovato</p>
<p className="text-sm mt-1">
{search || filterStato
? 'Prova a modificare i filtri di ricerca.'
: 'Crea il primo fascicolo per raggruppare le comunicazioni PEC.'}
</p>
{!search && !filterStato && (
<Button
className="mt-4"
onClick={() => { setEditFascicolo(null); setShowDialog(true) }}
>
<Plus className="h-4 w-4 mr-1" />
Crea fascicolo
</Button>
)}
</div>
) : (
<div className="max-w-4xl mx-auto space-y-3">
{fascicoli.map((f) => (
<div
key={f.id}
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer group"
onClick={() => navigate(`/fascicoli/${f.id}`)}
>
{/* Icona stato */}
<div className="flex-shrink-0">
{f.stato === 'aperto' && <FolderOpen className="h-6 w-6 text-green-600" />}
{f.stato === 'chiuso' && <FolderCheck className="h-6 w-6 text-gray-500" />}
{f.stato === 'archiviato' && <FolderArchive className="h-6 w-6 text-amber-600" />}
</div>
{/* Info principale */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold truncate">{f.titolo}</p>
<StatoBadge stato={f.stato} />
{f.numero_pratica && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded">
#{f.numero_pratica}
</span>
)}
{f.categoria && (
<span className="text-xs text-muted-foreground bg-blue-50 border border-blue-100 px-1.5 py-0.5 rounded">
{f.categoria}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{f.message_count} {f.message_count === 1 ? 'messaggio' : 'messaggi'}
</span>
{f.scadenza && (
<span className="flex items-center gap-1 text-amber-600">
<Calendar className="h-3 w-3" />
Scade: {formatDate(f.scadenza)}
</span>
)}
<span>Aggiornato: {formatDate(f.updated_at)}</span>
</div>
{f.note && (
<p className="text-xs text-muted-foreground italic mt-0.5 truncate">{f.note}</p>
)}
</div>
{/* Azioni */}
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setEditFascicolo(f)
setShowDialog(true)
}}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Modifica"
>
<Pencil className="h-4 w-4" />
</button>
{isAdmin && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteConfirm(f)
}}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Elimina"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
))}
</div>
)}
</div>
{/* Dialog crea / modifica */}
{showDialog && (
<FascicoloDialog
fascicolo={editFascicolo ?? undefined}
onClose={() => { setShowDialog(false); setEditFascicolo(null) }}
/>
)}
{/* Dialog conferma eliminazione */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl p-6 w-full max-w-md space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2 text-destructive">
<Trash2 className="h-5 w-5" />
Elimina fascicolo
</h3>
<p className="text-sm text-muted-foreground">
Stai per eliminare il fascicolo{' '}
<strong>{deleteConfirm.titolo}</strong>.
I messaggi collegati non verranno eliminati.
Questa operazione non puo essere annullata.
</p>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Annulla
</Button>
<Button
variant="destructive"
isLoading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(deleteConfirm.id)}
>
Elimina
</Button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,547 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft,
FolderOpen,
FolderCheck,
FolderArchive,
MessageSquare,
Calendar,
Pencil,
Trash2,
Plus,
X,
Search,
Inbox,
Send,
ExternalLink,
AlertTriangle,
CheckCircle2,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
fascicoliApi,
type FascicoloResponse,
type FascicoloMessageItem,
type FascicoloUpdate,
} from '@/api/fascicoli.api'
import { messagesApi } from '@/api/messages.api'
import { formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
// ─── Badge stato ──────────────────────────────────────────────────────────────
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
const config: Record<string, { label: string; className: string }> = {
aperto: { label: 'Aperto', className: 'bg-green-100 text-green-800 border border-green-200' },
chiuso: { label: 'Chiuso', className: 'bg-gray-100 text-gray-700 border border-gray-200' },
archiviato: { label: 'Archiviato', className: 'bg-amber-100 text-amber-800 border border-amber-200' },
}
const { label, className } = config[stato] ?? config.aperto
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
{label}
</span>
)
}
// ─── Dialog modifica fascicolo ────────────────────────────────────────────────
interface EditDialogProps {
fascicolo: FascicoloResponse
onClose: () => void
}
function EditDialog({ fascicolo, onClose }: EditDialogProps) {
const queryClient = useQueryClient()
const [form, setForm] = useState<FascicoloUpdate & { stato: 'aperto' | 'chiuso' | 'archiviato' }>({
titolo: fascicolo.titolo,
numero_pratica: fascicolo.numero_pratica ?? '',
stato: fascicolo.stato,
categoria: fascicolo.categoria ?? '',
scadenza: fascicolo.scadenza ? fascicolo.scadenza.substring(0, 16) : '',
note: fascicolo.note ?? '',
})
const updateMutation = useMutation({
mutationFn: (data: FascicoloUpdate) => fascicoliApi.update(fascicolo.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicolo', fascicolo.id] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo aggiornato')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const handleSubmit = () => {
if (!form.titolo?.trim()) { toast.error('Il titolo e obbligatorio'); return }
updateMutation.mutate({
titolo: form.titolo?.trim(),
numero_pratica: (form.numero_pratica as string)?.trim() || null,
stato: form.stato,
categoria: (form.categoria as string)?.trim() || null,
scadenza: form.scadenza ? new Date(form.scadenza as string).toISOString() : null,
note: (form.note as string)?.trim() || null,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-lg font-semibold">Modifica fascicolo</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-muted"><X className="h-5 w-5" /></button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="space-y-1">
<Label>Titolo *</Label>
<Input value={form.titolo ?? ''} onChange={(e) => setForm((f) => ({ ...f, titolo: e.target.value }))} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Numero pratica</Label>
<Input value={form.numero_pratica as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, numero_pratica: e.target.value }))} placeholder="Es. 2024/0042" />
</div>
<div className="space-y-1">
<Label>Stato</Label>
<select value={form.stato} onChange={(e) => setForm((f) => ({ ...f, stato: e.target.value as typeof form.stato }))}
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="aperto">Aperto</option>
<option value="chiuso">Chiuso</option>
<option value="archiviato">Archiviato</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Categoria</Label>
<Input value={form.categoria as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Scadenza</Label>
<Input type="datetime-local" value={form.scadenza as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, scadenza: e.target.value }))} />
</div>
</div>
<div className="space-y-1">
<Label>Note</Label>
<textarea value={form.note as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))} rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
</div>
</div>
<div className="flex justify-end gap-3 px-5 py-4 border-t">
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={updateMutation.isPending}>Salva modifiche</Button>
</div>
</div>
</div>
)
}
// ─── Modal selezione messaggi da aggiungere ───────────────────────────────────
interface AddMessagesModalProps {
fascicoloId: string
existingIds: Set<string>
onClose: () => void
}
function AddMessagesModal({ fascicoloId, existingIds, onClose }: AddMessagesModalProps) {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const { data: messages = [] } = useQuery({
queryKey: ['messages-for-fascicolo', search],
queryFn: async () => {
// Carica messaggi recenti (non gia' nel fascicolo)
const params: Record<string, string | number> = { page: 1, page_size: 50, direction: 'all' }
if (search) params.search = search
const result = await messagesApi.list(params)
const items = (result as any).items ?? result
return items.filter((m: { id: string }) => !existingIds.has(m.id))
},
})
const addMutation = useMutation({
mutationFn: () => fascicoliApi.addMessages(fascicoloId, Array.from(selected)),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['fascicolo', fascicoloId] })
queryClient.invalidateQueries({ queryKey: ['fascicolo-messages', fascicoloId] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success(`${data.added} messaggi aggiunti al fascicolo`)
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[80vh]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b flex-shrink-0">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5 text-primary" />
Aggiungi messaggi al fascicolo
</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-muted"><X className="h-5 w-5" /></button>
</div>
{/* Ricerca */}
<div className="px-5 py-3 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input className="pl-8" placeholder="Cerca messaggi..." value={search} onChange={(e) => setSearch(e.target.value)} autoFocus />
</div>
</div>
{/* Lista messaggi */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1.5">
{messages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Nessun messaggio disponibile</p>
</div>
) : (
messages.map((msg: FascicoloMessageItem & { id: string; subject?: string; from_address?: string; to_addresses?: string[]; direction?: string; state?: string; received_at?: string; sent_at?: string }) => (
<label
key={msg.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selected.has(msg.id) ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
}`}
>
<input
type="checkbox"
checked={selected.has(msg.id)}
onChange={() => toggleSelect(msg.id)}
className="h-4 w-4 accent-primary flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{msg.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground truncate">
{msg.direction === 'inbound' ? `Da: ${msg.from_address}` : `A: ${(msg.to_addresses ?? []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
</div>
<div className="flex-shrink-0">
{msg.direction === 'inbound'
? <Inbox className="h-4 w-4 text-blue-500" />
: <Send className="h-4 w-4 text-green-500" />}
</div>
</label>
))
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-4 border-t flex-shrink-0">
<p className="text-sm text-muted-foreground">
{selected.size > 0 ? `${selected.size} messaggi selezionati` : 'Seleziona i messaggi da aggiungere'}
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button
onClick={() => addMutation.mutate()}
disabled={selected.size === 0}
isLoading={addMutation.isPending}
>
Aggiungi {selected.size > 0 ? `(${selected.size})` : ''}
</Button>
</div>
</div>
</div>
</div>
)
}
// ─── Pagina dettaglio fascicolo ───────────────────────────────────────────────
export function FascicoloDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [showEditDialog, setShowEditDialog] = useState(false)
const [showAddMessages, setShowAddMessages] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const { data: fascicolo, isLoading: loadingFasc } = useQuery({
queryKey: ['fascicolo', id],
queryFn: () => fascicoliApi.get(id!),
enabled: !!id,
})
const { data: messages = [], isLoading: loadingMsgs } = useQuery({
queryKey: ['fascicolo-messages', id],
queryFn: () => fascicoliApi.getMessages(id!),
enabled: !!id,
})
const removeMsgMutation = useMutation({
mutationFn: (msgId: string) => fascicoliApi.removeMessages(id!, [msgId]),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicolo-messages', id] })
queryClient.invalidateQueries({ queryKey: ['fascicolo', id] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Messaggio rimosso dal fascicolo')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteFascicoloMutation = useMutation({
mutationFn: () => fascicoliApi.delete(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo eliminato')
navigate('/fascicoli')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
if (loadingFasc) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!fascicolo) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<AlertTriangle className="h-10 w-10 text-muted-foreground opacity-40" />
<p className="text-muted-foreground">Fascicolo non trovato</p>
<Button variant="outline" onClick={() => navigate('/fascicoli')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Torna ai fascicoli
</Button>
</div>
)
}
const existingIds = new Set(messages.map((m) => m.id))
const FolderIcon = fascicolo.stato === 'aperto'
? FolderOpen
: fascicolo.stato === 'chiuso'
? FolderCheck
: FolderArchive
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="border-b bg-background px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/fascicoli')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span className="text-sm text-muted-foreground">Fascicoli</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowEditDialog(true)}>
<Pencil className="h-4 w-4 mr-1" />
Modifica
</Button>
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setDeleteConfirm(true)}
className="text-destructive border-destructive/30 hover:bg-destructive/10">
<Trash2 className="h-4 w-4 mr-1" />
Elimina
</Button>
)}
</div>
</div>
{/* Intestazione fascicolo */}
<div className="border-b bg-muted/30 px-6 py-5">
<div className="max-w-4xl mx-auto">
<div className="flex items-start gap-4">
<FolderIcon className={`h-10 w-10 flex-shrink-0 mt-0.5 ${
fascicolo.stato === 'aperto' ? 'text-green-600'
: fascicolo.stato === 'chiuso' ? 'text-gray-500'
: 'text-amber-600'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold">{fascicolo.titolo}</h1>
<StatoBadge stato={fascicolo.stato} />
{fascicolo.numero_pratica && (
<span className="text-sm text-muted-foreground font-mono bg-background border px-2 py-0.5 rounded">
#{fascicolo.numero_pratica}
</span>
)}
</div>
<div className="flex items-center gap-6 mt-2 text-sm text-muted-foreground flex-wrap">
{fascicolo.categoria && (
<span className="flex items-center gap-1.5">
<span className="font-medium">Categoria:</span> {fascicolo.categoria}
</span>
)}
{fascicolo.scadenza && (
<span className="flex items-center gap-1.5 text-amber-700">
<Calendar className="h-4 w-4" />
<span className="font-medium">Scadenza:</span> {formatDate(fascicolo.scadenza)}
</span>
)}
<span className="flex items-center gap-1.5">
<MessageSquare className="h-4 w-4" />
{fascicolo.message_count} {fascicolo.message_count === 1 ? 'messaggio' : 'messaggi'}
</span>
<span>Aggiornato: {formatDate(fascicolo.updated_at)}</span>
</div>
{fascicolo.note && (
<p className="mt-2 text-sm text-muted-foreground italic">{fascicolo.note}</p>
)}
</div>
</div>
</div>
</div>
{/* Sezione messaggi */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-primary" />
Messaggi nel fascicolo ({messages.length})
</h2>
<Button size="sm" onClick={() => setShowAddMessages(true)}>
<Plus className="h-4 w-4 mr-1" />
Aggiungi messaggi
</Button>
</div>
{loadingMsgs ? (
<div className="flex justify-center py-10">
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : messages.length === 0 ? (
<div className="text-center py-14 text-muted-foreground border-2 border-dashed rounded-xl">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">Nessun messaggio nel fascicolo</p>
<p className="text-sm mt-1">Aggiungi messaggi PEC per costruire il fascicolo.</p>
<Button className="mt-4" size="sm" onClick={() => setShowAddMessages(true)}>
<Plus className="h-4 w-4 mr-1" />
Aggiungi messaggi
</Button>
</div>
) : (
<div className="space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow group"
>
{/* Icona direzione */}
<div className="flex-shrink-0">
{msg.direction === 'inbound'
? <Inbox className="h-5 w-5 text-blue-500" />
: <Send className="h-5 w-5 text-green-500" />}
</div>
{/* Info messaggio */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{msg.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{msg.direction === 'inbound'
? `Da: ${msg.from_address}`
: `A: ${(msg.to_addresses ?? []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
<p className="text-xs text-muted-foreground/70">
Aggiunto: {formatDate(msg.added_at)}
</p>
</div>
{/* Badge stato */}
<div className="flex-shrink-0">
<PecStateBadge state={msg.state} />
</div>
{/* Azioni */}
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => navigate(`/messages/${msg.id}`)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary"
title="Apri messaggio"
>
<ExternalLink className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeMsgMutation.mutate(msg.id)}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Rimuovi dal fascicolo"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Modal modifica */}
{showEditDialog && (
<EditDialog fascicolo={fascicolo} onClose={() => setShowEditDialog(false)} />
)}
{/* Modal aggiungi messaggi */}
{showAddMessages && (
<AddMessagesModal
fascicoloId={id!}
existingIds={existingIds}
onClose={() => setShowAddMessages(false)}
/>
)}
{/* Conferma eliminazione */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl p-6 w-full max-w-md space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2 text-destructive">
<Trash2 className="h-5 w-5" />
Elimina fascicolo
</h3>
<p className="text-sm text-muted-foreground">
Stai per eliminare <strong>{fascicolo.titolo}</strong>.
I messaggi collegati non verranno eliminati.
Questa operazione non puo essere annullata.
</p>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)}>Annulla</Button>
<Button
variant="destructive"
isLoading={deleteFascicoloMutation.isPending}
onClick={() => deleteFascicoloMutation.mutate()}
>
Elimina
</Button>
</div>
</div>
</div>
)}
</div>
)
}
+9
View File
@@ -52,6 +52,7 @@ import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge' import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge' import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector' import { TagSelector } from '@/components/TagManager/TagSelector'
import { RiskLevelBadge, ConfidentialityBadge } from '@/components/RiskBadge/RiskBadge'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { messagesApi } from '@/api/messages.api' import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api' import { labelsApi } from '@/api/labels.api'
@@ -1093,6 +1094,14 @@ function MessageRow({
</div> </div>
)} )}
{/* Badge Rischio e Riservatezza (Feature N3) */}
{(message.risk_level || message.confidentiality) && (
<div className="flex items-center gap-1.5 mt-1" onClick={(e) => e.stopPropagation()}>
{message.risk_level && <RiskLevelBadge level={message.risk_level} compact />}
{message.confidentiality && <ConfidentialityBadge level={message.confidentiality} compact />}
</div>
)}
{message.body_text && ( {message.body_text && (
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate mt-0.5">
{truncate(message.body_text, 120)} {truncate(message.body_text, 120)}
@@ -14,6 +14,7 @@ import {
Mail, Mail,
Send, Send,
Tag, Tag,
Tags,
Trash2, Trash2,
RotateCcw, RotateCcw,
MailX, MailX,
@@ -24,6 +25,8 @@ import {
MessageSquare, MessageSquare,
X, X,
ChevronDown, ChevronDown,
FolderOpen,
Plus,
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -33,9 +36,12 @@ import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree' import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { TagBadge } from '@/components/TagManager/TagBadge' import { TagBadge } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector' import { TagSelector } from '@/components/TagManager/TagSelector'
import { RiskLevelBadge, ConfidentialityBadge, RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
import { messagesApi } from '@/api/messages.api' import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api' import { labelsApi } from '@/api/labels.api'
import { deadlinesApi } from '@/api/deadlines.api' import { deadlinesApi } from '@/api/deadlines.api'
import { fascicoliApi, type FascicoloResponse } from '@/api/fascicoli.api'
import type { LabelResponse, MessageResponse } from '@/types/api.types'
import { formatDate, formatBytes } from '@/lib/utils' import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client' import { getErrorMessage } from '@/api/client'
import apiClient from '@/api/client' import apiClient from '@/api/client'
@@ -158,6 +164,426 @@ function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType
) )
} }
// ─── Widget Fascicoli nel dettaglio messaggio (N1) ────────────────────────────
function FascicoliWidget({ messageId, navigate }: { messageId: string; navigate: (to: string) => void }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const { data: fascicoli = [] } = useQuery({
queryKey: ['message-fascicoli', messageId],
queryFn: () => fascicoliApi.getMessageFascicoli(messageId),
})
// Lista di tutti i fascicoli del tenant per il dropdown di aggiunta
const { data: allFascicoli = [] } = useQuery({
queryKey: ['fascicoli'],
queryFn: () => fascicoliApi.list(),
enabled: showAddModal,
})
const addMutation = useMutation({
mutationFn: (fascicoloId: string) =>
fascicoliApi.addMessages(fascicoloId, [messageId]),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['message-fascicoli', messageId] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Messaggio aggiunto al fascicolo')
setShowAddModal(false)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const alreadyInIds = new Set(fascicoli.map((f) => f.id))
const available = (allFascicoli as FascicoloResponse[]).filter((f) => !alreadyInIds.has(f.id) && f.stato !== 'archiviato')
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
Fascicoli ({fascicoli.length})
</h3>
<div className="rounded-lg border bg-background p-3">
{fascicoli.length === 0 ? (
<p className="text-sm text-muted-foreground">Questo messaggio non appartiene a nessun fascicolo.</p>
) : (
<div className="flex flex-wrap gap-2 mb-2">
{fascicoli.map((f) => (
<button
key={f.id}
type="button"
onClick={() => navigate(`/fascicoli/${f.id}`)}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 transition-colors"
>
<FolderOpen className="h-3.5 w-3.5" />
{f.titolo}
{f.numero_pratica && (
<span className="text-xs opacity-70">#{f.numero_pratica}</span>
)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => setShowAddModal(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Aggiungi a fascicolo
</button>
</div>
{/* Modal selezione fascicolo */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-base font-semibold flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-primary" />
Aggiungi a fascicolo
</h3>
<button onClick={() => setShowAddModal(false)} className="p-1 rounded hover:bg-muted">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-5 py-3 max-h-72 overflow-y-auto space-y-1">
{available.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<FolderOpen className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p className="text-sm">
{allFascicoli.length === 0
? 'Nessun fascicolo disponibile. Creane uno dalla pagina Fascicoli.'
: 'Il messaggio e gia\' in tutti i fascicoli aperti.'}
</p>
</div>
) : (
available.map((f) => (
<button
key={f.id}
type="button"
onClick={() => addMutation.mutate(f.id)}
disabled={addMutation.isPending}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted transition-colors text-left"
>
<FolderOpen className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{f.titolo}</p>
{f.numero_pratica && (
<p className="text-xs text-muted-foreground">#{f.numero_pratica}</p>
)}
</div>
</button>
))
)}
</div>
<div className="flex justify-between items-center px-5 py-3 border-t">
<button
type="button"
onClick={() => navigate('/fascicoli')}
className="text-xs text-primary hover:underline"
>
Gestisci fascicoli
</button>
<Button variant="outline" size="sm" onClick={() => setShowAddModal(false)}>
Chiudi
</Button>
</div>
</div>
</div>
)}
</div>
)
}
// ─── Widget Tassonomia nel dettaglio messaggio (N2) ───────────────────────────
function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messageLabels: LabelResponse[] }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const [selectedLabelId, setSelectedLabelId] = useState('')
// Lista flat di tutte le label del tenant (per costruire i percorsi)
const { data: allLabels = [] } = useQuery({
queryKey: ['labels'],
queryFn: () => labelsApi.list(),
})
// Le label tassonomiche del messaggio sono quelle con parent_id != null
const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null)
// Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione"
function buildPath(labelId: string): string {
const map = new Map<string, LabelResponse>(allLabels.map((l: LabelResponse) => [l.id, l]))
const parts: string[] = []
let current: LabelResponse | undefined = map.get(labelId)
while (current) {
parts.unshift(current.name)
const parentId = current.parent_id
if (parentId) {
current = map.get(parentId)
} else {
break
}
}
return parts.join(' > ')
}
// Label disponibili per l'aggiunta (non ancora assegnate)
const alreadyAssignedIds = new Set(messageLabels.map((l) => l.id))
const availableForAdd = allLabels.filter((l: LabelResponse) => !alreadyAssignedIds.has(l.id))
const addMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.addMessageLabels(messageId, { label_ids: [labelId] }),
onSuccess: (updatedLabels: LabelResponse[]) => {
queryClient.setQueryData(['message', messageId], (old: any) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
queryClient.invalidateQueries({ queryKey: ['messages'] })
setShowAddModal(false)
setSelectedLabelId('')
toast.success('Classificazione aggiunta')
},
onError: (e: unknown) => toast.error(getErrorMessage(e)),
})
const removeMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.removeMessageLabels(messageId, { label_ids: [labelId] }),
onSuccess: (updatedLabels: LabelResponse[]) => {
queryClient.setQueryData(['message', messageId], (old: any) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success('Classificazione rimossa')
},
onError: (e: unknown) => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Tags className="h-4 w-4" />
Classificazione tassonomica ({taxonomyLabels.length})
</h3>
<div className="rounded-lg border bg-background p-3">
{taxonomyLabels.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nessuna classificazione tassonomica assegnata a questo messaggio.
</p>
) : (
<div className="flex flex-wrap gap-2 mb-2">
{taxonomyLabels.map((lbl) => {
const path = buildPath(lbl.id)
return (
<span
key={lbl.id}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm border bg-background"
style={
lbl.color
? {
backgroundColor: lbl.color + '20',
borderColor: lbl.color + '60',
color: lbl.color,
}
: {}
}
>
{lbl.color && (
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: lbl.color }}
/>
)}
<span className="font-medium">{path || lbl.name}</span>
<button
type="button"
onClick={() => removeMutation.mutate(lbl.id)}
disabled={removeMutation.isPending}
className="ml-1 opacity-50 hover:opacity-100 transition-opacity"
title="Rimuovi classificazione"
>
<X className="h-3 w-3" />
</button>
</span>
)
})}
</div>
)}
<button
type="button"
onClick={() => { setSelectedLabelId(''); setShowAddModal(true) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Aggiungi classificazione
</button>
</div>
{/* Modal selezione classificazione */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-base font-semibold flex items-center gap-2">
<Tags className="h-4 w-4 text-primary" />
Aggiungi classificazione tassonomica
</h3>
<button onClick={() => setShowAddModal(false)} className="p-1 rounded hover:bg-muted">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-sm text-muted-foreground">
Seleziona un nodo della tassonomia da assegnare a questo messaggio.
Il percorso mostra: Ambito &gt; Processo &gt; Classificazione.
</p>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
value={selectedLabelId}
onChange={(e) => setSelectedLabelId(e.target.value)}
>
<option value="">-- Seleziona classificazione --</option>
{availableForAdd.map((l: LabelResponse) => (
<option key={l.id} value={l.id}>
{buildPath(l.id) || l.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 px-5 py-3 border-t">
<Button variant="outline" size="sm" onClick={() => setShowAddModal(false)}>
Annulla
</Button>
<Button
size="sm"
disabled={!selectedLabelId}
isLoading={addMutation.isPending}
onClick={() => selectedLabelId && addMutation.mutate(selectedLabelId)}
>
Assegna
</Button>
</div>
</div>
</div>
)}
</div>
)
}
// ─── Widget Rischio e Riservatezza (N3) ──────────────────────────────────────
function RiskConfidentialityWidget({
message,
onUpdate,
}: {
message: MessageResponse
onUpdate: (updated: MessageResponse) => void
}) {
const [riskLevel, setRiskLevel] = useState<string>(message.risk_level ?? '')
const [confidentiality, setConfidentiality] = useState<string>(message.confidentiality ?? '')
const [isSaving, setIsSaving] = useState(false)
// Sincronizza stato locale quando il messaggio cambia
useEffect(() => {
setRiskLevel(message.risk_level ?? '')
setConfidentiality(message.confidentiality ?? '')
}, [message.risk_level, message.confidentiality])
const hasCurrent = message.risk_level || message.confidentiality
const hasChanges =
riskLevel !== (message.risk_level ?? '') ||
confidentiality !== (message.confidentiality ?? '')
const handleSave = async () => {
setIsSaving(true)
try {
const updated = await messagesApi.update(message.id, {
risk_level: riskLevel || '',
confidentiality: confidentiality || '',
})
onUpdate(updated)
toast.success('Rischio e riservatezza salvati')
} catch (e) {
toast.error(getErrorMessage(e))
} finally {
setIsSaving(false)
}
}
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<span className="inline-block h-4 w-4 text-orange-500">
{/* icona shield */}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
Rischio e Riservatezza
</h3>
<div className="rounded-lg border bg-background p-4 space-y-4">
{/* Badge correnti */}
{hasCurrent && (
<div className="flex items-center gap-2 flex-wrap">
{message.risk_level && (
<RiskLevelBadge level={message.risk_level} />
)}
{message.confidentiality && (
<ConfidentialityBadge level={message.confidentiality} />
)}
</div>
)}
{/* Form di modifica */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Livello di rischio</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
value={riskLevel}
onChange={(e) => setRiskLevel(e.target.value)}
>
<option value="">-- Nessun rischio --</option>
{RISK_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Riservatezza</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
value={confidentiality}
onChange={(e) => setConfidentiality(e.target.value)}
>
<option value="">-- Nessuna riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{hasChanges && (
<div className="flex justify-end">
<Button
size="sm"
isLoading={isSaving}
onClick={handleSave}
>
Salva
</Button>
</div>
)}
</div>
</div>
)
}
// ─── Pagina principale ──────────────────────────────────────────────────────── // ─── Pagina principale ────────────────────────────────────────────────────────
export function MessageDetailPage() { export function MessageDetailPage() {
@@ -924,6 +1350,18 @@ export function MessageDetailPage() {
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} /> <ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
)} )}
{/* Fascicoli (N1) */}
<FascicoliWidget messageId={message.id} navigate={navigate} />
{/* Classificazione Tassonomica (N2) */}
<TaxonomyWidget messageId={message.id} messageLabels={message.labels || []} />
{/* Rischio e Riservatezza (N3) */}
<RiskConfidentialityWidget message={message} onUpdate={(updated) => {
queryClient.setQueryData(['message', id], updated)
queryClient.invalidateQueries({ queryKey: ['messages'] })
}} />
{/* Messaggio originale per ricevute */} {/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && ( {message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4"> <div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
@@ -0,0 +1,344 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Sliders, Plus, Pencil, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { permissionPresetsApi } from '@/api/permission_presets.api'
import { getErrorMessage } from '@/api/client'
import { cn } from '@/lib/utils'
import type { PermissionPresetResponse } from '@/types/api.types'
// ─── Flag visualizzazione permesso ───────────────────────────────────────────
interface PermissionBadgeProps {
active: boolean
label: string
}
function PermissionBadge({ active, label }: PermissionBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
active
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-muted text-muted-foreground border border-border',
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', active ? 'bg-primary' : 'bg-muted-foreground/40')} />
{label}
</span>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function PermissionPresetsPage() {
const queryClient = useQueryClient()
const [showDialog, setShowDialog] = useState(false)
const [editingPreset, setEditingPreset] = useState<PermissionPresetResponse | null>(null)
const { data: presets = [], isLoading } = useQuery({
queryKey: ['permission-presets'],
queryFn: () => permissionPresetsApi.list(),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => permissionPresetsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['permission-presets'] })
toast.success('Preset eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const handleEdit = (preset: PermissionPresetResponse) => {
setEditingPreset(preset)
setShowDialog(true)
}
const handleNew = () => {
setEditingPreset(null)
setShowDialog(true)
}
const handleDelete = (preset: PermissionPresetResponse) => {
if (!confirm(`Eliminare il preset "${preset.name}"? L'operazione non e' reversibile.`)) return
deleteMutation.mutate(preset.id)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Sliders className="h-5 w-5 text-primary" />
<div>
<h1 className="text-xl font-semibold">Preset Permessi</h1>
<p className="text-sm text-muted-foreground">
Definisci template di permessi riutilizzabili (sottoruoli) per gli operatori
</p>
</div>
</div>
<Button onClick={handleNew}>
<Plus className="h-4 w-4 mr-2" />
Nuovo preset
</Button>
</div>
{/* Contenuto */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : presets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Sliders className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="font-medium mb-1">Nessun preset definito</p>
<p className="text-sm text-muted-foreground mb-4">
Crea dei preset per applicare rapidamente combinazioni di permessi agli operatori.
</p>
<Button onClick={handleNew}>
<Plus className="h-4 w-4 mr-2" />
Crea primo preset
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{presets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
onEdit={() => handleEdit(preset)}
onDelete={() => handleDelete(preset)}
isDeleting={deleteMutation.isPending}
/>
))}
</div>
)}
</div>
{/* Dialog crea/modifica */}
{showDialog && (
<PresetDialog
preset={editingPreset}
onClose={() => setShowDialog(false)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['permission-presets'] })
setShowDialog(false)
}}
/>
)}
</div>
)
}
// ─── Card preset ──────────────────────────────────────────────────────────────
interface PresetCardProps {
preset: PermissionPresetResponse
onEdit: () => void
onDelete: () => void
isDeleting: boolean
}
function PresetCard({ preset, onEdit, onDelete, isDeleting }: PresetCardProps) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-3 hover:shadow-sm transition-shadow">
{/* Titolo e descrizione */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold truncate">{preset.name}</h3>
{preset.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{preset.description}
</p>
)}
</div>
</div>
{/* Permessi */}
<div className="flex flex-wrap gap-1.5">
<PermissionBadge active={preset.can_read} label="Lettura" />
<PermissionBadge active={preset.can_send} label="Invio" />
<PermissionBadge active={preset.can_manage} label="Gestione" />
<PermissionBadge active={preset.can_conserve} label="Conservazione" />
</div>
{/* Azioni */}
<div className="flex justify-end gap-2 pt-1 border-t">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-7 text-xs"
>
<Pencil className="h-3 w-3 mr-1" />
Modifica
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
disabled={isDeleting}
className="h-7 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
Elimina
</Button>
</div>
</div>
)
}
// ─── Dialog crea/modifica preset ─────────────────────────────────────────────
interface PresetDialogProps {
preset: PermissionPresetResponse | null
onClose: () => void
onSaved: () => void
}
function PresetDialog({ preset, onClose, onSaved }: PresetDialogProps) {
const isEdit = preset !== null
const [name, setName] = useState(preset?.name ?? '')
const [description, setDescription] = useState(preset?.description ?? '')
const [canRead, setCanRead] = useState(preset?.can_read ?? true)
const [canSend, setCanSend] = useState(preset?.can_send ?? false)
const [canManage, setCanManage] = useState(preset?.can_manage ?? false)
const [canConserve, setCanConserve] = useState(preset?.can_conserve ?? false)
const createMutation = useMutation({
mutationFn: () =>
permissionPresetsApi.create({
name: name.trim(),
description: description.trim() || null,
can_read: canRead,
can_send: canSend,
can_manage: canManage,
can_conserve: canConserve,
}),
onSuccess: () => {
toast.success('Preset creato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: () =>
permissionPresetsApi.update(preset!.id, {
name: name.trim(),
description: description.trim() || null,
can_read: canRead,
can_send: canSend,
can_manage: canManage,
can_conserve: canConserve,
}),
onSuccess: () => {
toast.success('Preset aggiornato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSave = () => {
if (!name.trim()) {
toast.error('Il nome e\' obbligatorio')
return
}
if (isEdit) {
updateMutation.mutate()
} else {
createMutation.mutate()
}
}
const permFlags = [
{ key: 'can_read', label: 'Lettura messaggi', desc: 'Accesso in lettura alle PEC della casella', state: canRead, setState: setCanRead },
{ key: 'can_send', label: 'Invio PEC', desc: 'Possibilita\' di inviare messaggi dalla casella', state: canSend, setState: setCanSend },
{ key: 'can_manage', label: 'Gestione casella', desc: 'Modifica configurazione IMAP/SMTP della casella', state: canManage, setState: setCanManage },
{ key: 'can_conserve', label: 'Conservazione documenti', desc: 'Spostamento messaggi nella cartella di conservazione', state: canConserve, setState: setCanConserve },
]
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Modifica preset' : 'Nuovo preset permessi'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
{/* Nome */}
<div className="space-y-1.5">
<Label htmlFor="preset-name">Nome <span className="text-destructive">*</span></Label>
<input
id="preset-name"
type="text"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Es. Operatore Archivio, Operatore Invio..."
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
</div>
{/* Descrizione */}
<div className="space-y-1.5">
<Label htmlFor="preset-desc">Descrizione</Label>
<textarea
id="preset-desc"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
rows={2}
placeholder="Descrizione opzionale del preset..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* Permessi */}
<div className="space-y-2">
<Label>Permessi inclusi nel preset</Label>
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{permFlags.map(({ key, label, desc, state, setState }) => (
<label key={key} className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={state}
onChange={(e) => setState(e.target.checked)}
className="h-4 w-4 mt-0.5 rounded border-input flex-shrink-0"
/>
<div>
<p className="text-sm font-medium leading-tight">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Annulla
</Button>
<Button onClick={handleSave} isLoading={isPending} disabled={!name.trim()}>
{isEdit ? 'Salva modifiche' : 'Crea preset'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -12,11 +12,15 @@ import {
} from '@/components/ui/Dialog' } from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { permissionsApi } from '@/api/permissions.api' import { permissionsApi } from '@/api/permissions.api'
import { permissionPresetsApi } from '@/api/permission_presets.api'
import { mailboxesApi } from '@/api/mailboxes.api' import { mailboxesApi } from '@/api/mailboxes.api'
import { usersApi } from '@/api/users.api' import { usersApi } from '@/api/users.api'
import { getErrorMessage } from '@/api/client' import { getErrorMessage } from '@/api/client'
import { cn, ROLE_LABELS } from '@/lib/utils' import { cn, ROLE_LABELS } from '@/lib/utils'
import type { MailboxUserPermissionResponse } from '@/types/api.types' import type {
MailboxUserPermissionResponse,
PermissionPresetResponse,
} from '@/types/api.types'
export function PermissionsPage() { export function PermissionsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -139,9 +143,10 @@ export function PermissionsPage() {
<tr className="bg-muted/50 border-b"> <tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Utente</th> <th className="text-left px-4 py-3 font-medium text-muted-foreground">Utente</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Ruolo</th> <th className="text-left px-4 py-3 font-medium text-muted-foreground">Ruolo</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Lettura</th> <th className="text-center px-3 py-3 font-medium text-muted-foreground">Lettura</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Invio</th> <th className="text-center px-3 py-3 font-medium text-muted-foreground">Invio</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Gestione</th> <th className="text-center px-3 py-3 font-medium text-muted-foreground">Gestione</th>
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Conserv.</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th> <th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
</tr> </tr>
</thead> </thead>
@@ -196,17 +201,22 @@ interface PermissionRowProps {
function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) { function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (data: { can_read: boolean; can_send: boolean; can_manage: boolean }) => mutationFn: (data: {
permissionsApi.grant(mailboxId, perm.user_id, data), can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
}) => permissionsApi.grant(mailboxId, perm.user_id, data),
onSuccess: () => toast.success('Permesso aggiornato'), onSuccess: () => toast.success('Permesso aggiornato'),
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
const toggle = (field: 'can_read' | 'can_send' | 'can_manage') => { const toggle = (field: 'can_read' | 'can_send' | 'can_manage' | 'can_conserve') => {
updateMutation.mutate({ updateMutation.mutate({
can_read: field === 'can_read' ? !perm.can_read : perm.can_read, can_read: field === 'can_read' ? !perm.can_read : perm.can_read,
can_send: field === 'can_send' ? !perm.can_send : perm.can_send, can_send: field === 'can_send' ? !perm.can_send : perm.can_send,
can_manage: field === 'can_manage' ? !perm.can_manage : perm.can_manage, can_manage: field === 'can_manage' ? !perm.can_manage : perm.can_manage,
can_conserve: field === 'can_conserve' ? !perm.can_conserve : perm.can_conserve,
}) })
} }
@@ -214,19 +224,20 @@ function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
<tr className="hover:bg-muted/30 transition-colors"> <tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div> <div>
<p className="font-medium">{perm.full_name}</p> <p className="font-medium">{perm.user_full_name}</p>
<p className="text-xs text-muted-foreground">{perm.email}</p> <p className="text-xs text-muted-foreground">{perm.user_email}</p>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-xs bg-muted px-2 py-0.5 rounded"> <span className="text-xs bg-muted px-2 py-0.5 rounded">
{ROLE_LABELS[perm.role] || perm.role} {ROLE_LABELS[perm.user_role] || perm.user_role}
</span> </span>
</td> </td>
{(['can_read', 'can_send', 'can_manage'] as const).map((field) => ( {(['can_read', 'can_send', 'can_manage', 'can_conserve'] as const).map((field) => (
<td key={field} className="px-4 py-3 text-center"> <td key={field} className="px-3 py-3 text-center">
<button <button
onClick={() => toggle(field)} onClick={() => toggle(field)}
disabled={updateMutation.isPending}
className={cn( className={cn(
'h-5 w-5 rounded border-2 inline-flex items-center justify-center transition-colors', 'h-5 w-5 rounded border-2 inline-flex items-center justify-center transition-colors',
perm[field] perm[field]
@@ -234,7 +245,7 @@ function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
: 'border-muted-foreground/40 bg-background hover:border-primary', : 'border-muted-foreground/40 bg-background hover:border-primary',
)} )}
> >
{perm[field] && <span className="text-xs"></span>} {perm[field] && <span className="text-xs"></span>}
</button> </button>
</td> </td>
))} ))}
@@ -263,21 +274,29 @@ interface GrantPermissionDialogProps {
function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionDialogProps) { function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionDialogProps) {
const [selectedUserId, setSelectedUserId] = useState('') const [selectedUserId, setSelectedUserId] = useState('')
const [selectedPresetId, setSelectedPresetId] = useState('')
const [canRead, setCanRead] = useState(true) const [canRead, setCanRead] = useState(true)
const [canSend, setCanSend] = useState(false) const [canSend, setCanSend] = useState(false)
const [canManage, setCanManage] = useState(false) const [canManage, setCanManage] = useState(false)
const [canConserve, setCanConserve] = useState(false)
const { data: usersData } = useQuery({ const { data: usersData } = useQuery({
queryKey: ['users'], queryKey: ['users'],
queryFn: () => usersApi.list(1, 100), queryFn: () => usersApi.list(1, 100),
}) })
const { data: presets = [] } = useQuery({
queryKey: ['permission-presets'],
queryFn: () => permissionPresetsApi.list(),
})
const grantMutation = useMutation({ const grantMutation = useMutation({
mutationFn: () => mutationFn: () =>
permissionsApi.grant(mailboxId, selectedUserId, { permissionsApi.grant(mailboxId, selectedUserId, {
can_read: canRead, can_read: canRead,
can_send: canSend, can_send: canSend,
can_manage: canManage, can_manage: canManage,
can_conserve: canConserve,
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Permesso assegnato') toast.success('Permesso assegnato')
@@ -288,6 +307,23 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
const users = usersData?.items?.filter((u) => u.is_active) || [] const users = usersData?.items?.filter((u) => u.is_active) || []
const applyPreset = (preset: PermissionPresetResponse) => {
setCanRead(preset.can_read)
setCanSend(preset.can_send)
setCanManage(preset.can_manage)
setCanConserve(preset.can_conserve)
setSelectedPresetId(preset.id)
}
const handlePresetChange = (presetId: string) => {
if (!presetId) {
setSelectedPresetId('')
return
}
const preset = presets.find((p) => p.id === presetId)
if (preset) applyPreset(preset)
}
return ( return (
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
@@ -295,6 +331,7 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
<DialogTitle>Assegna permesso casella</DialogTitle> <DialogTitle>Assegna permesso casella</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{/* Utente */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Utente</Label> <Label>Utente</Label>
<select <select
@@ -311,19 +348,47 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
</select> </select>
</div> </div>
{/* Preset (opzionale) */}
{presets.length > 0 && (
<div className="space-y-2">
<Label>Applica preset (opzionale)</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedPresetId}
onChange={(e) => handlePresetChange(e.target.value)}
>
<option value="">Nessun preset configura manualmente</option>
{presets.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.description ? ` ${p.description}` : ''}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Il preset pre-compila i permessi. Puoi modificarli liberamente prima di confermare.
</p>
</div>
)}
{/* Permessi */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Permessi</Label> <Label>Permessi</Label>
<div className="space-y-2"> <div className="space-y-2 rounded-md border p-3 bg-muted/30">
{[ {[
{ key: 'can_read', label: 'Lettura messaggi', state: canRead, setState: setCanRead }, { key: 'can_read', label: 'Lettura messaggi', state: canRead, setState: setCanRead },
{ key: 'can_send', label: 'Invio PEC', state: canSend, setState: setCanSend }, { key: 'can_send', label: 'Invio PEC', state: canSend, setState: setCanSend },
{ key: 'can_manage', label: 'Gestione casella', state: canManage, setState: setCanManage }, { key: 'can_manage', label: 'Gestione casella', state: canManage, setState: setCanManage },
{ key: 'can_conserve', label: 'Conservazione documenti', state: canConserve, setState: setCanConserve },
].map(({ key, label, state, setState }) => ( ].map(({ key, label, state, setState }) => (
<label key={key} className="flex items-center gap-3 cursor-pointer"> <label key={key} className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={state} checked={state}
onChange={(e) => setState(e.target.checked)} onChange={(e) => {
setState(e.target.checked)
setSelectedPresetId('') // Reset preset se modifica manuale
}}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input"
/> />
<span className="text-sm">{label}</span> <span className="text-sm">{label}</span>
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/Label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
import { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api' import { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
import { labelsApi } from '@/api/labels.api' import { labelsApi } from '@/api/labels.api'
import type { LabelResponse, LabelTreeResponse } from '@/types/api.types'
import { RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
import { getErrorMessage } from '@/api/client' import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -18,6 +20,10 @@ const FIELD_LABELS: Record<ConditionField, string> = {
subject: 'Oggetto', subject: 'Oggetto',
mailbox_id: 'ID Casella', mailbox_id: 'ID Casella',
pec_type: 'Tipo PEC', pec_type: 'Tipo PEC',
has_label: 'Ha etichetta/classificazione',
// Rischio e Riservatezza (N3)
risk_level: 'Livello di rischio',
confidentiality: 'Riservatezza',
} }
const OPERATOR_LABELS: Record<ConditionOperator, string> = { const OPERATOR_LABELS: Record<ConditionOperator, string> = {
@@ -35,6 +41,26 @@ const ACTION_LABELS: Record<ActionType, string> = {
mark_read: 'Segna come letto', mark_read: 'Segna come letto',
mark_starred: 'Aggiungi ai preferiti', mark_starred: 'Aggiungi ai preferiti',
notify_webhook: 'Notifica webhook', notify_webhook: 'Notifica webhook',
apply_taxonomy: 'Applica classificazione tassonomica',
// Rischio e Riservatezza (N3)
set_risk_level: 'Imposta livello di rischio',
set_confidentiality: 'Imposta riservatezza',
}
/** Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione" */
function buildLabelPath(labelId: string, allLabels: LabelResponse[]): string {
const map = new Map(allLabels.map((l) => [l.id, l]))
const parts: string[] = []
let current = map.get(labelId)
while (current) {
parts.unshift(current.name)
if (current.parent_id) {
current = map.get(current.parent_id)
} else {
break
}
}
return parts.join(' > ')
} }
interface Condition { interface Condition {
@@ -325,12 +351,27 @@ export function RoutingRulesPage() {
> >
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)} {(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select> </select>
{cond.field === 'has_label' ? (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
) : (
<Input <Input
className="flex-1" className="flex-1"
value={cond.value} value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))} onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
placeholder="Valore..." placeholder="Valore..."
/> />
)}
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded"> <button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -362,7 +403,21 @@ export function RoutingRulesPage() {
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))} onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
> >
<option value="">-- Seleziona etichetta --</option> <option value="">-- Seleziona etichetta --</option>
{labels.map(l => <option key={l.id} value={l.id}>{l.name}</option>)} {labels.map((l: LabelResponse) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)}
{(action.action_type === 'apply_taxonomy') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona classificazione --</option>
{labels.map((l: LabelResponse) => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select> </select>
)} )}
{(action.action_type === 'notify_webhook') && ( {(action.action_type === 'notify_webhook') && (
@@ -373,6 +428,31 @@ export function RoutingRulesPage() {
placeholder="https://..." placeholder="https://..."
/> />
)} )}
{/* Rischio e Riservatezza (N3) */}
{(action.action_type === 'set_risk_level') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona livello --</option>
{RISK_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{(action.action_type === 'set_confidentiality') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded"> <button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -0,0 +1,454 @@
/**
* TaxonomyPage Gestione della Tassonomia di Classificazione Multi-livello (Feature N2).
*
* Struttura gerarchica:
* Livello 0 (Radice) = Ambito (Area Aziendale) es. "Legale", "Commerciale"
* Livello 1 = Processo es. "Contratti", "Contenzioso"
* Livello 2 (Foglia) = Classificazione es. "NDA", "Ricorso", "Fattura"
*
* Le classificazioni vengono poi assegnate ai messaggi tramite:
* - Widget tassonomia nel dettaglio messaggio (selezione manuale)
* - Regole di smistamento con azione "apply_taxonomy" (automatica)
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ChevronRight,
ChevronDown,
Plus,
Pencil,
Trash2,
Tags,
Info,
X,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
import { labelsApi } from '@/api/labels.api'
import type { LabelTreeResponse } from '@/types/api.types'
import { getErrorMessage } from '@/api/client'
import { cn } from '@/lib/utils'
// ─── Livelli della tassonomia ─────────────────────────────────────────────────
const LEVEL_LABELS = ['Ambito', 'Processo', 'Classificazione']
const LEVEL_COLORS = [
'bg-blue-100 text-blue-800 border-blue-200',
'bg-purple-100 text-purple-800 border-purple-200',
'bg-green-100 text-green-800 border-green-200',
]
const LEVEL_BG = [
'border-l-2 border-blue-400',
'border-l-2 border-purple-400 ml-6',
'border-l-2 border-green-400 ml-12',
]
// ─── Calcola la profondita' di un nodo nell'albero ────────────────────────────
function getNodeDepth(node: LabelTreeResponse): number {
if (!node.parent_id) return 0
return -1 // verrà calcolato nel contesto dell'albero
}
// ─── Componente nodo dell'albero ──────────────────────────────────────────────
interface TreeNodeProps {
node: LabelTreeResponse
depth: number
onAddChild: (parent: LabelTreeResponse, depth: number) => void
onEdit: (node: LabelTreeResponse, depth: number) => void
onDelete: (node: LabelTreeResponse) => void
}
function TreeNode({ node, depth, onAddChild, onEdit, onDelete }: TreeNodeProps) {
const [expanded, setExpanded] = useState(true)
const hasChildren = node.children.length > 0
const levelLabel = LEVEL_LABELS[depth] ?? `Livello ${depth}`
const levelColor = LEVEL_COLORS[depth] ?? 'bg-gray-100 text-gray-700 border-gray-200'
const levelBg = LEVEL_BG[depth] ?? 'border-l-2 border-gray-400 ml-16'
return (
<div className={cn(levelBg, 'pl-3 py-0.5')}>
<div className="flex items-center gap-2 py-2 px-2 rounded-lg hover:bg-muted/40 group">
{/* Espandi/comprimi */}
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="w-5" />
)}
{/* Pallino colore */}
{node.color && (
<span
className="h-3.5 w-3.5 rounded-full flex-shrink-0 border"
style={{ backgroundColor: node.color }}
/>
)}
{/* Nome */}
<span className="flex-1 text-sm font-medium">{node.name}</span>
{/* Badge livello */}
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium', levelColor)}>
{levelLabel}
</span>
{/* Descrizione (tooltip) */}
{node.description && (
<span title={node.description} className="text-muted-foreground cursor-help">
<Info className="h-3.5 w-3.5" />
</span>
)}
{/* Azioni — visibili solo al hover */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{depth < 2 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={`Aggiungi ${LEVEL_LABELS[depth + 1] ?? 'sottonodo'}`}
onClick={() => onAddChild(node, depth + 1)}
>
<Plus className="h-3.5 w-3.5 text-primary" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Modifica"
onClick={() => onEdit(node, depth)}
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Elimina"
onClick={() => onDelete(node)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
{/* Figli */}
{expanded && hasChildren && (
<div className="mt-0.5">
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
onAddChild={onAddChild}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function TaxonomyPage() {
const queryClient = useQueryClient()
// Stato form
const [showForm, setShowForm] = useState(false)
const [formName, setFormName] = useState('')
const [formColor, setFormColor] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formParentId, setFormParentId] = useState<string | null>(null)
const [formParentName, setFormParentName] = useState<string>('')
const [formDepth, setFormDepth] = useState(0)
const [editingId, setEditingId] = useState<string | null>(null)
// Carica albero tassonomico
const { data: tree = [], isLoading } = useQuery({
queryKey: ['labels-tree'],
queryFn: () => labelsApi.getTree(),
})
// Conta i nodi totali
function countNodes(nodes: LabelTreeResponse[]): number {
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0)
}
const totalNodes = countNodes(tree)
// Crea nodo
const createMutation = useMutation({
mutationFn: (data: { name: string; color?: string; description?: string; parent_id?: string | null }) =>
labelsApi.create({ name: data.name, color: data.color || null, description: data.description || null, parent_id: data.parent_id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
queryClient.invalidateQueries({ queryKey: ['labels'] })
toast.success('Nodo creato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
// Aggiorna nodo
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { name: string; color?: string; description?: string } }) =>
labelsApi.update(id, { name: data.name, color: data.color || null, description: data.description || null }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
queryClient.invalidateQueries({ queryKey: ['labels'] })
toast.success('Nodo aggiornato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
// Elimina nodo (elimina anche tutti i figli in cascata)
const deleteMutation = useMutation({
mutationFn: (id: string) => labelsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
queryClient.invalidateQueries({ queryKey: ['labels'] })
toast.success('Nodo eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
function openCreate(parentNode: LabelTreeResponse | null = null, depth = 0) {
setEditingId(null)
setFormName('')
setFormColor('')
setFormDescription('')
setFormParentId(parentNode?.id ?? null)
setFormParentName(parentNode?.name ?? '')
setFormDepth(depth)
setShowForm(true)
}
function openEdit(node: LabelTreeResponse, depth: number) {
setEditingId(node.id)
setFormName(node.name)
setFormColor(node.color ?? '')
setFormDescription(node.description ?? '')
setFormParentId(node.parent_id)
setFormParentName('')
setFormDepth(depth)
setShowForm(true)
}
function closeForm() {
setShowForm(false)
setEditingId(null)
setFormName('')
setFormColor('')
setFormDescription('')
setFormParentId(null)
setFormParentName('')
}
function handleDelete(node: LabelTreeResponse) {
const hasChildren = node.children.length > 0
const msg = hasChildren
? `Eliminare "${node.name}" e tutti i suoi ${node.children.length} sottonodi? Verranno rimossi anche dai messaggi.`
: `Eliminare "${node.name}"? Verra' rimosso anche dai messaggi.`
if (confirm(msg)) {
deleteMutation.mutate(node.id)
}
}
function handleSubmit() {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
if (formColor && !/^#[0-9A-Fa-f]{6}$/.test(formColor)) {
return toast.error('Il colore deve essere in formato esadecimale #RRGGBB')
}
if (editingId) {
updateMutation.mutate({
id: editingId,
data: { name: formName.trim(), color: formColor, description: formDescription.trim() },
})
} else {
createMutation.mutate({
name: formName.trim(),
color: formColor,
description: formDescription.trim(),
parent_id: formParentId,
})
}
}
const levelLabel = LEVEL_LABELS[formDepth] ?? `Livello ${formDepth}`
const levelColor = LEVEL_COLORS[formDepth] ?? 'bg-gray-100 text-gray-700'
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Tags className="h-5 w-5 text-primary" />
Tassonomia di Classificazione
</h1>
<p className="text-sm text-muted-foreground">
Organizza la classificazione dei messaggi in una struttura gerarchica a 3 livelli
</p>
</div>
<Button onClick={() => openCreate(null, 0)}>
<Plus className="h-4 w-4 mr-2" />
Nuovo Ambito
</Button>
</div>
{/* Legenda livelli */}
<div className="px-6 py-3 border-b bg-muted/20 flex items-center gap-6 text-sm flex-wrap">
{LEVEL_LABELS.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<span className={cn('px-2 py-0.5 rounded-full border text-xs font-medium', LEVEL_COLORS[i])}>
{label}
</span>
<span className="text-muted-foreground text-xs">
{i === 0 ? 'Area aziendale principale' : i === 1 ? 'Processo / sotto-area' : 'Classificazione assegnabile ai messaggi'}
</span>
</div>
))}
<span className="ml-auto text-xs text-muted-foreground">{totalNodes} nodi totali</span>
</div>
{/* Albero */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : tree.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Tags className="h-14 w-14 mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium">Nessuna tassonomia configurata</p>
<p className="text-sm mt-1 max-w-md mx-auto">
Crea un Ambito per iniziare a strutturare la classificazione dei messaggi PEC.
Ogni Ambito puo' contenere Processi, che a loro volta contengono Classificazioni.
</p>
<Button className="mt-6" onClick={() => openCreate(null, 0)}>
<Plus className="h-4 w-4 mr-2" />
Crea il primo Ambito
</Button>
</div>
) : (
<div className="space-y-2 max-w-3xl">
{tree.map((root) => (
<TreeNode
key={root.id}
node={root}
depth={0}
onAddChild={(parent, depth) => openCreate(parent, depth)}
onEdit={openEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Dialog crea/modifica nodo */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{editingId ? 'Modifica nodo' : `Nuovo ${levelLabel}`}
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-normal', levelColor)}>
{levelLabel}
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
{/* Parent path (solo in creazione) */}
{!editingId && formParentName && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/40 rounded-lg px-3 py-2">
<Tags className="h-4 w-4 flex-shrink-0" />
<span>Sotto: <strong>{formParentName}</strong></span>
</div>
)}
<div className="space-y-2">
<Label>Nome *</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder={`Es. ${levelLabel === 'Ambito' ? 'Legale' : levelLabel === 'Processo' ? 'Contratti' : 'NDA'}`}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Breve descrizione del nodo..."
/>
</div>
<div className="space-y-2">
<Label>Colore (opzionale)</Label>
<div className="flex items-center gap-3">
<Input
value={formColor}
onChange={(e) => setFormColor(e.target.value)}
placeholder="#3B82F6"
maxLength={7}
className="font-mono"
/>
{formColor && /^#[0-9A-Fa-f]{6}$/.test(formColor) && (
<div
className="h-8 w-8 rounded-md border flex-shrink-0"
style={{ backgroundColor: formColor }}
/>
)}
{/* Palette colori rapidi */}
<div className="flex gap-1">
{['#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', '#EF4444', '#6B7280'].map((c) => (
<button
key={c}
type="button"
onClick={() => setFormColor(c)}
className="h-6 w-6 rounded-full border-2 hover:scale-110 transition-transform"
style={{ backgroundColor: c, borderColor: formColor === c ? '#1e40af' : 'transparent' }}
title={c}
/>
))}
</div>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button
onClick={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
>
{editingId ? 'Salva modifiche' : `Crea ${levelLabel}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+59 -4
View File
@@ -170,23 +170,35 @@ export interface ConnectionTestResult {
capabilities: string[] | null capabilities: string[] | null
} }
// ─── Label (Tag) ────────────────────────────────────────────────────────────── // ─── Label (Tag) + Tassonomia (N2) ───────────────────────────────────────────
export interface LabelResponse { export interface LabelResponse {
id: string id: string
tenant_id: string tenant_id: string
name: string name: string
color: string | null color: string | null
// Tassonomia: se valorizzato, questo nodo fa parte di un albero gerarchico
parent_id: string | null
description: string | null
}
/** Nodo tassonomico con figli annidati — restituito da GET /labels/tree */
export interface LabelTreeResponse extends LabelResponse {
children: LabelTreeResponse[]
} }
export interface LabelCreate { export interface LabelCreate {
name: string name: string
color?: string | null color?: string | null
parent_id?: string | null
description?: string | null
} }
export interface LabelUpdate { export interface LabelUpdate {
name?: string name?: string
color?: string | null color?: string | null
parent_id?: string | null
description?: string | null
} }
export interface MessageLabelSetRequest { export interface MessageLabelSetRequest {
@@ -224,6 +236,11 @@ export interface SearchMatchInfo {
in_attachments: AttachmentMatchInfo[] in_attachments: AttachmentMatchInfo[]
} }
// ─── Rischio e Riservatezza (Feature N3) ─────────────────────────────────────
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
export type ConfidentialityLevel = 'public' | 'internal' | 'confidential' | 'secret'
// ─── Message ────────────────────────────────────────────────────────────────── // ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound' export type PecDirection = 'inbound' | 'outbound'
@@ -279,6 +296,9 @@ export interface MessageResponse {
pending_conservation_at: string | null pending_conservation_at: string | null
is_conserved: boolean is_conserved: boolean
conserved_at: string | null conserved_at: string | null
// Rischio e Riservatezza (Feature N3)
risk_level: RiskLevel | null
confidentiality: ConfidentialityLevel | null
raw_eml_path: string | null raw_eml_path: string | null
created_at: string created_at: string
updated_at: string updated_at: string
@@ -353,6 +373,7 @@ export interface PermissionResponse {
can_read: boolean can_read: boolean
can_send: boolean can_send: boolean
can_manage: boolean can_manage: boolean
can_conserve: boolean
granted_by: string | null granted_by: string | null
granted_at: string granted_at: string
} }
@@ -366,9 +387,9 @@ export interface PermissionGrantRequest {
export interface MailboxUserPermissionResponse { export interface MailboxUserPermissionResponse {
user_id: string user_id: string
email: string user_email: string
full_name: string user_full_name: string
role: UserRole user_role: UserRole
can_read: boolean can_read: boolean
can_send: boolean can_send: boolean
can_manage: boolean can_manage: boolean
@@ -376,6 +397,40 @@ export interface MailboxUserPermissionResponse {
granted_at: string granted_at: string
} }
// ─── Permission Preset (sottoruoli) ───────────────────────────────────────────
export interface PermissionPresetResponse {
id: string
tenant_id: string
name: string
description: string | null
can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
created_by: string | null
created_at: string
updated_at: string
}
export interface PermissionPresetCreate {
name: string
description?: string | null
can_read?: boolean
can_send?: boolean
can_manage?: boolean
can_conserve?: boolean
}
export interface PermissionPresetUpdate {
name?: string
description?: string | null
can_read?: boolean
can_send?: boolean
can_manage?: boolean
can_conserve?: boolean
}
export interface UserMailboxPermissionResponse { export interface UserMailboxPermissionResponse {
mailbox_id: string mailbox_id: string
email_address: string email_address: string