Σε δύο σειρές

Αναπτύσσω το Migraine Tracker, ένα mobile health app σε React Native 0.81 + Expo 54 με Express 5 + PostgreSQL + Drizzle backend, όπου το FHIR 4.0.1 export είναι πραγματικά επαληθεύσιμο byte-for-byte έναντι της προδιαγραφής. Ένας νευρολόγος που λαμβάνει το αρχείο μπορεί να εισάγει το ιστορικό ημικρανίας του ασθενούς απευθείας στο EHR του, αντί να το πληκτρολογήσει ξανά από screenshots.

Αυτό το άρθρο καλύπτει πώς μοιάζει το «πραγματικό FHIR» στον κώδικα, πώς να μοντελοποιήσεις ένα ημικρανιακό επεισόδιο ως Observation resource (δεν είναι μία απλή τιμή), πώς εντάσσονται πραγματικά τα SNOMED CT και HL7 terminology systems, και ποιες React Native + Expo αποφάσεις έκαναν τη συνέχεια του build λογική.

Τι παραδίδουν στην πράξη οι περισσότερες «FHIR-compliant» mobile health apps

Η κατηγορία είναι γεμάτη apps που διαφημίζουν FHIR και παραδίδουν κάτι σαν:

{
  "type": "migraine",
  "severity": 7,
  "triggers": ["stress", "caffeine"],
  "started": "2026-05-10T08:00:00Z",
  "medicationTaken": "Sumatriptan 50mg"
}

Αυτό δεν είναι FHIR. Είναι JSON με health-sounding keys. Ένας νευρολόγος που προσπαθεί να το εισάγει σε EHR δεν παίρνει τίποτα — το σύστημα δεν ξέρει τι σημαίνει "type": "migraine" σε κωδικοποιημένο vocabulary. Δεν υπάρχει resourceType, ούτε code.coding, ούτε subject reference, ούτε terminology system.

Πραγματικό FHIR 4.0.1 για την ίδια εγγραφή μοιάζει πολύ πιο πολύ έτσι:

{
  "resourceType": "Observation",
  "status": "final",
  "code": {
    "coding": [{
      "system": "http://snomed.info/sct",
      "code": "37796009",
      "display": "Migraine"
    }]
  },
  "subject": { "reference": "Patient/abc123" },
  "effectivePeriod": {
    "start": "2026-05-10T08:00:00Z",
    "end":   "2026-05-10T14:00:00Z"
  },
  "valueQuantity": {
    "value": 7,
    "unit": "1-10",
    "system": "http://unitsofmeasure.org",
    "code": "{score}"
  },
  "component": [
    {
      "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1101000175103", "display": "Stress trigger" }] },
      "valueBoolean": true
    },
    {
      "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1101000175104", "display": "Caffeine trigger" }] },
      "valueBoolean": true
    }
  ]
}

Αυτό ένα EHR μπορεί να εισάγει. Κάθε έννοια έχει κωδικοποιημένο σύστημα. Κάθε σχέση είναι explicit. Το subject είναι reference σε Patient resource που υπάρχει ξεχωριστά. Τα φάρμακα που ελήφθησαν κατά το επεισόδιο είναι ξεχωριστά MedicationStatement resources, συνδεδεμένα στον ίδιο ασθενή και χρονική περίοδο.

Μοντελοποίηση ημικρανιακού επεισοδίου ως Observation

Εδώ κολλάνε οι περισσότερες υλοποιήσεις. Το Observation resource του FHIR 4.0.1 είναι σχεδιασμένο κυρίως για μετρήσεις — μια τιμή καρδιακού ρυθμού, μια καταχώρηση αρτηριακής, μια τιμή γλυκόζης. Μια ημικρανία είναι episodic event με onset, duration, severity, triggers και associated medications. Δεν χωράει καθαρά.

Το mapping στο οποίο κατέληξα:

Migraine conceptFHIR slot
Το γεγονός ότι ήταν ημικρανίαcode.coding με SNOMED CT 37796009 («Migraine»)
Onset + endeffectivePeriod.start + effectivePeriod.end
Severity (0–10)valueQuantity.value με unit: "1-10" και custom-namespace code
Κάθε triggercomponent[i] με δικό του code.coding (SNOMED) και valueBoolean: true
Κάθε φάρμακο που ελήφθηΞεχωριστό MedicationStatement resource που αναφέρεται στο ίδιο subject + effectivePeriod που επικαλύπτει το επεισόδιο

Το να είναι τα triggers component entries αντί για ενιαίο comma-separated string μετράει: σημαίνει ότι ένα EHR μπορεί να τρέξει query τύπου «δείξε μου τα επεισόδια που πυροδοτήθηκαν από στρες» χωρίς να χρειαστεί να κάνει parse free text.

Χτίζοντας το FHIR layer (Express + Drizzle + SNOMED)

Το backend είναι Express 5 + PostgreSQL + Drizzle ORM + Better Auth. Το FHIR layer έχει τις δικές του τέσσερις routes με dedicated auth middleware, γιατί τα clinical exports ακολουθούν διαφορετική ροή auth από τα κανονικά API calls — ένας γιατρός που λαμβάνει export δεν είναι κατ’ ανάγκη ο χρήστης που δημιούργησε τα δεδομένα.

