Story 1.16.3: Generic File Upload Service

Last updated: December 4, 2025 Reading time: 15 minutes File size: 23.81 KB

Story 1.16.3: Generic File Upload Service

Status

Done

Story

As a developer, I want a generic file upload service that is independent of entity creation, so that files can be uploaded before entities exist, eliminating circular dependencies and enabling better user experience.

Domain Context

Primary Domain

Shared/Infrastructure (Cross-cutting file upload concerns)

Involved Services

  • Company User Management Service - Immediate use: Company logo uploads
  • User Service - Future use: Profile picture uploads
  • Event Management Service - Future use: Event banner uploads
  • Partner Coordination Service - Future use: Partner logo uploads

Shared-Kernel Foundation (automatically included):

  • Exception hierarchy (NotFoundException, ValidationException, BusinessException)
  • ErrorResponse (standardized error format with correlationId, path, severity)
  • DomainEvent & DomainEventPublisher (event-driven architecture)
  • Value objects (UUID identifiers)
  • SecurityContextHelper (user context extraction)

Cross-Domain Dependencies

  • AWS S3: Direct file storage with presigned URLs
  • AWS CloudFront: CDN for file delivery
  • Scheduled Job: Cleanup service for orphaned uploads

Requirements Context

  • FR-CMP-01: Company profile management with logo support
  • FR-USR-02: User profile with profile picture (future)
  • FR-EVT-03: Event management with banner images (future)

Problem Statement

Current architecture has circular dependency when creating companies with logos:

  1. Logo upload endpoint requires company to exist: POST /companies/{name}/logo/presigned-url
  2. Company cannot be created without being created first
  3. Current workaround: Create company without logo, then edit to add logo (poor UX)
  4. Same problem will affect user profiles, event banners, etc.

Acceptance Criteria Source

Epic 1.16 (Company Management Foundation), new acceptance criteria for file upload architecture

Architecture Context

Architecture Patterns

Referenced patterns from architecture docs:

Database Schema Reference

Architecture Schema (Source of Truth):

  • Doc: docs/architecture/ADR-002-generic-file-upload-service.md
  • New Table: logos with state machine (PENDING → CONFIRMED → ASSOCIATED)

Migration Check (Dev Agent completes in Task 0):

  • Existing Migration: None (new feature)
  • Status: ❌ Create new
  • Action: Create V6__Create_logos_table.sql

Infrastructure Components

  • S3 Bucket: batbern-{env}-company-logos
    • Temp prefix: logos/temp/{uploadId}/
    • Final prefix: logos/{year}/{entity-type}/{entity-name}/
  • CloudFront Distribution: CDN for logo delivery
  • Scheduled Job: Daily cleanup of orphaned uploads
    • PENDING status > 24 hours old
    • CONFIRMED status > 7 days old (not associated)

Wireframe Context

UI Components

No specific wireframes (infrastructure component). Reusable across:

  • Company Management screen (Story 2.5.1)
  • User Profile screen (future)
  • Event Management screen (future)

React Components

New Components:

  • FileUpload.tsx (web-frontend/src/components/shared/FileUpload/)

    • Props: { entityType?, entityId?, currentFileUrl?, onUploadSuccess, onUploadError, maxSize, allowedTypes }
    • Material-UI: Box, Typography, LinearProgress, IconButton, Alert
    • State: uploadProgress, isUploading, error, fileUrl
    • Supports drag-and-drop via react-dropzone
  • useFileUpload hook (web-frontend/src/hooks/useFileUpload/)

    • Logic: Generate presigned URL → Upload to S3 → Confirm upload
    • Returns: { uploadFile, progress, isUploading, error, reset }

