Document Management in Play The Event
Every event generates paperwork: invoices, contracts, permits, receipts, insurance policies, posters, and more. Managing these documents manually across email threads, shared drives, and local folders quickly becomes chaotic. Play The Event solves this with a structured document management system built directly into the platform, treating each document as a first-class domain entity with versioning, categorization, sharing, and metadata extraction capabilities.
This article explores the complete document management architecture: the
DocumentoEvento aggregate root following Domain-Driven Design
principles, the 11 predefined categories with custom category support,
automatic folder detection, the full versioning system, secure sharing links,
metadata extraction, file integrity verification, and the integration between
documents and expenses.
What You'll Find in This Article
- Document as an Aggregate Root following DDD patterns with optimistic locking
- 11 predefined categories (FATTURA, SCONTRINO, CONTRATTO, etc.) explained
- Custom categories per event with icons, colors, and ordering
- Automatic folder detection based on MIME type and document category
- Full versioning system: add, restore, and browse version history
- Sharing links with expiration, download limits, and revocation
- Metadata extraction from EXIF, PDF properties, and Office documents
- File integrity verification with SHA-256 hashing and 50MB size limits
- Document-to-expense linking for receipt and invoice management
- Organized storage with separate directories by file type
Document as an Aggregate Root
In Play The Event,
the DocumentoEvento class is designed as an Aggregate Root
following Domain-Driven Design (DDD) principles. This means the document entity
is the single entry point for all operations related to document management,
versions, and sharing links. No external code manipulates versions or links
directly; everything goes through the aggregate root, which enforces all
business invariants.
AGGREGATE ROOT: DocumentoEvento
│
├── Identity
│ ├── id (Long, auto-generated)
│ ├── eventoId (Long, event reference)
│ └── versione (Long, optimistic locking)
│
├── Content Metadata
│ ├── titolo (String, max 200 chars)
│ ├── descrizione (String, max 1000 chars)
│ ├── nomeFileOriginale (String, original filename)
│ ├── mimeType (String, content type)
│ └── dimensioneBytes (Long, max 50MB)
│
├── Classification
│ ├── categoria (CategoriaDocumento enum)
│ ├── categoriaPersonalizzataId (Long, optional)
│ └── tipoCartella (TipoCartella enum, auto-detected)
│
├── Ownership & Tracking
│ ├── caricatoDaId (Long, uploader user ID)
│ ├── spesaId (Long, linked expense)
│ ├── creatoIl (Instant, creation timestamp)
│ └── aggiornatoIl (Instant, last modified)
│
├── Extracted Metadata (JSON)
│ └── metadati (Map<String, Object>)
│
├── Child Entities: Versions
│ └── versioni (List<VersioneDocumento>)
│ ├── Version 1 (initial upload)
│ ├── Version 2 (updated file)
│ └── Version N...
│
└── Child Entities: Sharing Links
└── linkCondivisione (Set<LinkCondivisioneDocumento>)
├── Link A (active, 5 days remaining)
├── Link B (expired)
└── Link C (revoked)
The aggregate root uses optimistic locking via the
@Version annotation. If two users attempt to modify the
same document concurrently, the second write will fail with an
OptimisticLockException, preventing data corruption without
requiring database-level pessimistic locks.
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
) {
// Validate all creation parameters
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.tipoCartella = TipoCartella
.fromMimeTypeAndCategoria(mimeType, doc.categoria);
doc.versioneCorrente = 1;
// Create the first version automatically
VersioneDocumento primaVersione = VersioneDocumento.crea(
1, nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, hashContenuto,
caricatoDaId, "Versione iniziale"
);
primaVersione.setDocumento(doc);
doc.versioni.add(primaVersione);
return doc;
}
Notice that the factory method is static and the constructor is
protected. This pattern ensures that every DocumentoEvento
instance is created through the controlled factory method, where all invariants
are validated before the object enters the system. The first version is created
automatically during document creation with the note "Versione iniziale"
(initial version).
11 Predefined Categories
Play The Event provides 11 predefined document categories that cover the most common types of paperwork in event management. Each category carries a display name and an icon identifier used in the UI.
CategoriaDocumento Enum
| Enum Value | English Meaning | Icon | Use Case |
|---|---|---|---|
| FATTURA | Invoice | file-text | Vendor invoices, service billing |
| SCONTRINO | Receipt | receipt | Purchase receipts, expense proofs |
| PERMESSO | Permit | shield-check | Municipal permits, venue authorizations |
| BANDO | Public Notice / Call | megaphone | Calls for proposals, public tenders |
| CONTRATTO | Contract | file-signature | Vendor contracts, venue agreements |
| PREVENTIVO | Quote / Estimate | calculator | Cost estimates, price quotes |
| ASSICURAZIONE | Insurance | shield | Event liability, equipment insurance |
| CERTIFICATO | Certificate | award | Safety certifications, compliance docs |
| LOCANDINA | Poster / Flyer | image | Event posters, promotional materials |
| PERSONALIZZATA | Custom Category | tag | Event-specific custom categories |
| ALTRO | Other | file | Default fallback for uncategorized files |
The enum includes built-in intelligence. For example, the isRicevuta()
method returns true for both FATTURA and
SCONTRINO, allowing the system to route receipts and invoices
to dedicated storage folders automatically. The fromMimeType()
method provides default categorization based on content type: image files
default to LOCANDINA, everything else defaults to ALTRO.
public boolean isRicevuta() {
return this == FATTURA || this == SCONTRINO;
}
public static CategoriaDocumento fromMimeType(String mimeType) {
if (mimeType == null) {
return ALTRO;
}
if (mimeType.startsWith("image/")) {
return LOCANDINA;
}
return ALTRO;
}
Custom Categories per Event
While the 11 predefined categories cover most scenarios, every event is
unique. A music festival might need a "Stage Plan" category, while a corporate
conference might need "Speaker Agreement." The
CategoriaDocumentoPersonalizzata entity allows organizers to
define custom document categories scoped to their specific event.
CategoriaDocumentoPersonalizzata
├── id (Long)
├── eventoId (Long) ─── scoped to this event
├── nome (String, max 50 chars) ─── unique per event
├── descrizione (String, max 200 chars)
├── icona (String, default: "file-text")
├── colore (String, hex format #RRGGBB, default: #6b7280)
├── ordine (Integer) ─── display ordering
└── creatoIl (Instant)
UNIQUENESS CONSTRAINT:
(evento_id, nome) must be unique
→ No duplicate category names within the same event
VALIDATION RULES:
├── nome: required, max 50 characters
├── descrizione: optional, max 200 characters
├── colore: hex format (#RRGGBB) or default #6b7280
└── icona: any icon name or default "file-text"
Custom categories are linked to documents via the categoriaPersonalizzataId
field on DocumentoEvento. When a document's category is set to
PERSONALIZZATA, the custom category ID is stored; for any other
category, the custom ID is automatically cleared to null,
maintaining referential consistency.
Custom Category Features
- Color-coded: Each category has a hex color for visual distinction in the UI
- Icon support: Custom icon from the Lucide icon set
- Ordered display: The
ordinefield controls category ordering - Event-scoped: Categories are isolated per event, no cross-event leakage
- Validated: Color must be valid hex (#RRGGBB), name must be unique per event
Automatic Folder Detection
When a document is uploaded to Play The Event,
the system automatically determines the appropriate storage folder based on
two factors: the file's MIME type and the document's
category. This is handled by the TipoCartella enum.
FOLDER DETECTION ALGORITHM
Input: mimeType (String), categoria (CategoriaDocumento)
Step 1: Check if category is a receipt type
IF categoria == FATTURA or categoria == SCONTRINO
THEN → RICEVUTE folder ("/ricevute")
Step 2: Check if file is an image
IF mimeType starts with "image/"
THEN → IMMAGINI folder ("/immagini")
Step 3: Default
ELSE → DOCUMENTI folder ("/documenti")
EXAMPLES:
┌──────────────────────────┬──────────────┬──────────────┐
│ File │ Category │ Folder │
├──────────────────────────┼──────────────┼──────────────┤
│ invoice_2026.pdf │ FATTURA │ /ricevute │
│ receipt_001.jpg │ SCONTRINO │ /ricevute │
│ event_poster.png │ LOCANDINA │ /immagini │
│ team_photo.jpg │ ALTRO │ /immagini │
│ venue_contract.pdf │ CONTRATTO │ /documenti │
│ safety_cert.docx │ CERTIFICATO │ /documenti │
│ budget_estimate.xlsx │ PREVENTIVO │ /documenti │
└──────────────────────────┴──────────────┴──────────────┘
The priority is important: category takes precedence over MIME type. If an
organizer uploads a JPEG photograph of a receipt and categorizes it as
SCONTRINO, the file goes to the /ricevute folder,
not the /immagini folder. This ensures that financial documents
stay grouped together regardless of their format, which is critical for
audit trails and expense reconciliation.
Three Storage Folders
- DOCUMENTI - Contracts, permits, estimates, certificates, and general files
- IMMAGINI - Photos, posters, flyers, and visual assets
- RICEVUTE - Invoices and receipts, isolated for financial tracking
Detection Priority
- 1st: Category check (FATTURA/SCONTRINO override everything)
- 2nd: MIME type check (image/* goes to IMMAGINI)
- 3rd: Default (everything else to DOCUMENTI)
Versioning System
Documents evolve over time. A contract might go through multiple revisions, an event poster might need design updates, and a budget estimate might change as vendors respond with quotes. Play The Event supports full document versioning, allowing users to upload new versions, browse the complete history, and restore any previous version.
VersioneDocumento
├── id (Long)
├── documentoId (Long) ─── parent document
├── numeroVersione (Integer) ─── sequential, starting at 1
├── nomeFileOriginale (String, max 255 chars)
├── percorsoStorage (String, max 500 chars)
├── dimensioneBytes (Long)
├── mimeType (String, max 100 chars)
├── hashContenuto (String, SHA-256, 64 chars)
├── creatoDaId (Long) ─── who uploaded this version
├── notaVersione (String, max 500 chars) ─── change note
└── creatoIl (Instant, creation timestamp)
CONSTRAINTS:
UNIQUE(documento_id, numero_versione)
→ No duplicate version numbers per document
INDEXES:
(documento_id) ─── fast lookup by document
(documento_id, numero_versione DESC) ─── ordered history
Adding a New Version
When a user uploads a new version of an existing document, the aggregate root
increments the version counter, updates the top-level file metadata, and
creates a new VersioneDocumento entity in a single atomic
operation.
public VersioneDocumento aggiungiVersione(
String nomeFileOriginale,
String percorsoStorage,
Long dimensioneBytes,
String mimeType,
String hashContenuto,
Long caricatoDaId,
String notaVersione
) {
// Validate all version parameters
validaParametriVersione(nomeFileOriginale,
percorsoStorage, dimensioneBytes,
mimeType, caricatoDaId);
// Increment version counter
this.versioneCorrente++;
// Update document-level metadata
this.nomeFileOriginale = nomeFileOriginale;
this.mimeType = mimeType;
this.dimensioneBytes = dimensioneBytes;
// Create the new version entity
VersioneDocumento nuovaVersione = VersioneDocumento.crea(
this.versioneCorrente,
nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, hashContenuto,
caricatoDaId, notaVersione
);
nuovaVersione.setDocumento(this);
this.versioni.add(nuovaVersione);
return nuovaVersione;
}
Restoring a Previous Version
Restoring a version does not overwrite history. Instead, it creates a new version with the content of the previous one, maintaining a complete audit trail. If you restore version 2 when the current version is 5, the system creates version 6 with the content of version 2 and the note "Ripristino versione 2".
VERSION RESTORE EXAMPLE
Document: "Venue Contract.pdf"
Current: Version 5
User action: Restore Version 2
BEFORE RESTORE:
v1 ─ Initial upload (Jan 10)
v2 ─ Added appendix A (Jan 15)
v3 ─ Price update (Jan 20)
v4 ─ Legal review changes (Feb 1)
v5 ─ Final signatures (Feb 5) ← current
AFTER RESTORE:
v1 ─ Initial upload (Jan 10)
v2 ─ Added appendix A (Jan 15)
v3 ─ Price update (Jan 20)
v4 ─ Legal review changes (Feb 1)
v5 ─ Final signatures (Feb 5)
v6 ─ "Ripristino versione 2" (Feb 14) ← current
(same content as v2)
KEY POINT: No history is lost.
Version 5 remains accessible.
Immutable Version History
Versions are never deleted or overwritten. The restore operation creates a new version pointing to the same storage path and hash as the source version. This design ensures a complete, tamper-evident audit trail that satisfies regulatory requirements for document management in event planning, especially when public funding or permits are involved.
Sharing Links
Organizers often need to share documents with external stakeholders: venue managers, city officials, sponsors, or vendors who do not have an account on Play The Event. The platform provides temporary sharing links that allow unauthenticated access to specific documents with built-in security controls.
LinkCondivisioneDocumento
├── id (Long)
├── documentoId (Long) ─── linked document
├── token (String, 64 chars, UUID-based, unique)
├── scadenza (Instant) ─── expiration timestamp
├── creatoDaId (Long) ─── who created the link
├── numeroDownload (Integer, starts at 0)
├── maxDownload (Integer, null = unlimited)
├── revocato (Boolean, default false)
├── revocatoIl (Instant, revocation timestamp)
└── creatoIl (Instant, creation timestamp)
SECURITY CONSTRAINTS:
Maximum link duration: 30 days
Token: 64 hex characters (UUID-based, unique index)
Validation: expiration + download count + revocation
Link Validity Check
A sharing link is considered valid only when all three conditions are met simultaneously. If any one fails, the link is invalid and the download is blocked.
LINK VALIDITY = ALL of:
1. NOT revoked
├── revocato == false
└── If true → link was manually disabled
2. NOT expired
├── Instant.now() is BEFORE scadenza
└── If past → time-based expiration
3. Downloads NOT exhausted
├── maxDownload == null → unlimited ✓
└── numeroDownload < maxDownload → still valid ✓
EXAMPLE SCENARIOS:
┌─────────┬─────────┬──────────┬──────────┐
│ Revoked │ Expired │ Downloads│ Valid? │
├─────────┼─────────┼──────────┼──────────┤
│ false │ false │ 2/5 │ YES ✓ │
│ false │ false │ 5/5 │ NO ✗ │
│ false │ true │ 0/∞ │ NO ✗ │
│ true │ false │ 0/5 │ NO ✗ │
│ false │ false │ 3/∞ │ YES ✓ │
└─────────┴─────────┴──────────┴──────────┘
Link Creation
- Generated through the aggregate root only
- Token: 64-character hex from two UUIDs
- Duration validated (positive, max 30 days)
- Max download count optional (null = unlimited)
- Tracks creation time and creator ID
Link Revocation
- Immediate: sets
revocato = true - Records revocation timestamp
- Idempotent: revoking twice is safe
- Active links filterable via
getLinkAttivi() - Revoked links remain in history for audit
Download Tracking
Every time a sharing link is used to download a document, the
registraDownload() method increments the download counter.
This operation first checks link validity; if the link is no longer valid
(expired, revoked, or downloads exhausted), an IllegalStateException
is thrown, preventing the download.
public void registraDownload() {
if (!isValido()) {
throw new IllegalStateException(
"Cannot register download: link not valid"
);
}
this.numeroDownload++;
}
// Helper methods for remaining resources
public Duration getTempoRimanente() {
if (isScaduto()) return Duration.ZERO;
return Duration.between(Instant.now(), scadenza);
}
public Integer getDownloadRimanenti() {
if (maxDownload == null) return null; // unlimited
return Math.max(0, maxDownload - numeroDownload);
}
Metadata Extraction
When a file is uploaded, Play The Event extracts available metadata and stores
it as a JSON map in the metadati field. This
provides valuable context about each document without requiring users to
manually enter information.
METADATA EXTRACTION BY FILE TYPE
IMAGE FILES (JPEG, TIFF, PNG):
├── EXIF data
│ ├── camera make/model
│ ├── date taken
│ ├── GPS coordinates (if available)
│ ├── resolution (width x height)
│ └── orientation
├── IPTC data
│ ├── caption/description
│ ├── keywords
│ └── copyright
└── XMP data
├── creator tool
└── custom metadata
PDF FILES:
├── title
├── author
├── subject
├── creator application
├── creation date
├── modification date
├── page count
└── PDF version
OFFICE FILES (DOCX, XLSX, PPTX):
├── title
├── author
├── last modified by
├── creation date
├── modification date
├── word count (documents)
├── slide count (presentations)
└── sheet names (spreadsheets)
STORAGE FORMAT:
Column: metadati (JSON)
Converter: JsonMapConverter
Type: Map<String, Object>
The metadata system is designed to be flexible. The Map<String, Object>
type allows storing any key-value pair, accommodating different metadata
schemas from different file types. Individual metadata entries can be added
or queried through dedicated methods on the aggregate root.
// Set all metadata at once (after extraction)
doc.impostaMetadati(extractedMetadata);
// Add or update a single metadata entry
doc.aggiungiMetadato("pageCount", 15);
doc.aggiungiMetadato("author", "Federico Calo");
// Query specific metadata
Object pageCount = doc.getMetadato("pageCount");
// Check if metadata exists
boolean hasMetadata = doc.haMetadati();
File Integrity: SHA-256 Hashing
Every file version in Play The Event stores a SHA-256 hash
of the file content in the hashContenuto field. This 64-character
hexadecimal string serves as a cryptographic fingerprint, enabling integrity
verification at any point in the document's lifecycle.
FILE INTEGRITY SYSTEM
SHA-256 HASH:
├── Generated at upload time
├── Stored per version (not per document)
├── 64-character hex string
├── Example: "a1b2c3d4e5f6...7890abcdef"
└── Used for:
├── Deduplication detection
├── Corruption verification
├── Tamper detection
└── Version comparison
SIZE LIMITS:
Maximum file size: 50MB (52,428,800 bytes)
Validated at:
├── Document creation (factory method)
└── Version addition (each new upload)
private static final long MAX_DIMENSIONE_BYTES =
50L * 1024 * 1024; // 50MB
VALIDATION:
if (dimensioneBytes > MAX_DIMENSIONE_BYTES) {
throw new IllegalArgumentException(
"File troppo grande (max 50MB)"
);
}
Why SHA-256 per Version?
The hash is stored on each VersioneDocumento rather than on
the parent DocumentoEvento. This allows the system to verify
integrity of every individual version independently. When restoring a
previous version, the hash from the original version is carried over,
enabling verification that the restored content matches the original
exactly.
Document-Expense Linking
One of the most powerful features of the document system is its integration with the expense tracking module. Receipts and invoices can be linked directly to expense entries, creating a complete financial audit trail.
DOCUMENT-EXPENSE LINKING
Link a document to an expense:
doc.collegaASpesa(spesaId);
→ Sets spesaId on the document
→ Enables bi-directional query
Unlink a document from an expense:
doc.scollegaDaSpesa();
→ Sets spesaId to null
Query:
doc.isCollegatoASpesa();
→ Returns true if spesaId != null
DATABASE INDEX:
idx_documento_spesa ON (spesa_id)
→ Fast lookup of all documents for an expense
USE CASE FLOW:
1. Organizer creates expense "Venue Deposit - $2000"
2. Uploads receipt photo (JPEG)
→ Category: SCONTRINO
→ Folder: /ricevute (auto-detected)
3. Links document to expense
→ doc.collegaASpesa(expenseId)
4. Later, accountant queries all receipts for expense
→ SELECT * FROM documenti_evento
WHERE spesa_id = ?
Linked Documents Benefit
- Complete audit trail from expense to receipt
- Financial reporting with attached proofs
- Budget reconciliation with document verification
- Compliance with grant and subsidy requirements
Query Helpers
isCollegatoASpesa()- checks link statusisRicevuta()- checks if receipt folderisImmagine()- checks if image folderhaCategoriaPersonalizzata()- custom category check
Organized Storage Architecture
The document management system in Play The Event organizes files into a clear directory structure that combines event isolation with automatic categorization.
STORAGE LAYOUT
/events
└── /{eventoId}
└── /documents
├── /documenti ← contracts, permits, certificates
│ ├── doc_001_v1.pdf
│ ├── doc_001_v2.pdf
│ ├── doc_005_v1.docx
│ └── ...
│
├── /immagini ← photos, posters, visual assets
│ ├── doc_002_v1.jpg
│ ├── doc_003_v1.png
│ └── ...
│
└── /ricevute ← invoices and receipts
├── doc_004_v1.pdf
├── doc_004_v2.pdf
└── ...
KEY PROPERTIES:
├── Event isolation: each event has its own directory tree
├── Folder auto-detection: based on MIME + category
├── Version co-location: all versions of a document
│ live in the same folder
├── Path stored per version: enables independent access
└── Path updateable: supports post-creation path adjustment
Each version stores its own percorsoStorage (storage path), with
a maximum length of 500 characters. The aggiornaPercorsoStorage()
method on VersioneDocumento allows updating the path after initial
creation, which is essential during the save flow when the document ID is not
yet available at object construction time.
Database Indexing Strategy
Performance is a key consideration for the document system. The domain model declares strategic database indexes to ensure fast queries across the most common access patterns.
Index Map
| Index | Columns | Purpose |
|---|---|---|
| idx_documento_evento | evento_id | List all documents for an event |
| idx_documento_categoria | categoria | Filter documents by category |
| idx_documento_tipo_cartella | tipo_cartella | Filter by storage folder type |
| idx_documento_caricato_da | caricato_da_id | Find documents uploaded by a user |
| idx_documento_spesa | spesa_id | Find receipts linked to an expense |
| idx_versione_documento | documento_id | List all versions of a document |
| idx_versione_numero | documento_id, numero_versione DESC | Ordered version history |
| idx_link_token | token (unique) | Fast sharing link lookup |
| idx_link_documento | documento_id | All sharing links for a document |
| idx_link_scadenza | scadenza | Cleanup expired links |
In the Next Article
With document management covered, the next article will explore another critical aspect of the platform's domain model. The document system works in concert with expense tracking, event management, and participant coordination to deliver a comprehensive event planning experience. Visit www.playtheevent.com to see how all these systems work together in production.
Key Takeaways
- Documents are modeled as Aggregate Roots with optimistic locking for concurrent access safety
- 11 predefined categories cover common event document types, with custom categories for flexibility
- Automatic folder detection routes files to DOCUMENTI, IMMAGINI, or RICEVUTE based on MIME type and category
- Full versioning preserves complete history; restoring a version creates a new entry, never overwrites
- Sharing links provide controlled external access with expiration, download limits, and revocation
- SHA-256 hashing per version enables integrity verification and tamper detection
- Document-expense linking creates a complete financial audit trail from expense to receipt
- Metadata extraction captures EXIF, PDF, and Office properties automatically on upload
- Strategic database indexes optimize the most common query patterns across all entities







