← projeler

Generic Survey Engine for Clinical Researches

Bir sağlık grubu için klinik araştırma veri toplama platformu

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

6Custom tablo1HTML dosyası (tüm motor)7Desteklenen soru tipiTanımlanabilir araştırma sayısı

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