Acceptance Criteria

  1. AC1: Generic Upload Initiation

    • Generic presigned URL endpoint POST /api/v1/logos/presigned-url works without entity existence
    • Validates file type (PNG, JPEG, SVG) and size (max 5MB)
    • Creates Logo entity with status PENDING
    • Returns presigned S3 URL for temp location: logos/temp/{uploadId}/
  2. AC2: Upload Confirmation

    • Confirmation endpoint POST /api/v1/logos/{uploadId}/confirm updates Logo status to CONFIRMED
    • Stores checksum for integrity verification
    • Logo record persists independently of any entity
  3. AC3: Entity Association

    • Entity creation can reference uploaded file via logoUploadId field
    • Company creation with logoUploadId copies S3 file from temp to final location
    • Final S3 key format: logos/{year}/{entity-type}/{entity-name}/logo-{fileId}.{ext}
    • Logo status updated to ASSOCIATED with entity reference
  4. AC4: S3 File Management

    • S3 copy operation from temp to final location on association
    • Original temp file cleaned up after successful copy
    • CloudFront URL generated for final location
  5. AC5: Automated Cleanup

    • Scheduled job runs daily
    • Deletes PENDING logos older than 24 hours
    • Deletes CONFIRMED (not associated) logos older than 7 days
    • Corresponding S3 files also deleted
  6. AC6: Frontend Component Reusability

    • FileUpload component supports generic mode (no entity) and entity-specific mode
    • useFileUpload hook handles presigned URL flow
    • Component location: web-frontend/src/components/shared/FileUpload/ (NOT under Company)
  7. AC7: Legacy Endpoint Removal

    • Old company-specific logo endpoints removed:
      • POST /companies/{name}/logo/presigned-url (DELETED)
      • POST /companies/{name}/logo/confirm (DELETED)
    • CompanyLogoService.java deleted entirely
    • Old LogoUpload.tsx component removed from Company folder
  8. AC8: Test Coverage

    • Unit tests for GenericLogoService (upload, confirm, associate, copy)
    • Integration tests for LogoController endpoints
    • Unit tests for LogoCleanupService
    • Frontend tests for FileUpload component and useFileUpload hook
    • E2E test: Create company with logo in single operation

Test Specifications (TDD)

Test Scenarios by Acceptance Criteria

AC1 Tests (Upload Initiation):

  • Test 1.1: should_generatePresignedUrl_when_validFileProvided
  • Test 1.2: should_createLogoWithPendingStatus_when_uploadRequested
  • Test 1.3: should_throwFileSizeExceeded_when_fileSizeOver5MB
  • Test 1.4: should_throwInvalidFileType_when_unsupportedExtension
  • Test 1.5: should_generateUniqueTempS3Key_when_uploadRequested

AC2 Tests (Upload Confirmation):

  • Test 2.1: should_updateLogoToConfirmed_when_uploadConfirmed
  • Test 2.2: should_storeChecksum_when_confirmationProvided
  • Test 2.3: should_throwNotFound_when_invalidUploadId

AC3 Tests (Entity Association):

  • Test 3.1: should_associateLogoWithCompany_when_logoUploadIdProvided
  • Test 3.2: should_ignoreLogoAssociation_when_logoUploadIdNull
  • Test 3.3: should_updateLogoStatus_when_associationSuccessful

AC4 Tests (S3 File Management):

  • Test 4.1: should_copyS3Object_when_associatingLogo
  • Test 4.2: should_generateFinalS3Key_when_associating
  • Test 4.3: should_generateCloudFrontUrl_when_associationComplete
  • Test 4.4: should_deleteTempFile_when_copySuccessful

AC5 Tests (Cleanup):

  • Test 5.1: should_deletePendingLogos_when_olderThan24Hours
  • Test 5.2: should_deleteConfirmedLogos_when_olderThan7Days
  • Test 5.3: should_preserveAssociatedLogos_when_cleanupRuns
  • Test 5.4: should_deleteCorrespondingS3Files_when_cleaningUpLogos

AC6 Tests (Frontend):

  • Test 6.1: should_uploadFile_when_dropzoneUsed
  • Test 6.2: should_showProgress_when_uploadInProgress
  • Test 6.3: should_callOnSuccess_when_uploadCompletes
  • Test 6.4: should_callOnError_when_uploadFails

AC7 Tests (Cleanup):

  • Test 7.1: should_return404_when_accessingOldLogoEndpoint
  • Test 7.2: should_compileWithoutCompanyLogoService_when_deleted

AC8 Tests (Integration):

  • Test 8.1: should_createCompanyWithLogo_when_logoUploadIdProvided_E2E

