Skip to content

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

  • totalFiles counts all persisted documents for the organization until they are permanently deleted.
  • totalSize tracks the total byte usage for those persisted documents.
  • trashedFiles counts only documents with deletedAt set, meaning they are currently in trash.
  • Moving documents to trash increments trashedFiles only for documents that were not already trashed.
  • Permanent deletion decrements totalFiles and totalSize; it also decrements trashedFiles for any deleted document that was already in trash.
  • New storage_stats rows start with trashedFiles = 0.

API/Contract

  • GET /storage/stats returns totalFiles, totalSize, trashedFiles, agencyId, orgId, and storageLimit.
  • storageLimit is the active plan's storage quota in bytes (same unit as totalSize), resolved via ISubscriptionLimitsGateway.getResourceLimit(agencyId, PlanResourceKey.STORAGE). It is null when the agency has no active subscription or the active plan does not declare a STORAGE resource, so the stats endpoint stays readable even without a plan.
  • Storage quotas are persisted in bytes end-to-end: plans.plan_resources.quantity is a bigint column, and the STORAGE resource 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 as storage_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 returning storageLimit: null.
  • The shared response contract lives in packages/schemas/src/storage/storage-stats.schema.ts as IStorageStatsResponse.

Data Model Impact

  • storage.storage_stats includes a persisted trashed_files integer column with a default value of 0.
  • Trash state for documents still comes from storage.documents.deleted_at and storage.documents.deleted_by.
  • This change requires a storage migration to add the new trashed_files column 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.
  • TrashCleanupCron logs cleanup start/end, the number of documents selected for permanent deletion, and any S3 deletion failures.

Change Log

  • 2026-04-05: Added storageLimit to the stats API response. The value is resolved per-request from the active plan via ISubscriptionLimitsGateway and is null when no active subscription exists. Unblocks the Storage Dialog header showing used/total/percent + progress bar.
  • 2026-04-05: Widened plans.plan_resources.quantity from integer to bigint and migrated existing STORAGE rows 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 where register-document and generate-bulk-upload-urls were comparing byte-sized files against GB-sized quotas, effectively rejecting uploads as if the quota were bytes.
  • 2026-03-31: Removed maxStorageBytes from 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 trashedFiles accounting to storage stats, move-to-trash, permanent delete, and trash cleanup flows.

Source Paths

  • apps/api/src/modules/storage/domain/entities/storage-stats.entity.ts
  • apps/api/src/modules/storage/domain/repositories/storage-stats.repository.interface.ts
  • apps/api/src/modules/storage/application/commands/documents/move-to-trash.command.ts
  • apps/api/src/modules/storage/application/commands/documents/delete-document.command.ts
  • apps/api/src/modules/storage/application/queries/storage/get-storage-stats.query.ts
  • apps/api/src/modules/storage/infrastructure/repositories/storage-stats.repository.impl.ts
  • apps/api/src/modules/storage/infrastructure/services/trash-cleanup.cron.ts
  • apps/api/src/modules/storage/presentation/controllers/storage-stats.controller.ts
  • apps/api/src/shared/application/services/subscription-limits-gateway.service.interface.ts
  • packages/schemas/src/storage/storage-stats.schema.ts