Σε δύο σειρές
Αναπτύσσω το 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 concept | FHIR slot |
|---|---|
| Το γεγονός ότι ήταν ημικρανία | code.coding με SNOMED CT 37796009 («Migraine») |
| Onset + end | effectivePeriod.start + effectivePeriod.end |
| Severity (0–10) | valueQuantity.value με unit: "1-10" και custom-namespace code |
| Κάθε trigger | component[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 resourceGET /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 ήταν συνειδητή επιλογή γιατί:
- Ένα codebase, δύο πλατφόρμες. Ένα Swift + Kotlin ζευγάρι θα σήμαινε δύο FHIR layers, δύο HealthKit integrations, δύο offline queues. Το να μοιράζεται το FHIR transformation σε iOS και Android μετράει εδώ — είναι το κομμάτι με τα πιο λεπτά bugs.
- Το Expo managed workflow δίνει OTA updates. Ένας γιατρός αναφέρει bug στο export format. Πατάω JS bundle update· ο πληθυσμός ασθενών παίρνει το fix την επόμενη φορά που ανοίγει το app. Καμία ροή App Store review για critical clinical-data fix.
- Τα HealthKit entitlements δουλεύουν μέσω Expo. Με το
health-recordsentitlement (που η Apple ελέγχει αυστηρά), ο χρήστης μπορεί να εισάγει υπάρχον medication history από το iOS Health app αντί να το πληκτρολογήσει. - 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 του, επικοινώνησε.
Παρόμοια έργα
- Migraine Tracker — πλήρες case study για το build παραπάνω
- Γηροκομείο Ζωσιμάδων Healthcare Ops Stack — institutional healthcare ops platform, επίσης strict TypeScript + Postgres, αλλά inpatient ops αντί για personal health
- Π.Γ.Ν. Ιωαννίνων StorageManagement — Tauri + Rust desktop app για παρακολούθηση αντιδραστηρίων εργαστηρίου νοσοκομείου