ADR-001: Patient Photos ActiveStorage Architecture
Status: Accepted
Date: 2026-03-27
Deciders: Kulcare Engineering Team
Context
Patient photos were migrated from the legacy parts/clinical domain (using CarrierWave + S3) to the new backend/domains/clinical modular architecture. This ADR documents the key architectural decisions made during the migration.
Decision 1: ActiveStorage over CarrierWave
Decision: Use Rails ActiveStorage for file attachments instead of CarrierWave.
Rationale:
- Rails-native solution with first-class ActiveRecord integration
- Built-in variant processing (resize, format conversion) without ImageMagick wrappers
- Cleaner S3 integration with direct upload support
- No additional gem dependency — uses
active_storagealready in Rails 8.1 - Simpler configuration with
config/storage.yml
Alternatives Considered:
- CarrierWave: Already in use in legacy parts/clinical. Requires maintaining custom uploaders and separate S3 configuration.
- Shrine: More flexible but adds another dependency with different semantics.
Decision 2: Separate patient_photos / patient_photo_records Tables
Decision: Two-table design where clinical.patient_photos is the parent (one per session/encounter) and clinical.patient_photo_records stores individual file records (one per uploaded file).
Rationale:
- Supports multiple photos per encounter session (up to 10 files via file0-file9 params)
- ActiveStorage
has_one_attached :filemeans one attachment per record - Clean separation: photo metadata (encounter, patient, practitioner) on parent; file-specific metadata (notes, filename, content type) on child
- Enables soft-delete cascade at the record level independently
Alternatives Considered:
- Single table with JSON array: Would require JSONB for multiple attachments, harder to query and soft-delete individually.
- Single table with STI: Would add complexity without benefit.
Decision 3: Event Polymorphism via event_id / event_type
Decision: Use polymorphic association (event_id UUID + event_type string) to link photos to either Clinical::Encounter (new standard) or legacy Appointment records.
Rationale:
- Backward compatibility: existing appointments continue to work without migration
- Forward-looking: new clinical encounters use UUID-based IDs
event_typecolumn explicitly names the associated model class- Avoids nullable foreign keys and separate encounter_id/appointment_id columns
Alternatives Considered:
- Separate
encounter_idandappointment_idcolumns: Creates nullable FKs and dual paths to maintain. - Single polymorphic
eventableinterface: More Rails-idiomatic but requires STI setup.
Decision 4: No Sync Between Clinical and Parts Tables
Decision: The new clinical.patient_photos and legacy parts/clinical/patient_photos tables are independent. No sync or migration between them.
Rationale:
- The legacy
parts/clinicaldomain is in maintenance mode - Different schemas (CarrierWave vs ActiveStorage attachments)
- Parallel operation with independent data ensures no risk to existing functionality
- Frontend can migrate to new clinical API endpoint independently
Decision 5: deleted_by_user_id References Identity::User
Decision: Soft delete records include deleted_by_user_id pointing to Identity::User, not Clinical::Practitioner.
Rationale:
- Consistent with clinical domain pattern (other clinical models use
Identity::User) Identity::Useris the authenticated actor in API requests- Supports audit trail via
patient_history_logswith proper user attribution soft_delete!(deleted_by: user)method pattern matches existing clinical models
Consequences
Positive:
- Rails-native ActiveStorage simplifies file handling and variant generation
- Two-table design scales naturally (individual record soft-delete, per-file metadata)
- Polymorphic event supports both legacy and new encounter patterns
- Independent tables eliminate migration complexity
Negative:
- Legacy
parts/clinical/patient_photosdata is not accessible from clinical domain - ActiveStorage requires
active_storage_blobsandactive_storage_attachmentstables - No file validation at the attachment level — content type validation lives on the model
Neutral:
- Frontend must migrate from parts API to clinical API endpoints
- Feature index docs updated to reference clinical domain (not parts)
References
- Migration Plan: docs/plans/2026-03-26-002-feat-migrate-patient-photos-clinical-domain-plan.md
- Gap Closure Plan: docs/plans/2026-03-27-001-fix-patient-photos-gap-closure-plan.md
- Brainstorm: docs/brainstorms/2026-03-24-patient-photos-clinical-domain-migration-brainstorm.md
- API Docs: docs/api-reference/patient-photos/patient-photos-api.md