Οι routes:

  • GET /fhir/Patient/:id — το patient resource
  • GET /fhir/Observation?patient=:id — search bundle επεισοδίων
  • GET /fhir/MedicationStatement?patient=:id — search bundle φαρμάκων
  • GET /fhir/Bundle/:id — το πλήρες export bundle, έτοιμο για drop στη ροή import ενός EHR

Η μετατροπή από τη Drizzle σειρά στο FHIR resource είναι pure function. Αυτό είναι το κομμάτι όπου επαλήθευσα output έναντι της 4.0.1 spec byte προς byte, εξασφαλίζοντας ότι κάθε required field είναι παρόν και κάθε conditional field που πρέπει να εμφανίζεται όντως εμφανίζεται.

Άξιο γνώσης αν το κάνεις: το Drizzle είναι σωστή επιλογή πάνω από Prisma εδώ, γιατί τα migraine + trigger + medication entities είναι έντονα σχεσιακά και θες explicit SQL κατά την κατασκευή του bundle. Τα auto-generated includes του Prisma αρχίζουν να μαχαιρώνουν τη ροή όταν χτίζεις five-resource bundles σε ένα round trip.

Γιατί React Native + Expo συγκεκριμένα

Το mobile app έπρεπε:

  • Να δουλεύει offline (οι ημικρανίες ξεκινάνε συχνά εκεί που δεν υπάρχει Wi-Fi, και ο χρήστης θέλει να καταγράψει το onset τώρα, όχι αργότερα)
  • Να τρέχει σε iOS και Android με ένα codebase
  • Να τραβά medication history από το iOS Health app όπου οι χρήστες το έχουν ήδη (HealthKit entitlements για health-records)
  • Να μιλά με Android Health Connect στο Android για τον ίδιο σκοπό
  • Να συγχρονίζεται αμφίδρομα με το backend όταν επανέρχεται το δίκτυο

Το React Native 0.81 + Expo 54 ήταν συνειδητή επιλογή γιατί:

  1. Ένα codebase, δύο πλατφόρμες. Ένα Swift + Kotlin ζευγάρι θα σήμαινε δύο FHIR layers, δύο HealthKit integrations, δύο offline queues. Το να μοιράζεται το FHIR transformation σε iOS και Android μετράει εδώ — είναι το κομμάτι με τα πιο λεπτά bugs.
  2. Το Expo managed workflow δίνει OTA updates. Ένας γιατρός αναφέρει bug στο export format. Πατάω JS bundle update· ο πληθυσμός ασθενών παίρνει το fix την επόμενη φορά που ανοίγει το app. Καμία ροή App Store review για critical clinical-data fix.
  3. Τα HealthKit entitlements δουλεύουν μέσω Expo. Με το health-records entitlement (που η Apple ελέγχει αυστηρά), ο χρήστης μπορεί να εισάγει υπάρχον medication history από το iOS Health app αντί να το πληκτρολογήσει.
  4. Sentry σε frontend και backend. Το React Native SDK του Sentry είναι ώριμο· τα production crashes παίρνουν observability.

Το offline sync

Το mobile app χρησιμοποιεί expo-sqlite για local storage. Το schema αντικατοπτρίζει το Drizzle schema του server. Ένα /api/sync endpoint παρέχει αμφίδρομο συγχρονισμό με last-write-wins semantics στον server.

Γιατί last-write-wins; Γιατί αυτό είναι single-user personal health data. Υπάρχει ακριβώς ένας άνθρωπος που γράφει σε αυτή τη βάση. Δεν μπορεί να υπάρξει «merge conflict» μεταξύ δύο συσκευών με ουσιαστικό τρόπο — ο χρήστης είναι η μία από αυτές. Το last-write-wins είναι η σωστή σημασιολογία εδώ. Προβλέψιμο, χωρίς UI επίλυσης συγκρούσεων, χωρίς άγχος.

Επαλήθευση: πώς ξέρω ότι το FHIR είναι πραγματικό

Τα 16 backend test suites (Jest + supertest) καλύπτουν το FHIR layer. Τα δύο που μετράνε περισσότερο:

  • fhir-spec-compliance.test.ts — περνά κάθε Observation, Patient και MedicationStatement μέσα από έναν 4.0.1 schema validator. Οποιοδήποτε spec drift αποτυγχάνει στο CI.
  • fhir-import-replay.test.ts — παράγει export bundle για synthetic ασθενή, το στέλνει σε sandbox EHR, κάνει parse αυτό που επιστρέφει, ελέγχει ότι το εισαχθέν record ταιριάζει με το πηγαίο.

Το δεύτερο είναι το test που κλείνει το loop πάνω στο «είναι πραγματικά αυτό FHIR». Ένα EHR σύστημα είτε το εισάγει είτε όχι. Δεν υπάρχει marketing ανάμεσα.

Τι μένει μπροστά

Το Migraine Tracker είναι αυτή τη στιγμή pre-launch — η υποβολή σε App Store και Play Store εκκρεμεί για final asset polish και Sentry DSN για παραγωγή. Το HealthKit entitlement απαιτεί συγκεκριμένη ροή έγκρισης Apple, την οποία αυτή τη στιγμή τρέχω.

Αν χτίζεις mobile health app και χρειάζεσαι πραγματικό FHIR 4.0.1 αντί για το marketing του, επικοινώνησε.

Παρόμοια έργα