Skip to main content
CCDigital manages the complete lifecycle of citizen documents from upload through review approval to file storage and versioning.

Document Model

Documents in CCDigital follow a two-tier model:

Document Definition

Catalog of available document types (e.g., “Cedula de Ciudadania”, “Diploma”)Entity: DocumentDefinition.java

Person Document

Instance of a document type associated with a specific personEntity: PersonDocument.javaTable: person_documents

Entity Structure

The PersonDocument entity represents a document instance with full review workflow support:
// PersonDocument.java
@Entity
@Table(name = "person_documents")
public class PersonDocument {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "person_id", nullable = false)
    private Person person;
    
    @ManyToOne
    @JoinColumn(name = "document_id", nullable = false)
    private DocumentDefinition documentDefinition;
    
    @Enumerated(EnumType.STRING)
    @Column(name = "status")
    private PersonDocumentStatus status; // VIGENTE, VENCIDO, REVOCADO
    
    @Column(name = "issued_at")
    private LocalDate issueDate;
    
    @Column(name = "expires_at")
    private LocalDate expiryDate;
    
    @ManyToOne
    @JoinColumn(name = "issuer_entity_id")
    private IssuingEntity issuerEntity;
    
    // Review workflow fields
    @Enumerated(EnumType.STRING)
    @Column(name = "review_status")
    private ReviewStatus reviewStatus; // PENDING, APPROVED, REJECTED
    
    @Column(name = "reviewed_by_user")
    private Long reviewedByUserId;
    
    @Column(name = "reviewed_at")
    private LocalDateTime reviewedAt;
    
    @Column(name = "review_notes")
    private String reviewNotes;
    
    @OneToMany(mappedBy = "personDocument", cascade = CascadeType.ALL)
    private List<FileRecord> files;
}

Document Upload Workflows

CCDigital supports three distinct upload workflows based on user role:

1. Admin Upload

Government administrators can upload any document type for any citizen.
1

Select Person and Document Type

Admin navigates to /admin/persons/{id}/upload and selects the document type from the catalog.
2

Upload File

File is uploaded and stored in the person’s folder with cryptographic hash.
// PersonDocumentService.java:316
@Transactional
public PersonDocument uploadForPerson(Long personId, Long documentId,
                                     PersonDocumentStatus status,
                                     LocalDate issueDate, LocalDate expiryDate,
                                     MultipartFile file) {
    
    Person person = personRepository.findById(personId)
        .orElseThrow(() -> new IllegalArgumentException("Persona no encontrada"));
    
    DocumentDefinition def = documentDefinitionService.findById(documentId)
        .orElseThrow(() -> new IllegalArgumentException("Documento no encontrado"));
    
    PersonDocument pd = new PersonDocument();
    pd.setPerson(person);
    pd.setDocumentDefinition(def);
    pd.setReviewStatus(ReviewStatus.PENDING);
    
    PersonDocument savedPd = personDocumentRepository.save(pd);
    
    // Store file with SHA256 hash
    FileStorageService.StoredFileInfo info = fileStorageService.storePersonFile(person, file);
    
    FileRecord fr = new FileRecord();
    fr.setPersonDocument(savedPd);
    fr.setHashSha256(info.getSha256());
    fr.setStoragePath(info.getRelativePath());
    
    fileRecordRepository.save(fr);
    return savedPd;
}
3

Review Queue

Document enters PENDING state awaiting review at /admin/person-documents/{id}/review.

2. Issuer Upload

Authorized entities (universities, hospitals, etc.) can upload documents they are permitted to issue.
1

Search Person

Issuer searches for person by id_type and id_number at /issuer.
2

Validate PDF

System enforces strict PDF validation:
// PersonDocumentService.java:268
private boolean isPdfFile(MultipartFile file) {
    // Check extension
    boolean hasPdfExtension = originalName.toLowerCase().endsWith(".pdf");
    
    // Check MIME type
    boolean hasPdfMime = contentType.toLowerCase().contains("pdf");
    
    // Check file signature
    byte[] header = file.getInputStream().readNBytes(4);
    boolean hasPdfSignature = header[0] == '%' 
                           && header[1] == 'P'
                           && header[2] == 'D'
                           && header[3] == 'F';
    
    return hasPdfSignature && (hasPdfExtension || hasPdfMime);
}
3

Permission Check

System validates issuer has permission for the document type:
// PersonDocumentService.java:187
@Transactional
public PersonDocument uploadFromIssuer(Long issuerId, Long personId,
                                      Long documentId, /* ... */) {
    
    // Validate issuer permission
    boolean allowed = documentDefinitionService.findAllowedByIssuer(issuerId)
        .stream()
        .anyMatch(d -> d.getId().equals(documentId));
    
    if (!allowed) {
        throw new IllegalArgumentException(
            "Este emisor no tiene permitido cargar ese tipo de documento."
        );
    }
    
    // ... continue with upload
}
4

Store with Metadata

Document stored with fixed MIME type application/pdf and SHA256 hash for integrity verification.
Issuer Restriction: Only PDF files are accepted from issuer uploads. Extension, MIME type, and file signature (%PDF) are all validated.

3. API Upload

Programmatic upload via REST API using PersonDocumentRequest DTO.
// PersonDocumentService.java:107
@Transactional
public PersonDocument create(PersonDocumentRequest request) {
    Person person = personRepository.findById(request.getPersonId())
        .orElseThrow(() -> new IllegalArgumentException("Persona no encontrada"));
    
    DocumentDefinition def = documentDefinitionService.findById(request.getDocumentId())
        .orElseThrow(() -> new IllegalArgumentException("Documento no encontrado"));
    
    PersonDocument pd = new PersonDocument();
    pd.setPerson(person);
    pd.setDocumentDefinition(def);
    pd.setReviewStatus(ReviewStatus.PENDING);
    
    PersonDocument saved = personDocumentRepository.save(pd);
    
    // If storagePath provided, create FileRecord
    if (request.getStoragePath() != null) {
        FileRecord fr = new FileRecord();
        fr.setPersonDocument(saved);
        fr.setStoragePath(request.getStoragePath());
        fr.setHashSha256(request.getHashSha256());
        fr.setStoredAs(FileStoredAs.PATH);
        fileRecordRepository.save(fr);
    }
    
    return saved;
}

