Il Sistema Documentale di Play The Event
Nella gestione di un evento, i documenti rappresentano un asset critico: fatture, contratti, permessi, locandine, certificati e ricevute devono essere organizzati, versionati e condivisi in modo sicuro. In Play The Event, il modulo documentale è stato progettato seguendo i principi del Domain-Driven Design, con DocumentoEvento come Aggregate Root che coordina versioning, categorizzazione automatica, estrazione metadati e condivisione tramite link temporanei.
Questo approccio garantisce integrità dei dati, tracciabilità completa delle modifiche e un'organizzazione automatica dei file in cartelle logiche basate sul tipo MIME e sulla categoria del documento.
Cosa Troverai in Questo Articolo
- L'Aggregate Root
DocumentoEventoe il pattern DDD per i documenti - Le 11 categorie predefinite e le categorie personalizzate per evento
- L'auto-detection delle cartelle basata su MIME type e categoria
- Il sistema di versioning con aggiunta e ripristino versioni
- I link di condivisione con scadenza temporale e limite download
- L'estrazione automatica dei metadati (EXIF, PDF, Office)
- L'integrità dei file tramite hashing SHA-256
- Il collegamento documenti-spese e lo storage organizzato
DocumentoEvento: l'Aggregate Root
DocumentoEvento è l'Aggregate Root del bounded context documentale. Ogni
documento appartiene a un evento specifico, è caricato da un utente e gestisce internamente
la propria cronologia di versioni e i link di condivisione. Il factory method crea()
garantisce che ogni documento nasca in uno stato valido, con la prima versione già creata.
@Entity
@Table(name = "documenti_evento")
public class DocumentoEvento {
private static final long MAX_DIMENSIONE_BYTES = 50L * 1024 * 1024; // 50MB
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long eventoId;
private String titolo; // max 200 caratteri
private String descrizione; // max 1000 caratteri
@Enumerated(EnumType.STRING)
private CategoriaDocumento categoria;
private Long categoriaPersonalizzataId;
@Enumerated(EnumType.STRING)
private TipoCartella tipoCartella;
private Integer versioneCorrente;
private String nomeFileOriginale;
private String mimeType;
private Long dimensioneBytes;
private Long caricatoDaId;
private Long spesaId; // collegamento opzionale a spesa
@Convert(converter = JsonMapConverter.class)
private Map<String, Object> metadati = new HashMap<>();
@OneToMany(mappedBy = "documento", cascade = CascadeType.ALL)
@OrderBy("numeroVersione DESC")
private List<VersioneDocumento> versioni = new ArrayList<>();
@OneToMany(mappedBy = "documento", cascade = CascadeType.ALL)
private Set<LinkCondivisioneDocumento> linkCondivisione = new HashSet<>();
}
La tabella documenti_evento utilizza cinque indici per ottimizzare le query
più frequenti: per evento, per categoria, per tipo cartella, per utente che ha
caricato il documento e per spesa collegata. L'optimistic locking tramite @Version
previene conflitti di aggiornamento concorrente.
Factory Method con Validazione Completa
Il costruttore è protected e accessibile solo tramite il factory method
crea(), che valida tutti i parametri prima di creare l'istanza. Il tipo cartella
viene determinato automaticamente in base al MIME type e alla categoria.
public static DocumentoEvento crea(
Long eventoId, String titolo, String descrizione,
CategoriaDocumento categoria, Long categoriaPersonalizzataId,
String nomeFileOriginale, String mimeType,
Long dimensioneBytes, Long caricatoDaId,
String percorsoStorage, String hashContenuto) {
validaParametriCreazione(eventoId, titolo, nomeFileOriginale,
mimeType, dimensioneBytes, caricatoDaId, percorsoStorage);
DocumentoEvento doc = new DocumentoEvento();
doc.eventoId = eventoId;
doc.titolo = titolo.trim();
doc.categoria = categoria != null ? categoria : CategoriaDocumento.ALTRO;
doc.categoriaPersonalizzataId =
doc.categoria == CategoriaDocumento.PERSONALIZZATA
? categoriaPersonalizzataId : null;
doc.tipoCartella = TipoCartella.fromMimeTypeAndCategoria(mimeType, doc.categoria);
doc.versioneCorrente = 1;
// Crea la prima versione con relazione bidirezionale
VersioneDocumento primaVersione = VersioneDocumento.crea(
1, nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, hashContenuto,
caricatoDaId, "Versione iniziale"
);
primaVersione.setDocumento(doc);
doc.versioni.add(primaVersione);
return doc;
}
Le 11 Categorie Predefinite
Ogni documento caricato su
Play The Event
è associato a una delle 11 categorie predefinite nell'enum CategoriaDocumento.
Ogni categoria include un nome visualizzabile e un'icona per l'interfaccia utente.
public enum CategoriaDocumento {
FATTURA("Fattura", "file-text"),
SCONTRINO("Scontrino", "receipt"),
PERMESSO("Permesso", "shield-check"),
BANDO("Bando", "megaphone"),
CONTRATTO("Contratto", "file-signature"),
PREVENTIVO("Preventivo", "calculator"),
ASSICURAZIONE("Assicurazione", "shield"),
CERTIFICATO("Certificato", "award"),
LOCANDINA("Locandina", "image"),
PERSONALIZZATA("Categoria Personalizzata", "tag"),
ALTRO("Altro", "file");
private final String displayName;
private final String icona;
// Verifica se la categoria rappresenta una ricevuta
public boolean isRicevuta() {
return this == FATTURA || this == SCONTRINO;
}
// Suggerisce una categoria di default per un tipo MIME
public static CategoriaDocumento fromMimeType(String mimeType) {
if (mimeType != null && mimeType.startsWith("image/")) {
return LOCANDINA;
}
return ALTRO;
}
}
Le categorie coprono le esigenze tipiche della gestione eventi: documenti fiscali
(FATTURA, SCONTRINO, PREVENTIVO),
documenti legali (CONTRATTO, PERMESSO, BANDO,
ASSICURAZIONE), documenti di qualifica (CERTIFICATO) e
materiale promozionale (LOCANDINA). Il metodo isRicevuta()
identifica fatture e scontrini per l'instradamento automatico nella cartella ricevute.
Categorie Personalizzate per Evento
Quando le 11 categorie predefinite non sono sufficienti, gli organizzatori possono creare
categorie personalizzate specifiche per il proprio evento. L'entity
CategoriaDocumentoPersonalizzata consente di definire nome, descrizione,
icona, colore e ordine di visualizzazione per ogni categoria aggiuntiva.
@Entity
@Table(name = "categorie_documento_evento",
uniqueConstraints = @UniqueConstraint(
name = "uq_categoria_evento_nome",
columnNames = {"evento_id", "nome"}
))
public class CategoriaDocumentoPersonalizzata {
private static final Pattern HEX_COLOR_PATTERN =
Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final String COLORE_DEFAULT = "#6b7280";
private static final String ICONA_DEFAULT = "file-text";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long eventoId;
private String nome; // max 50 caratteri, unico per evento
private String descrizione; // max 200 caratteri
private String icona; // nome icona Lucide
private String colore; // formato hex #RRGGBB
private Integer ordine; // ordine di visualizzazione
}
Il vincolo di unicità uq_categoria_evento_nome impedisce la creazione
di categorie duplicate all'interno dello stesso evento. Il colore è validato con una
regex che accetta esclusivamente il formato esadecimale #RRGGBB. Se non viene
specificato un colore o un'icona, vengono applicati i valori di default.
Quando un documento utilizza la categoria PERSONALIZZATA, il campo
categoriaPersonalizzataId del DocumentoEvento viene impostato
con l'ID della categoria personalizzata corrispondente. Se la categoria non è
PERSONALIZZATA, il campo viene automaticamente annullato.
Auto-Detection della Cartella
Il sistema organizza automaticamente i file in tre cartelle logiche in base al tipo MIME
del file e alla categoria del documento. L'enum TipoCartella definisce la
logica di instradamento.
public enum TipoCartella {
DOCUMENTI("documenti"),
IMMAGINI("immagini"),
RICEVUTE("ricevute");
private final String percorso;
public static TipoCartella fromMimeTypeAndCategoria(
String mimeType, CategoriaDocumento categoria) {
// 1. Fatture e scontrini vanno SEMPRE nella cartella ricevute
if (categoria != null && categoria.isRicevuta()) {
return RICEVUTE;
}
// 2. I file con MIME type image/* vanno nella cartella immagini
if (mimeType != null && mimeType.startsWith("image/")) {
return IMMAGINI;
}
// 3. Tutto il resto va nella cartella documenti
return DOCUMENTI;
}
}
La priorità dell'instradamento è chiara: la categoria ha
precedenza sul tipo MIME. Una fattura in formato PDF viene collocata nella cartella
/ricevute e non in /documenti, perché la sua categoria
(FATTURA) la identifica come ricevuta. Un'immagine JPEG senza categoria
specifica di ricevuta finisce nella cartella /immagini.
Regole di Instradamento
- /ricevute - Documenti con categoria FATTURA o SCONTRINO (qualsiasi formato file)
- /immagini - File con MIME type
image/*(JPEG, PNG, WebP, etc.) - /documenti - Tutti gli altri file (PDF, Word, Excel, testo, etc.)
Sistema di Versioning
Ogni documento mantiene una cronologia completa delle versioni. Quando un utente carica
una nuova versione, il sistema incrementa il contatore versioneCorrente e
crea un nuovo oggetto VersioneDocumento con tutti i metadati del file aggiornato.
@Entity
@Table(name = "versioni_documento",
uniqueConstraints = @UniqueConstraint(
name = "uq_documento_versione",
columnNames = {"documento_id", "numero_versione"}
))
public class VersioneDocumento {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "documento_id", nullable = false)
private DocumentoEvento documento;
private Integer numeroVersione; // progressivo, >= 1
private String nomeFileOriginale; // max 255 caratteri
private String percorsoStorage; // max 500 caratteri
private Long dimensioneBytes;
private String mimeType;
private String hashContenuto; // SHA-256, 64 caratteri hex
private Long creatoDaId;
private String notaVersione; // max 500 caratteri
private Instant creatoIl;
}
Aggiunta di una Nuova Versione
L'aggiunta di una versione è gestita interamente dall'Aggregate Root, che garantisce la consistenza dello stato interno. Il numero di versione viene incrementato automaticamente e i metadati del documento principale vengono aggiornati con i dati del nuovo file.
public VersioneDocumento aggiungiVersione(
String nomeFileOriginale, String percorsoStorage,
Long dimensioneBytes, String mimeType,
String hashContenuto, Long caricatoDaId, String notaVersione) {
validaParametriVersione(nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, caricatoDaId);
this.versioneCorrente++;
this.nomeFileOriginale = nomeFileOriginale;
this.mimeType = mimeType;
this.dimensioneBytes = dimensioneBytes;
VersioneDocumento nuovaVersione = VersioneDocumento.crea(
this.versioneCorrente, nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, hashContenuto,
caricatoDaId, notaVersione
);
nuovaVersione.setDocumento(this);
this.versioni.add(nuovaVersione);
return nuovaVersione;
}
Ripristino di una Versione Precedente
Il ripristino non sovrascrive la cronologia: crea una nuova versione con il contenuto della versione da ripristinare. In questo modo la storia rimane immutabile e ogni operazione è tracciata.
public VersioneDocumento ripristinaVersione(Integer numeroVersione, Long utenteId) {
VersioneDocumento versioneDaRipristinare = versioni.stream()
.filter(v -> v.getNumeroVersione().equals(numeroVersione))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Versione " + numeroVersione + " non trovata"
));
// Crea una NUOVA versione con i dati della versione precedente
return aggiungiVersione(
versioneDaRipristinare.getNomeFileOriginale(),
versioneDaRipristinare.getPercorsoStorage(),
versioneDaRipristinare.getDimensioneBytes(),
versioneDaRipristinare.getMimeType(),
versioneDaRipristinare.getHashContenuto(),
utenteId,
"Ripristino versione " + numeroVersione
);
}
Versioning Immutabile
Il ripristino di una versione precedente non elimina né modifica la cronologia esistente. Se un documento ha 3 versioni e si ripristina la versione 1, il risultato sarà una versione 4 con il contenuto identico alla versione 1. Questo approccio garantisce un audit trail completo e previene la perdita accidentale di dati.
Link di Condivisione Temporanei
I documenti possono essere condivisi esternamente tramite link temporanei che non richiedono
autenticazione. L'entity LinkCondivisioneDocumento gestisce la generazione,
la validazione e la revoca di questi link.
@Entity
@Table(name = "link_condivisione_documento")
public class LinkCondivisioneDocumento {
private static final Duration DURATA_MASSIMA = Duration.ofDays(30);
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "documento_id", nullable = false)
private DocumentoEvento documento;
private String token; // 64 caratteri, UUID-based, unico
private Instant scadenza; // max 30 giorni dalla creazione
private Long creatoDaId;
private Integer numeroDownload; // contatore incrementale
private Integer maxDownload; // limite opzionale (null = illimitato)
private Boolean revocato;
private Instant revocatoIl;
}
Validità Multi-Condizione
Un link è considerato valido solo se soddisfa tre condizioni simultanee: non è stato revocato, non è scaduto e non ha raggiunto il limite massimo di download.
public boolean isValido() {
// Condizione 1: non revocato manualmente
if (revocato) {
return false;
}
// Condizione 2: non scaduto temporalmente
if (Instant.now().isAfter(scadenza)) {
return false;
}
// Condizione 3: download rimanenti disponibili
if (maxDownload != null && numeroDownload >= maxDownload) {
return false;
}
return true;
}
public void registraDownload() {
if (!isValido()) {
throw new IllegalStateException(
"Impossibile registrare download: link non valido");
}
this.numeroDownload++;
}
Il token viene generato combinando due UUID e troncando a 64 caratteri, garantendo unicità e imprevedibilità. La durata massima consentita per un link è di 30 giorni. La revoca è un'operazione idempotente: revocare un link già revocato non produce errori.
Generazione e Revoca dall'Aggregate Root
La creazione e la revoca dei link passano sempre dall'Aggregate Root, che mantiene il controllo completo sull'insieme dei link associati al documento.
// Generazione di un nuovo link temporaneo
public LinkCondivisioneDocumento generaLinkCondivisione(
Duration durata, Long creatoDaId, Integer maxDownload) {
LinkCondivisioneDocumento link = LinkCondivisioneDocumento.crea(
durata, creatoDaId, maxDownload
);
link.setDocumento(this);
this.linkCondivisione.add(link);
return link;
}
// Revoca di un link specifico tramite token
public void revocaLinkCondivisione(String token) {
LinkCondivisioneDocumento link = linkCondivisione.stream()
.filter(l -> l.getToken().equals(token))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Link non trovato: " + token));
link.revoca();
}
// Query: tutti i link ancora attivi
public List<LinkCondivisioneDocumento> getLinkAttivi() {
return linkCondivisione.stream()
.filter(LinkCondivisioneDocumento::isValido)
.collect(Collectors.toList());
}
Estrazione Automatica dei Metadati
Quando un file viene caricato,
Play The Event
estrae automaticamente i metadati disponibili utilizzando Apache Tika e la
libreria metadata-extractor. I metadati estratti vengono salvati come JSON
nel campo metadati del documento.
public interface MetadatiDocumentoExtractor {
// Estrae tutti i metadati in base al tipo MIME
Map<String, Object> estraiMetadati(
InputStream inputStream, String nomeFile, String mimeType);
// Specializzazioni per tipo di file
Map<String, Object> estraiMetadatiImmagine(InputStream inputStream);
Map<String, Object> estraiMetadatiPdf(InputStream inputStream);
Map<String, Object> estraiMetadatiOffice(
InputStream inputStream, String mimeType);
boolean isFormatoSupportato(String mimeType);
}
Metadati per Immagini (EXIF, IPTC, XMP)
Per le immagini, il sistema estrae metadati da quattro directory principali: EXIF IFD0 (marca fotocamera, modello, software, orientamento), EXIF SubIFD (tempo di esposizione, apertura, ISO, lunghezza focale, flash), GPS (latitudine, longitudine, altitudine) e IPTC (titolo, descrizione, parole chiave, autore, città, paese).
// MIME types immagine con estrazione EXIF completa
"image/jpeg", "image/png", "image/gif", "image/webp",
"image/tiff", "image/bmp", "image/heic", "image/heif", "image/avif"
// Esempio di metadati estratti da una foto JPEG
{
"exifBase": {
"marca": "Canon",
"modello": "EOS R5",
"software": "Adobe Lightroom 14.0",
"orientamento": "Top, left side (Horizontal)"
},
"exifTecnico": {
"dataScatto": "2026:01:15 14:30:22",
"tempoEsposizione": "1/250 sec",
"apertura": "f/2.8",
"iso": "400",
"lunghezzaFocale": "70 mm",
"flash": "Flash did not fire"
},
"gps": {
"latitudine": 41.1171,
"longitudine": 16.8719
}
}
Metadati per PDF e Documenti Office
Per i file PDF, il sistema estrae titolo, autore, soggetto, parole chiave, numero di pagine, versione PDF e informazioni sulla crittografia. Per i documenti Office (Word, Excel, PowerPoint, OpenDocument), vengono estratte anche statistiche come numero di parole, caratteri, paragrafi, slide e tempo totale di modifica.
// PDF
"application/pdf"
// Office - Word
"application/msword"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
// Office - Excel
"application/vnd.ms-excel"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// Office - PowerPoint
"application/vnd.ms-powerpoint"
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
// OpenDocument
"application/vnd.oasis.opendocument.text"
"application/vnd.oasis.opendocument.spreadsheet"
"application/vnd.oasis.opendocument.presentation"
// Altri
"text/plain", "text/html", "text/csv",
"application/rtf", "application/zip"
I metadati vengono salvati nel campo JSON del documento e possono essere consultati
tramite i metodi getMetadato(chiave), aggiungiMetadato(chiave, valore)
e haMetadati() dell'Aggregate Root.
Integrità dei File con SHA-256
Ogni versione di un documento include un hash SHA-256 del contenuto, calcolato al momento del caricamento. Questo permette di verificare in qualsiasi momento che il file non sia stato alterato o corrotto durante lo storage.
// Calcolo hash al caricamento
public String calcolaHash(InputStream contenuto) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = contenuto.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
byte[] hash = digest.digest();
return bytesToHex(hash); // 64 caratteri esadecimali
}
// Verifica integrita' su richiesta
public boolean verificaIntegrita(String percorsoRelativo, String hashAtteso) {
if (hashAtteso == null) {
return true; // Se non c'e' hash, considera valido
}
try (InputStream is = leggi(percorsoRelativo).orElse(null)) {
if (is == null) return false;
String hashEffettivo = calcolaHash(is);
return hashAtteso.equals(hashEffettivo);
}
}
Il limite massimo per file è di 50 MB, validato sia nel factory
method di DocumentoEvento che nel metodo aggiungiVersione().
File che superano questo limite vengono rifiutati con un'eccezione esplicita.
Collegamento Documento-Spesa
I documenti possono essere collegati a una spesa dell'evento, creando una relazione bidirezionale tra il sistema documentale e il sistema di gestione finanziaria. Questo è particolarmente utile per allegare ricevute, fatture e scontrini alle spese corrispondenti.
// Collega il documento a una spesa
public void collegaASpesa(Long spesaId) {
this.spesaId = spesaId;
}
// Rimuove il collegamento con una spesa
public void scollegaDaSpesa() {
this.spesaId = null;
}
// Verifica se il documento e' collegato a una spesa
public boolean isCollegatoASpesa() {
return spesaId != null;
}
// Verifica se il documento e' una ricevuta
public boolean isRicevuta() {
return tipoCartella == TipoCartella.RICEVUTE;
}
L'indice idx_documento_spesa sulla colonna spesa_id ottimizza
le query che recuperano tutti i documenti allegati a una determinata spesa. Questo
permette di visualizzare rapidamente le ricevute associate a ogni voce del budget
dell'evento.
Storage Organizzato su Filesystem
L'implementazione dello storage segue il pattern Hexagonal Architecture:
il domain layer definisce l'interfaccia DocumentoStorageService, mentre
l'infrastructure layer fornisce l'implementazione FileSystemDocumentoStorage.
I file sono organizzati in una struttura gerarchica chiara.
{basePath}/
└── {eventoId}/
├── documenti/
│ └── {documentoId}/
│ ├── v1.pdf
│ ├── v2.pdf
│ └── v3.docx
├── immagini/
│ └── {documentoId}/
│ ├── v1.jpg
│ └── v2.png
└── ricevute/
└── {documentoId}/
├── v1.pdf
└── v2.jpg
// Esempio percorso completo:
// /storage/42/documenti/156/v2.pdf
// /storage/42/immagini/201/v1.jpg
// /storage/42/ricevute/189/v3.pdf
Il naming delle versioni segue la convenzione v{numero}.{estensione}, dove
l'estensione viene estratta dal nome file originale. Il servizio di storage gestisce
anche la pulizia delle directory vuote dopo l'eliminazione di documenti, risalendo
ricorsivamente l'albero fino alla directory base.
public interface DocumentoStorageService {
// Salvataggio con percorso auto-generato
String salva(Long eventoId, TipoCartella tipoCartella,
Long documentoId, Integer versione,
String nomeFile, InputStream contenuto);
// Lettura file
Optional<InputStream> leggi(String percorsoRelativo);
// Eliminazione singola versione o tutte le versioni
boolean elimina(String percorsoRelativo);
void eliminaTutteVersioni(Long eventoId, TipoCartella tipoCartella,
Long documentoId);
// Integrita' e verifica
String calcolaHash(InputStream contenuto);
boolean verificaIntegrita(String percorsoRelativo, String hashAtteso);
// Monitoraggio spazio
long getSpazioUtilizzato(Long eventoId);
long getDimensione(String percorsoRelativo);
boolean esiste(String percorsoRelativo);
}
Il metodo getSpazioUtilizzato() percorre ricorsivamente la directory
dell'evento e somma le dimensioni di tutti i file, permettendo di monitorare lo spazio
disco occupato per ciascun evento e di implementare eventuali limiti di quota.
Riepilogo del Sistema Documentale
- Aggregate Root:
DocumentoEventocon validazione completa e factory method - 11 categorie predefinite + categorie personalizzate per evento con icona e colore
- Auto-detection cartella: /ricevute, /immagini, /documenti in base a MIME type e categoria
- Versioning immutabile: ogni modifica e ripristino creano una nuova versione
- Link temporanei: condivisione con scadenza (max 30 giorni), limite download e revoca
- Metadati automatici: EXIF/IPTC/XMP per immagini, proprietà PDF e Office
- Integrità SHA-256: hashing del contenuto con verifica su richiesta
- Limite 50 MB: validazione dimensione in creazione e aggiornamento
- Collegamento spese: documenti allegabili alle voci di budget
- Storage gerarchico: organizzazione per evento, tipo cartella e documento
Il codice sorgente del sistema documentale è disponibile su GitHub. Nel prossimo articolo esploreremo un altro aspetto dell'architettura di Play The Event, analizzando come il sistema gestisce le interazioni tra i diversi bounded context del dominio.







