Appearance
Storage Stats and Trash Accounting
Summary
The Storage module persists per-organization usage counters in storage_stats. The API now exposes a dedicated trashedFiles counter so the stats payload can distinguish between total stored documents and documents that are currently soft-deleted in trash.
Business Rules
totalFilescounts all persisted documents for the organization until they are permanently deleted.totalSizetracks the total byte usage for those persisted documents.trashedFilescounts only documents withdeletedAtset, meaning they are currently in trash.- Moving documents to trash increments
trashedFilesonly for documents that were not already trashed. - Permanent deletion decrements
totalFilesandtotalSize; it also decrementstrashedFilesfor any deleted document that was already in trash. - New
storage_statsrows start withtrashedFiles = 0.
API/Contract
GET /storage/statsreturnstotalFiles,totalSize,trashedFiles,agencyId,orgId, andstorageLimit.storageLimitis the active plan's storage quota in bytes (same unit astotalSize), resolved viaISubscriptionLimitsGateway.getResourceLimit(agencyId, PlanResourceKey.STORAGE). It isnullwhen the agency has no active subscription or the active plan does not declare aSTORAGEresource, so the stats endpoint stays readable even without a plan.- Storage quotas are persisted in bytes end-to-end:
plans.plan_resources.quantityis abigintcolumn, and theSTORAGEresource holds the quota directly in bytes. No unit conversion happens at read time — the gateway, the query handler, and the DTO all operate on the same unit asstorage_stats.total_size. See the Plans module for the backing migration (ConvertStorageQuantityToBytes). - The quota comes from the Plans/Subscriptions module and is not persisted in
storage_stats; the storage module reads it per-request through the shared subscription limits gateway. - Consumers that enforce the quota on writes still short-circuit with
STORAGE.NO_ACTIVE_STORAGE_LIMIT; only the read-only stats endpoint degrades gracefully by returningstorageLimit: null. - The shared response contract lives in
packages/schemas/src/storage/storage-stats.schema.tsasIStorageStatsResponse.
Data Model Impact
storage.storage_statsincludes a persistedtrashed_filesinteger column with a default value of0.- Trash state for documents still comes from
storage.documents.deleted_atandstorage.documents.deleted_by. - This change requires a storage migration to add the new
trashed_filescolumn before the feature can run in environments with an existing database.
Failure Modes
- Trash cleanup updates the database before deleting S3 objects. If S3 deletion fails after commit, the cron logs the error and keeps the database state as the source of truth.
- If a caller tries to move a document that is already in trash, the handler leaves the existing trash count unchanged.
Observability
- Storage stats repository writes continue to flow through the shared Postgres repository metrics service.
TrashCleanupCronlogs cleanup start/end, the number of documents selected for permanent deletion, and any S3 deletion failures.
Change Log
- 2026-04-05: Added
storageLimitto the stats API response. The value is resolved per-request from the active plan viaISubscriptionLimitsGatewayand isnullwhen no active subscription exists. Unblocks the Storage Dialog header showing used/total/percent + progress bar. - 2026-04-05: Widened
plans.plan_resources.quantityfromintegertobigintand migrated existingSTORAGErows from GB to bytes, so the entire stack (plan, stats gateway, storage handlers, upload capacity checks) operates in a single unit. Fixes a latent bug whereregister-documentandgenerate-bulk-upload-urlswere comparing byte-sized files against GB-sized quotas, effectively rejecting uploads as if the quota were bytes. - 2026-03-31: Removed
maxStorageBytesfrom the stats API contract until per-org quotas are implemented end-to-end (DB column from an earlier migration may still exist but is not mapped). - 2026-03-27: Added persisted
trashedFilesaccounting to storage stats, move-to-trash, permanent delete, and trash cleanup flows.
Source Paths
apps/api/src/modules/storage/domain/entities/storage-stats.entity.tsapps/api/src/modules/storage/domain/repositories/storage-stats.repository.interface.tsapps/api/src/modules/storage/application/commands/documents/move-to-trash.command.tsapps/api/src/modules/storage/application/commands/documents/delete-document.command.tsapps/api/src/modules/storage/application/queries/storage/get-storage-stats.query.tsapps/api/src/modules/storage/infrastructure/repositories/storage-stats.repository.impl.tsapps/api/src/modules/storage/infrastructure/services/trash-cleanup.cron.tsapps/api/src/modules/storage/presentation/controllers/storage-stats.controller.tsapps/api/src/shared/application/services/subscription-limits-gateway.service.interface.tspackages/schemas/src/storage/storage-stats.schema.ts