Test File Locations

Backend Tests:

  • Unit: services/company-user-management-service/src/test/java/ch/batbern/companyuser/service/GenericLogoServiceTest.java
  • Unit: services/company-user-management-service/src/test/java/ch/batbern/companyuser/service/LogoCleanupServiceTest.java
  • Integration: services/company-user-management-service/src/test/java/ch/batbern/companyuser/controller/LogoControllerTest.java
  • Integration: services/company-user-management-service/src/test/java/ch/batbern/companyuser/integration/CompanyWithLogoIntegrationTest.java

Frontend Tests:

  • Unit: web-frontend/src/components/shared/FileUpload/__tests__/FileUpload.test.tsx
  • Unit: web-frontend/src/hooks/useFileUpload/__tests__/useFileUpload.test.ts
  • Integration: web-frontend/src/components/shared/Company/__tests__/CompanyForm.test.tsx (updated)
  • E2E: web-frontend/e2e/workflows/company-management/company-creation.spec.ts (updated)

Test Data & Mocks

Test Data Builders:

  • LogoTestDataBuilder for creating test Logo entities with various states
  • CompanyTestDataBuilder enhancement for logoUploadId

Mock Services:

  • Mock S3Presigner for presigned URL generation
  • Mock S3Client for copy/delete operations
  • Mock scheduled job execution for cleanup tests

Test Containers (MANDATORY):

  • PostgreSQL 16 Alpine via Testcontainers for ALL integration tests
    • All integration tests MUST extend AbstractIntegrationTest
    • Use singleton pattern with withReuse(true) for performance
    • Enable Flyway migrations for production parity
  • LocalStack for AWS S3 mocking

Test Configuration:

  • application-test.properties must use PostgreSQL dialect
  • spring.jpa.hibernate.ddl-auto=validate (let Flyway manage schema)
  • spring.flyway.enabled=true for testing real migrations
  • Mock S3 with LocalStack or Testcontainers S3

Tasks / Subtasks (TDD Workflow)

    • Delete web-frontend/src/components/shared/Company/tests

Dev Notes - Implementation Guide

Technical Design Notes

State Machine:

PENDING (upload requested)
   ↓
CONFIRMED (file uploaded to S3, checksum verified)
   ↓
ASSOCIATED (linked to entity, copied to final location)

Service Architecture:

  • GenericLogoService: Core upload/confirm/associate logic
  • LogoCleanupService: Scheduled job (@Scheduled cron)
  • LogoController: REST API endpoints

S3 Key Strategy:

  • Temp: logos/temp/{uploadId}/logo-{fileId}.{ext}
  • Final: logos/{year}/{entity-type}/{entity-name}/logo-{fileId}.{ext}

Transaction Boundaries:

  • Upload initiation: Single transaction (create Logo entity)
  • Confirmation: Single transaction (update Logo status)
  • Association: Transaction with company creation (atomic)

Error Handling:

  • FileSizeExceededException (>5MB)
  • InvalidFileTypeException (not PNG/JPEG/SVG)
  • LogoNotFoundException (invalid uploadId)
  • S3OperationException (copy/delete failures)

API Contracts

POST /api/v1/logos/presigned-url

yaml
requestBody:
  content:
    application/json:
      schema:
        type: object
        required: [fileName, fileSize, mimeType]
        properties:
          fileName:
            type: string
            example: "company-logo.png"
          fileSize:
            type: integer
            format: int64
            maximum: 5242880  # 5MB
            example: 102400
          mimeType:
            type: string
            enum: [image/png, image/jpeg, image/svg+xml]
            example: "image/png"

responses:
  '200':
    description: Presigned URL generated
    content:
      application/json:
        schema:
          type: object
          properties:
            uploadUrl:
              type: string
              format: uri
              example: "https://s3.amazonaws.com/bucket/key?signature=..."
            uploadId:
              type: string
              description: "Internal upload identifier"
              example: "abc123-def456"
            fileId:
              type: string
              example: "f3e8d1a4"
            expiresInMinutes:
              type: integer
              example: 15
            requiredHeaders:
              type: object
              example: {"Content-Type": "image/png"}

