Klinik Araştırma için Jenerik Anket Motoru
Bir sağlık grubu, farklı hastalık alanlarında klinik araştırma verisi topluyordu - hipertansiyon, kardiyoloji, endokrinoloji. Her yeni protokol için ayrı form geliştirmek, ayrı tablolar kurmak ve ayrı raporlar yazmak zorunda kalıyorlardı. Ben tek bir motor yazdım; araştırmayı artık kod değil, veri tanımlıyor.
Problem
Klinik araştırma platformları geleneksel olarak iki uç arasında sıkışır:
Sert yaklaşım - Her protokol için ayrı tablolar, ayrı formlar, ayrı şemalar. Güvenli ama hantal. Yeni bir araştırma başlatmak haftalar alır; IT bileti açılır, alan eklenir, form tasarlanır, yayınlanır.
Esnek yaklaşım - Jenerik "form builder" araçları. Hızlı ama genelde klinik veri disiplinine uymaz: tip güvenliği yok, validasyon zayıf, raporlama acı verici.
Müşterinin beklentisi ikisinin de iyi yanıydı: yeni bir araştırma tanımlamak bir öğleden sonra sürmeli, ama toplanan veri ilk günkü kadar sıkı tutulmalı.
Platform olarak Microsoft Power Platform seçilmişti. Bu, web resource sınırı getiriyor: tüm arayüz tek bir HTML dosyasına sığmak zorunda. Modül sistemi, build pipeline veya npm kullanamıyorsun; sadece vanilla JavaScript ile çalışıyorsun.
Temel Karar: Soruları Şema Değil, Veri Olarak Modelle
Projenin kırılma noktası buradaydı. Klasik yaklaşımda "hastanın tansiyonu" bir sütun olur. Ben bunu bir satır yaptım.
erDiagram
RESEARCH ||--o{ SECTION : contains
SECTION ||--o{ QUESTION : contains
QUESTION ||--o{ QUESTION_OPTION : offers
QUESTION ||--o{ QUESTION : "related to"
SESSION ||--o{ ANSWER : records
ANSWER }o--|| QUESTION : answers
ANSWER }o--o| QUESTION_OPTION : "selected value"
RESEARCH {
string name
string status
}
QUESTION {
string label
int type
bool required
guid relatedQuestionId
}
ANSWER {
string textValue
decimal numericValue
guid optionRef
}
Bu karar sayesinde yeni bir araştırma eklemek artık kod değişikliği gerektirmiyor. Koordinatör admin panelinden bölümleri, soruları ve seçenekleri tanımlıyor, motor bunları okuyup ekranda render ediyor.
Hipertansiyon protokolünden sonra ikinci araştırma tanımı bir öğleden sonrada hazırdı. Geliştirici dahil olmadan.
Mimari
Üç katman - her biri kendi sorumluluğunda, birbirine gevşek bağlı.
1. Veri Katmanı (Dataverse)
Altı custom tablo: Research, Section, Question, QuestionOption, Session, Answer. Soru tipleri (tekli seçim, çoklu seçim, numeric, tarih, serbest metin, Var/Yok, Var/Yok/Bilinmiyor) enum olarak tanımlı. Her cevap tipi için ayrı alan yok - textValue, numericValue, optionRef kolonları duruma göre doluyor.
2. Motor (Web Resource)
Tek HTML dosyası. İçinde:
- Renderer - Soru tipine göre uygun UI bileşenini üretir
- State manager - Açık oturumun cevaplarını bellekte tutar, validate eder
- Persistence layer - Dataverse Web API'sine OData üzerinden yazar
- Export - SheetJS ile Excel çıktısı (desktop-only; mobil WebView indirmeyi desteklemiyor)
3. Uygulama Kabuğu (Model-Driven Apps)
İki ayrı MDA: biri admin için tam yetkili, diğeri doktor için kısıtlı. Motor her ikisinin içinde de iframe olarak açılıyor. Rol bazlı görünüm ayrımı Dataverse güvenlik rolleriyle yapılıyor - motor, kimin ne gördüğünü kullanıcının rolünden çıkarıyor.
Teknik Derinlik Blokları
Koşullu Alanlar ve Kendine Referans
Bir ilacı "kullanıyor" işaretleyen doktorun, dozaj zamanlamasını da girmesi bekleniyor. Ama kullanmıyorsa bu alan görünmemeli - hatta validasyona girmemeli.
Bunun için soru tablosuna kendi kendine referans veren bir lookup alanı ekledim. Dozaj sorusu, ilacın kendisine bağlı; motor da bu ilişkiyi takip ederek render ediyor.
// Dataverse'de lookup alanının $select'te geçerli adı ile
// $expand'deki navigation property adı farklı - bu tuzağı
// bulmak yarım gün aldı.
const query = `?$select=name,type,required,_relatedQuestionId_value` +
`&$expand=options($select=label,value)`;
// _relatedQuestionId_value ← select'te bu
// relatedQuestion ← expand'de bu (başka isim)
OData field naming Dataverse'de tutarlı değil. Lookup alanını $select içinde _fieldname_value formatında, $expand içinde ise navigation property adıyla çağırmak gerekiyor. Yanlış kullanımda hata da dönmüyor - boş array dönüyor, yani problem günlerce fark edilmeyebiliyor.
Var / Yok / Bilinmiyor - Üç Durumlu Boolean
Klinik verinin gerçeği: "Bilinmiyor" bir cevaptır. null değildir. Hasta soruldu, hatırlamıyor - bu bilgidir.
Yeni bir soru tipi tanımladım: VARYOKBIL. Üç buton, üç farklı değer (1, 0, 9). "Bilinmiyor" için ayrı bir CSS sınıfı (selected-unknown) çünkü renk semantiği önemli: var/yok kararlı cevaptır (lacivert), bilinmiyor eksik bilgidir (gri).
.answer-button.selected { background: var(--brand-primary); color: white; }
.answer-button.selected-unknown { background: var(--neutral-500); color: white; }
Falsy Değer Tuzağı
Export sırasında numeric alanlar boş gözüküyordu. Sebep klasik JavaScript hatası:
// Yanlış: 0 sayısal değeri "boş" sayılıyor
const exportValue = answer.numericValue || "-";
// Doğru: sadece null/undefined boş sayılmalı
const exportValue = answer.numericValue ?? "-";
Klinik veride 0 anlamlı bir değerdir (örn: sigara içmiyor → 0 paket/gün). || operatörünü bu bağlamda kullanmak sessiz bir veri kaybı.
isSaving Guard'ı ve Başarısız Validation
"Tamamla" butonu, arka arkaya basmayı engellemek için bir isSaving flag'i kullanıyordu. İlk versiyonda flag, validasyondan önce set ediliyordu. Sonuç: validasyon fail olunca buton kilitli kalıyor, kullanıcı "donan ekran" rapor ediyordu.
// Hatalı akış
isSaving = true;
if (!validate()) return; // burada çıkınca flag temizlenmiyor
await save();
// Doğru akış
if (!validate()) return;
try {
isSaving = true;
await save();
} finally {
isSaving = false;
}
Tek yapman gereken iki satırın yerini değiştirmek; ama bu değişiklik olmadan kullanıcı her validasyon hatasında ekranın donduğunu sanıyor.
Güvenlik Modeli
Üç rol: Admin, Koordinatör, Doktor. Her biri farklı görünürlük, farklı yetkiler.
| Kaynak | Admin | Koordinatör | Doktor | |---------------------|---------|-------------|--------------| | Araştırma tanımı | Yazma | Yazma | Sadece kendi | | Soru / Seçenek | Yazma | Yazma | Okuma | | Oturum (Session) | Tümü | Tümü | Sadece kendi | | Cevap (Answer) | Tümü | Tümü | Sadece kendi | | Rapor / Export | Var | Var | Yok |
Dataverse güvenlik rolleri bu matrisi büyük oranda karşılıyor ama iki incelik vardı:
Session tablosunda doktora organization-level read vermek zorunda kaldım - çünkü duplicate kontrolü yapabilmesi için başka doktorların oturumlarının var olduğunu bilmesi, ama içeriğini görmemesi gerekiyor. Görünürlük kısıtı kodda ownerFilter ile yapıldı, şemada değil.
Append To yetkisi de ince bir noktaydı: @odata.bind ile lookup yazabilmek için hedef tabloda Append To = Organization gerekiyor. Bu, tablonun kendisini yazılabilir yapmıyor - sadece "bu tabloya referans verilebilir" diyor. Dokümantasyondan çıkarması kolay değildi.
Mobil Uyumluluk
Doktorlar tablet ve telefondan giriş yapıyor. Motor responsive ama bir kısıt var: ilaç ve dozaj soruları masaüstünde tablo görünümünde en iyi çalışıyor, mobilde tablo sıkışıyor.
Çözümü CSS ile hallettim: breakpoint altında tablo satırları display: block ile karta dönüşüyor, her hücrenin başlığı ise ::before ile üstüne ekleniyor.
@media (max-width: 600px) {
.drug-table, .drug-table tbody, .drug-table tr, .drug-table td {
display: block;
}
.drug-table td::before {
content: attr(data-label);
font-weight: 600;
display: block;
}
}
Export fonksiyonu mobilde gizleniyor - Power Apps mobile WebView dosya indirmeyi desteklemiyor. Butonu olduğu gibi bırakıp kullanıcının tıkladıktan sonra hiçbir şey olmamasını izlemesindense, butonu saklayıp yerine bir toast gösteriyoruz: "Dışa aktarma masaüstünde yapılabilir."
Lokalizasyon
Tüm kullanıcı yüzü Türkçe. Ama asıl incelik şurada: Dataverse güvenlik rollerinin isimleri de lokalize. "System Administrator" bizim kurulumda "Sistem Yöneticisi". Motor, canViewAllSessions() kontrolünde her iki ismi de tanıyor.
const ADMIN_ROLE_NAMES = ["System Administrator", "Sistem Yöneticisi"];
Dışarıdan ufak bir detay gibi görünüyor ama müşterinin ortamı değiştiğinde ya da ileride İngilizce bir test ortamı açıldığında kodun sessizce kırılmasını engelliyor.
Ölçütler
Neler Öğrendim
1. Low-code, no-code değildir. Power Platform iş kurallarını hızlandırıyor, bu doğru. Ama state yönetimi, hata senaryoları, idempotency gibi klasik konular hâlâ seni bekliyor. Sürükle-bırak ile gelinen nokta bir başlangıç; ürünün geri kalanı ondan sonra başlıyor.
2. Şema ile veri arasındaki çizgi bir tasarım kararıdır. "Bu bir kolon mu olsun, yoksa satır mı?" sorusu göründüğünden çok daha belirleyici. Kolon yaparsan şemaya sabitlenirsin; satır yaparsan esneklik kazanıp performans ve validasyon tarafında bedel ödersin. Bu projede satır doğru tercihti, ama önemli olan bunu bilerek seçmiş olmaktı.
3. Kurumsal dokümantasyon boşluklarını bulmak işin bir parçası. OData lookup naming, güvenlik rolü "Append To" semantiği, mobil WebView download kısıtı - bunların hiçbiri resmi dokümantasyonda net değildi. Çözüme ulaşmadan önce doğru soruyu formüle etmek bir hayli zaman alıyor.
4. Hekim kullanıcı en zor kullanıcıdır. Hızlı çalışıyorlar, sabırları az, ufak sürtünmeleri tolere etmiyorlar. Tek bir kilitlenen buton bile (isSaving bug'ı gibi) ürüne olan güveni sarsmaya yetiyor. Arayüzün görünmeyen davranışı, en az görünen kısmı kadar dikkat istiyor.
Sonraki Adımlar
- Raporlama katmanı - SSRS tabanlı protokol bazlı raporlar
- Yerel geliştirme döngüsü - Fiddler AutoResponder ile publish/import cycle'ını bypass ederek iteration hızını artırmak
- Güvenlik modelinin formal review'u - Ad-hoc gelişen kuralları tek bir matrise konsolide etmek