Kulcare Docs

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_storage already 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 :file means 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_type column explicitly names the associated model class
  • Avoids nullable foreign keys and separate encounter_id/appointment_id columns

Alternatives Considered:

  • Separate encounter_id and appointment_id columns: Creates nullable FKs and dual paths to maintain.
  • Single polymorphic eventable interface: 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/clinical domain 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::User is the authenticated actor in API requests
  • Supports audit trail via patient_history_logs with 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_photos data is not accessible from clinical domain
  • ActiveStorage requires active_storage_blobs and active_storage_attachments tables
  • 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

On this page