POST /api/v1/logos/{uploadId}/confirm

yaml
parameters:
  - name: uploadId
    in: path
    required: true
    schema:
      type: string

requestBody:
  content:
    application/json:
      schema:
        type: object
        required: [fileExtension]
        properties:
          fileExtension:
            type: string
            enum: [png, jpg, jpeg, svg]
            example: "png"
          checksum:
            type: string
            example: "sha256:abc123..."

responses:
  '200':
    description: Upload confirmed
    content:
      application/json:
        schema:
          type: object
          properties:
            uploadId:
              type: string
            status:
              type: string
              enum: [CONFIRMED]

POST /api/v1/companies (Updated)

yaml
requestBody:
  content:
    application/json:
      schema:
        type: object
        required: [name]
        properties:
          name:
            type: string
          displayName:
            type: string
          logoUploadId:  # NEW FIELD
            type: string
            description: "Upload ID from /logos/presigned-url"
            example: "abc123-def456"
          # ... other fields

Testing Requirements

Unit Test Coverage: >90% for service classes

  • GenericLogoServiceTest
  • LogoCleanupServiceTest

Integration Test Coverage: >80% for controllers

  • LogoControllerTest (Spring Boot Test)
  • CompanyWithLogoIntegrationTest

E2E Tests:

  • Create company with logo (single operation)
  • Upload logo, wait, then create company
  • Verify cleanup job removes orphaned uploads

Performance Tests:

  • Presigned URL generation < 100ms
  • S3 copy operation < 2s
  • Cleanup job processes 1000+ logos in < 30s

Mock Configuration:

java
@MockBean
private S3Presigner s3Presigner;

@MockBean
private S3Client s3Client;

@TestConfiguration
static class TestConfig {
    @Bean
    public S3Presigner mockS3Presigner() {
        S3Presigner presigner = mock(S3Presigner.class);
        // Configure mock presigned URL generation
        return presigner;
    }
}

Schema Validation Test

Location: services/company-user-management-service/src/test/java/ch/batbern/companyuser/validation/LogoSchemaValidationTest.java

java
@SpringBootTest
@Testcontainers
class LogoSchemaValidationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withReuse(true);

    @Autowired
    private EntityManager entityManager;

    @Test
    void shouldValidateLogoEntityMatchesMigration() {
        // Verify all @Column annotations match V6 migration
        // Verify indexes exist
        // Verify constraints match
    }
}

Definition of Done Checklist

Development Complete

Schema Validation (For JPA/Database stories)

Migration Alignment:

Entity Validation:

Documentation:

Infrastructure Complete

Frontend Complete

Review Ready

Change Log

Date Version Description Author
2025-10-27 1.0 Initial story creation Winston (Architect Agent)

Dev Agent Record

Agent Model Used

Claude Sonnet 4.5 (claude-sonnet-4-5-20250929)

Implementation Approach

TDD approach following Red-Green-Refactor cycle:

  1. Create migration first (V6)
  2. Write failing tests for each component
  3. Implement minimal code to pass tests
  4. Refactor while keeping tests green

Debug Log References

(To be filled during implementation)

Completion Notes

(To be filled during implementation)

Files Changed

Created:

  • Backend: Logo.java, LogoStatus.java, LogoRepository.java, GenericLogoService.java, LogoCleanupService.java, LogoController.java, V6__Create_logos_table.sql
  • Frontend: FileUpload.tsx, useFileUpload.ts, tests

Modified:

  • CreateCompanyRequest.java, CompanyService.java, CompanyController.java, CompanyForm.tsx

Deleted:

  • CompanyLogoService.java, LogoUpload.tsx, LogoUpload.test.tsx

Deployment Notes

Deployment Order:

  1. Deploy backend with new endpoints (API backward compatible during transition)
  2. Deploy frontend with new FileUpload component
  3. Monitor cleanup job execution
  4. Verify no orphaned uploads accumulate

Rollback Procedure:

  • Revert backend deployment
  • Database: logos table can remain (unused)
  • S3: No cleanup needed (orphans will be removed by cleanup job)

QA Results

(To be filled by QA Agent after implementation)