ADR-0018: Shared Attachments Table — Staged Upload, Polymorphic Link, and Soft Delete
Status
AcceptedTags
fieldforce, s3, seaweedfs, upload, attachments, minio-go, shared-infrastructureDecision
File attachments across all modules are stored in a single sharedattachments table. The client generates a UUID for each file before upload. The upload endpoint (POST /api/organizations/:org_id/attachments) accepts multiple files in one multipart request, streams each to SeaweedFS via minio-go, and returns records immediately with status = 'pending' — no entity link is required at upload time.
When a form is submitted (e.g., Log Activity), the request includes attachment_ids. Go atomically links them to the created entity via a single UPDATE ... WHERE status = 'pending' AND deleted_at IS NULL RETURNING id. The returned IDs are validated against the submitted IDs — a mismatch means one or more IDs were invalid or already used.
Orphaned pending attachments are soft-deleted (via deleted_at) by a cleanup job after 24 hours. S3 objects are deleted before the row is soft-deleted. No hard deletes from the attachments table.
Why
A module-scoped attachment table (e.g.,ff_attachments) requires duplication when other modules need the same capability. A shared table lets future modules (leave, support tickets, etc.) attach files without new migrations.
Staged upload (pending → linked) decouples file selection from form submission — users can upload a photo before filling in the form, and the UX is non-blocking. Activity or task records are never created as stubs just to obtain an ID for the upload.
Client-generated UUIDs serve as idempotency keys — if the client retries an upload (network drop, browser refresh), INSERT ... ON CONFLICT (id) DO NOTHING returns the existing row without re-uploading to S3.
The polymorphic link (entity_type + entity_id) on the attachments side avoids junction tables per module and keeps attachment queries to a single indexed scan. No DB FK constraint exists on this link, but the atomic UPDATE ... RETURNING pattern enforces at application layer that only pending attachments from the correct org can be linked — the pre-check and the write happen in the same statement with no race window.
Soft delete preserves an audit trail of all uploads. Hard deletes complicate S3 reconciliation and remove forensic history.
Rejected alternatives:
- Module-scoped table (
ff_attachments): Requires a new table per module. No shared query pattern. Replaced by sharedattachments. - Junction table per module: Adds joins; FK integrity is a benefit, but the atomic
UPDATE ... RETURNINGpattern achieves equivalent safety at application layer without the join cost. - Client uploads to S3 directly (presigned URL or frontend S3 credentials): Requires S3 credentials in the frontend environment. Cycle Planner’s
signS3Requestis not in a shared package. Rejected — Go owns all S3 I/O. - Hard delete for cleanup: Removes audit trail, complicates S3 reconciliation. Rejected.
How it works
Schema:fieldforce/{org_id}/{uuid}.{ext} — no activity scope in the key since the activity doesn’t exist at upload time.
Known limitations
- No DB FK on
entity_id: Application layer is the only enforcement. Mitigated by the atomicUPDATE ... RETURNINGpattern — a bug that bypasses the WHERE clause could link to an invalid entity. - Soft-deleted row accumulation: Rows with
deleted_at IS NOT NULLstay in the table indefinitely. Add a hard purge of rows wheredeleted_at < now() - interval '90 days'in a future maintenance job. - S3 delete failure during cleanup: If
RemoveObjectfails, the DB row is still soft-deleted and the S3 object lingers. Log the error; reconcile via periodic S3 inventory comparison if needed. - Cleanup job scope: The cleanup pass currently runs inside the fieldforce overdue job. When other modules adopt
attachments, move it to a shared infrastructure job. - Online-only uploads: Phase 2 uploads require connectivity. Offline queuing is Phase 4 (IndexedDB via
FieldforceStorageAdapter).
Rules for agents
- NEVER hard delete from
attachments— always setdeleted_at = now() - Upload MUST use the client-generated UUID as the primary key — never generate the ID server-side
- Upload MUST use
INSERT ... ON CONFLICT (id) DO NOTHING— never upsert or update existing rows on conflict - Linking MUST use a single atomic
UPDATE ... WHERE status = 'pending' AND deleted_at IS NULL RETURNING id— never a separate SELECT then UPDATE s3_keyMUST NOT appear in any API response — it is an internal column for cleanup only- Upload endpoint is at org level (
/api/organizations/:org_id/attachments) — never under a module-specific path - Cleanup MUST attempt S3 deletion before setting
deleted_at; if S3 fails, still soft-delete the DB row and log the error - S3 key format for fieldforce is
fieldforce/{org_id}/{uuid}.{ext}— no activity_id in the key