Review Workflow

All uploaded documents must be reviewed by government administrators before they can be accessed through access requests.
1

Pending Review

New documents have review_status = PENDING and are listed at /admin/persons/{id}.
2

Administrator Review

Admin opens document at /admin/person-documents/{id}/review and chooses:
  • APPROVED - Document is valid and can be shared
  • REJECTED - Document has issues and cannot be shared
  • PENDING - Return to queue for later review
// PersonDocumentService.java:387
@Transactional
public void review(Long personDocumentId, ReviewStatus status, String notes) {
    PersonDocument pd = personDocumentRepository.findById(personDocumentId)
        .orElseThrow(() -> new IllegalArgumentException("PersonDocument no encontrado"));
    
    pd.setReviewStatus(status != null ? status : ReviewStatus.PENDING);
    pd.setReviewNotes(notes != null && !notes.isBlank() ? notes.trim() : null);
    pd.setReviewedAt(LocalDateTime.now());
    pd.setReviewedByUserId(null); // Set to actual admin ID when auth is implemented
    
    personDocumentRepository.save(pd);
}
3

Access Control

Only documents with review_status = APPROVED can be included in access requests.Validation: AccessRequestService.java:168

File Storage Architecture

Files are stored on the local filesystem with metadata tracked in the database.

Storage Service

// FileStorageService.java
@Service
public class FileStorageService {
    
    @Value("${ccdigital.fs.base-path}")
    private String basePath;
    
    public StoredFileInfo storePersonFile(Person person, MultipartFile file) {
        // Create person folder with normalized name
        String folderName = normalizePersonFolderName(person);
        Path personDir = Paths.get(basePath, folderName);
        Files.createDirectories(personDir);
        
        // Generate unique filename
        String originalName = file.getOriginalFilename();
        String fileName = generateUniqueFileName(originalName);
        Path targetPath = personDir.resolve(fileName);
        
        // Copy file and calculate SHA256
        file.transferTo(targetPath);
        String sha256 = calculateSHA256(targetPath);
        
        // Return relative path from base
        String relativePath = basePath.relativize(targetPath).toString();
        
        return new StoredFileInfo(
            originalName,
            relativePath,
            file.getSize(),
            sha256
        );
    }
}
Configuration:
  • CCDIGITAL_FS_BASE_PATH - Root directory for document storage
  • Files organized in person-specific folders
  • Folder names normalized from person identification

File Metadata

File details are stored in the files table via FileRecord entity:
@Entity
@Table(name = "files")
public class FileRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "person_document_id")
    private PersonDocument personDocument;
    
    @Column(name = "original_name")
    private String originalName;
    
    @Column(name = "mime_type")
    private String mimeType;
    
    @Column(name = "byte_size")
    private Long byteSize;
    
    @Column(name = "hash_sha256")
    private String hashSha256;
    
    @Enumerated(EnumType.STRING)
    @Column(name = "stored_as")
    private FileStoredAs storedAs; // PATH or BLOB
    
    @Column(name = "storage_path")
    private String storagePath;
    
    @Column(name = "version")
    private Integer version;
}

Version Control

File versions are automatically managed through a database trigger. Trigger: trg_files_autoversion When a new file is inserted for an existing person_document_id:
  1. Existing file versions are incremented
  2. New file receives version = 1
  3. Version history is maintained for audit purposes
When loading documents for access requests, the system selects the file with the highest version number to serve the most recent revision.Implementation: AccessRequestService.java:619

Document Status Lifecycle

Documents have a functional status separate from review status:
  • VIGENTE - Document is currently valid
  • VENCIDO - Document has expired (based on expires_at)
  • REVOCADO - Document has been revoked by issuer
  • SUSPENDIDO - Document is temporarily suspended
The review status controls access permission:
  • PENDING - Awaiting admin review (default)
  • APPROVED - Validated by admin, can be shared
  • REJECTED - Invalid, cannot be shared

Key Services

Location: src/main/java/co/edu/unbosque/ccdigital/service/PersonDocumentService.javaCentral service for document lifecycle management including upload, review, and retrieval operations.
Location: src/main/java/co/edu/unbosque/ccdigital/service/FileStorageService.javaHandles physical file storage, path validation, and resource loading for document access.
Location: src/main/java/co/edu/unbosque/ccdigital/service/DocumentDefinitionService.javaManages the document catalog and issuer permissions for document types.

Database Schema

Core Tables

  • person_documents - Document instances with review workflow
    • Unique constraint: (person_id, document_id, issued_at)
  • files - File metadata and version history
    • Auto-versioning via trg_files_autoversion trigger
  • documents (via DocumentDefinition) - Document type catalog
    • Links to categories for organization
  • entity_document_definitions - Junction table for issuer permissions

Stored Procedures

  • sp_add_person_document - Helper for document creation
  • sp_upload_file_path - File path registration
  • sp_upload_pdf_blob - PDF blob storage (legacy)

Security Features

Path Validation: All file access operations validate that the requested path is within the allowed base directory to prevent path traversal attacks.Service: FileStorageService.java
Integrity Verification: Every file is hashed with SHA256 upon storage. The hash can be used to verify file integrity before serving or during sync to Hyperledger Fabric.

Build docs developers (and LLMs) love