Panduan Developer
Untuk seseorang yang akan membaca & mengubah kod ini. Padat, tepat, dan mengandaikan anda selesa dengan TypeScript, HTTP, dan SQL. Seni bina, lapisan, kontrak, auth, database, deploy, ujian β dengan graf modul interaktif.
π Kandungan
- Mukadimah & sumber kebenaran
- Seni bina sistem
- Repo & graf modul
- Stack, persekitaran & arahan
- Kitaran request & lapisan backend
- Auth & model JWT
- Database, migrasi & gotcha
- Frontend mendalam
- Build, deploy & CI
- Multi-hospital, provisioning & fleet
- Observability & ujian
- Konvensyen, corak terlarang & gotchas
- Resipi perubahan: mudah β sukar
- Modul ciri β peta fail & route
Mukadimah & sumber kebenaran
Dokumen ini ialah rujukan teknikal Sistem OMFS untuk developer. Ia ringkasan; bila ragu, kod dan CLAUDE.md menang.
Mula baca kod dari sini (untuk manusia)
Turutan disyorkan untuk developer baru yang mahu faham aliran sebenar dengan pantas. Ini fail kod β bukan fail peraturan AI.
| # | Fail | Kenapa |
|---|---|---|
| 1 | worker/index.ts | Pintu masuk: middleware, laluan awam, mount routes. |
| 2 | worker/routes/hospital.ts | Contoh route ringkas + lengkap (GET awam, PUT dipagari). |
| 3 | worker/services/HospitalConfigService.ts | Corak service (env, actorId) + audit. |
| 4 | worker/contracts/hospital.contracts.ts | Bagaimana Zod jadi sumber type. |
| 5 | shared/types.ts | Type teras (Patient, Visit, UserRole) FE+BE kongsi. |
Untuk ejen AI (Claude / Cursor / Aider)
Jika anda membrief ejen AI, fail-fail ini ialah taklimat projek yang dibaca mesin β apa yang ejen patut baca dahulu (CLAUDE.md sendiri ditulis untuk ejen, bukan manusia). Sebagai manusia, anda boleh langkau ini β jadual kod di atas sudah memadai.
CLAUDE.md + REFACTOR_PROGRESS.md
Peraturan projek + peta fail terkini, dan satu-satunya pelan hidup (keadaan kerja semasa).
graphify-out/GRAPH_REPORT.md
Peta struktur kod (~3950 nodes). God Nodes = abstraksi teras; Communities = fail berkaitan. Baca dulu sebelum grep penuh-repo.
.claude/rules/*.md
api.md (auth/SQL/roles), database.md (cascade), frontend.md (UI), services.md (bila extract).
Seni bina sistem
Satu Cloudflare Worker menyajikan kedua-dua aset SPA (HTML/JS/CSS terbina) dan API /api/*. Tiada server berasingan, tiada SSR. Database ialah D1 (SQLite) terikat (binding DB) pada Worker yang sama.
Setiap hospital = satu salinan Worker + satu D1 dalam akaun Cloudflare percuma mereka sendiri (lihat Β§09). Identiti per-instance hidup dalam jadual singleton hospital_config β tiada nama hospital ditanam keras.
Graf di bawah ialah seni bina lapisan secara interaktif. Tarik nod, hover untuk serlahkan kebergantungannya, scroll untuk zum.
π (Graf seni bina interaktif β buka dalam pelayar.)
Susun atur repo & graf modul
Struktur tegas: src/ (frontend) Β· worker/ (backend) Β· shared/ (type kongsi) Β· migrations/ (skema D1).
Graf modul di bawah memetakan hubungan sebenar dalam kod β routes β services β contracts, enjin on-call, dan jadual D1. ~44 nod terpilih (bukan keseluruhan ~3950 β elak hairball).
π (Graf modul interaktif β buka dalam pelayar.)
Stack, persekitaran & arahan
React 18 + Vite + TypeScript + Tailwind di depan; Hono di atas Cloudflare Worker; D1 (SQLite) sebagai database; Wrangler untuk deploy + migrasi.
Arahan harian
| Arahan | Guna |
|---|---|
| npm run dev | Dev tempatan (vite, port 3000). |
| npm run build | Jana ikon PWA β vite build (produksi). |
| npx tsc --noEmit | Type check (lihat gotcha Β§11 β gate ini hampir no-op). |
| npm test | Vitest (D1 Miniflare sebenar). |
| npm run lint:check | ESLint. |
| npm run seed:e2e | Apply migrasi lokal + seed seed-e2e.sql untuk Playwright. |
| npm run db:migrate:remote | Apply migrasi ke D1 produksi. |
| git push origin main | Deploy normal β GitHub Actions (CI β wrangler deploy). |
Kitaran request & lapisan backend
Setiap permintaan masuk melalui worker/index.ts. Auth diperiksa mengikut path, kemudian role, kemudian handler. Handler ikut SDD: parse Zod β service β envelope { success, data }.
// 1. Kontrak Zod dulu (SDD) β wujud sebelum handler, jadi type FE juga
app.put('/api/hospital/config', requireRole('superadmin'), async (c) => {
const body = UpdateHospitalConfigRequest.parse(await c.req.json()); // 400 jika salah bentuk
const jwt = c.get('jwtPayload')!; // diisi oleh middleware
const cfg = await new HospitalConfigService(c.env, jwt.sub).update(body);
return ok(c, cfg); // { success:true, data }
});
| Langkah | Di mana | Apa berlaku |
|---|---|---|
| 1 | index.ts | Padan PUBLIC_PATHS / PUBLIC_GET_PATHS β laluan awam langkau auth. |
| 2 | middleware/auth.ts | verifyJwt (jose) β isi jwtPayload; gagal β 401. |
| 3 | requireRole(...) | Role tak cukup β 403. Role disimpan dalam Bahasa Melayu. |
| 4 | routes/{domain}.ts | Zod .parse() β salah β 400 (ZodError). |
| 5 | services/*.ts | Semua sentuhan D1; constructor (env, actorId); audit via logAction. |
| 6 | ok(c, data) | Envelope seragam β 200. |
Budget saiz fail (dikuatkuasakan CI)
| Lapisan | Lokasi | Soft / Hard LOC |
|---|---|---|
| Route | worker/routes/*.ts | 400 / 600 |
| Service | worker/services/*.ts | 300 / 500 |
| Contract | worker/contracts/*.ts | 200 / 400 |
| Middleware | worker/middleware/*.ts | 100 / 200 |
| React page | src/pages/** | 250 / 400 |
| React leaf | src/components/** | 150 / 300 |
Hard-limit breach = CI gagal (node scripts/check-file-sizes.mjs). Nak sentuh fail yang sudah lepas hard limit β extract dulu (no-behaviour-change), commit, baru edit. Extract service bila: query sama di 3+ handler, mutasi perlu 2+ statement (withTx), atau peraturan sama dikuatkuasakan di >1 tempat.
Auth & model JWT
Login berbilang-pintu di worker/routes/auth.ts; token ditandatangani dengan jose; kata laluan di-hash dengan bcryptjs.
Pintu: POST /api/login (role biasa), /api/login/superadmin, dan /api/login/oncall (pintu berasingan; role oncall dipagari hanya ke /api/oncall/*). signJwt/verifyJwt di core-utils.ts; hashPassword/verifyPassword di auth.ts (BUKAN core-utils). JWT_SECRET unik-rawak setiap instance β kunci dikongsi = pemalsuan token silang-instance.
| Role (nilai DB) | Orang sebenar | Skop |
|---|---|---|
| pendaftar | Kaunter | Daftar pesakit, bayaran, senarai. |
| doktor | Doktor | Diagnosis & rawatan, rekod sendiri. |
| pentadbir | Staf klinik | Laporan, lihat semua pesakit (BUKAN urus on-call). |
| superadmin | Pemilik/developer | Tetapan hospital, urus pengguna, kawalan penuh. |
| oncall | Pengurus jadual | Pintu berasingan; jana/kunci jadual. Mgr juga = superadmin ATAU flag can_manage_oncall. |
Database, migrasi & gotcha
D1 (SQLite). Migrasi migrations/NNNN_nama.sql dijejak D1 ikut nama fail β maka mengedit migrasi yang sudah diapply adalah selamat (DB live tak re-run; hanya install baru dapat kandungan baru).
Jadual: patients Β· visits Β· visit_doctors Β· diagnoses Β· managements Β· users Β· audit_logs Β· settings Β· appointments Β· messages Β· system_kv Β· hospital_config (singleton id=1).
Pemulihan: D1 menyimpan PITR (time-travel) 7 hari β wrangler d1 time-travel info/restore. Export safety dulu (wrangler d1 export --remote), restore, re-apply migrasi selepas bookmark.
Frontend mendalam
React 18 + Vite + TypeScript + Tailwind (SPA, tiada SSR). UI = shadcn/ui di atas Radix (src/components/ui/).
Routing: react-router-dom v7 (src/main.tsx). State: zustand + React context (HospitalConfigProvider β useHospitalConfig(), dimuat dari GET /api/hospital/config yang awam β branding load sebelum auth). Borang: react-hook-form + zod. Halaman ikut role di src/pages/{admin,doctor,oncall,registrar,superadmin,shared}/.
Pecahkan komponen sebelum tambah ciri bila: >400 LOC (page) / >300 (leaf), >5 useState, >2 dialog, atau >3 domain. Helper yang di-extract pergi ke fail .ts (bukan sebelah komponen .tsx β peraturan react-refresh).
Build, deploy & CI
Deploy normal = push ke main β GitHub Actions. Push ialah deploy; jangan wrangler deploy manual sebagai aliran biasa (ia memintas gate CI).
.github/workflows/deploy.yml: job ci (lint β typecheck β build β test β file-size) β job deploy (wrangler deploy, 3 retry untuk API 10013). CI merah = tiada deploy. Sahkan hijau (gh run watch) sebelum lapor "live".
Multi-hospital, provisioning & fleet
Setiap hospital berjalan dalam akaun Cloudflare percuma mereka sendiri (kos sifar, skala linear β BUKAN SaaS pusat). Hanya orkestrasi yang berpusat.
| Tugas | Alat |
|---|---|
| Install pertama (instance baru) | scripts/provision-hospital.mjs |
| Templat config per-instance | wrangler.template.jsonc |
| Kemas kini SEMUA instance (satu arahan) | scripts/release-all.mjs |
| Backup D1 per-instance | scripts/backup-instance.mjs |
| Daftar instance (gitignored) | instances.registry.json |
| Token scoped per-akaun | .env.fleet |
| Semakan versi live | GET /api/version |
Provisioning β release. Provisioning = install pertama (cipta D1, migrate, set JWT_SECRET, putar kata laluan superadmin/admin yang di-seed dengan hash kongsi β bcrypt rawak baru). Release = kemas kini (tak sentuh secret; migrasi idempoten). Runbook: docs/PROVISIONING.md, docs/FLEET-OPERATIONS.md. Wizard pasang kali pertama di /setup dipagari flag settings.setup_completed.
Observability & ujian
Amaran ralat ke Discord (PII-scrubbed); ujian guna D1 sebenar, tiada mock.
Pemerhatian & ralat
worker/observability/alert.ts (sendAlert/dispatchAlert) hantar ke Discord β PII di-scrub + dedupe. Diwayar dalam worker/index.ts (app.onError, 4 cron catch, /api/client-errors). Opt-in melalui secret DISCORD_WEBHOOK_URL (no-op bila tak diset); env HOSPITAL_LABEL menanda instance.
Ujian
Vitest dengan @cloudflare/vitest-pool-workers β D1 Miniflare sebenar, TIADA mock D1 (sebab pernah berlaku: ujian mock lulus tapi migrasi prod gagal). E2E: Playwright (e2e/), seed via scripts/seed-e2e.sql (set setup_completed=1). Metodologi: SDD (Zod dulu) + TDD (service) + characterization (sebelum refactor legacy).
npx tsc --noEmit # type check
npm run build # esbuild (tangkap decl pendua yang tsc terlepas)
npm test # vitest (D1 sebenar)
npm run lint:check # eslint
Konvensyen, corak terlarang & gotchas
Perkara yang review akan tolak, dan gotcha yang sudah pernah memakan masa.
Gotchas (footguns yang disahkan)
| Gotcha | Nota |
|---|---|
| Cascade visits | Backup jadual anak sebelum DROP (Β§06a). |
| --remote hilang | execute tanpa flag kena DB lokal (Β§06b). |
| Kiraan binding INSERT | kolum/?/bind mesti selari (Β§06c). |
| Build per-instance | guna OMFS_WRANGLER_CONFIG, bukan -c (Β§08b). |
| typecheck β no-op | solution tsconfig periksa hampir tiada; e2e/runtime yang tangkap import hilang. |
| LEFT JOIN filter | filter jadual kanan dalam ON tak buang baris kiri β guna COUNT(CASE WHENβ¦). |
| Auth path-only | route awam-untuk-GET perlu PUBLIC_GET_PATHS, jika tidak PUT langkau auth lalu 401. |
| Auto-push senyap | semak git log origin/main..HEAD sebelum lapor "pushed". |
Resipi perubahan β dari mudah ke sukar
Boleh ke saya betul-betul coding selepas baca panduan ini? Membaca = faham. Membuat resipi di bawah = mula boleh. Setiap resipi senaraikan fail sebenar yang disentuh dan cara mengesahkannya.
π’ Sangat mudah β tukar teks / label pada skrin
Contoh: tukar perkataan pada butang, tajuk, atau mesej.
Fail: komponen .tsx berkaitan dalam src/pages/ atau src/components/. Cari teks sedia ada dengan carian editor (Ctrl+Shift+F).
Langkah: cari string β ubah β simpan. Uji: npm run dev, lihat skrin. Tiada migrasi, tiada API.
π’ Mudah β tukar warna / tema
Fail: token warna di src/index.css (pemboleh ubah --color-* / tema Tailwind v4). Untuk subpokok bertema, override --color-* pada pembungkus .theme-X (bukan triple --primary mentah).
Uji: npm run dev; semak kontras (lihat pelajaran kontras: teks gelap atas latar gelap = tak nampak).
π‘ Sederhana β tambah medan baru pada borang + simpan ke DB
Ikut aliran data β lima tempat selari:
- Migrasi: migrations/NNNN_nama.sql β ALTER TABLE ... ADD COLUMN (jalankan /migrate).
- Kontrak Zod: worker/contracts/*.contracts.ts β tambah medan pada schema.
- Service: worker/services/*Service.ts β masukkan dalam INSERT/UPDATE. Ingat kiraan binding (kolum = ? = .bind).
- Type kongsi: shared/types.ts β tambah medan pada interface.
- UI: komponen borang .tsx (react-hook-form + zod resolver).
Berikut diff sebenar untuk satu medan β katakan medan teks pilihan alergi pada pesakit. Perhatikan kelima-lima fail bergerak selari, dan kiraan kolum / ? / .bind kekal sama (langkah 3):
1 β migrations/0047_patients_alergi.sql (fail baharu β jalankan /migrate)
+ ALTER TABLE patients ADD COLUMN alergi TEXT;
2 β worker/contracts/patients.contracts.ts
export const CreatePatientRequest = z.object({
nama: z.string().min(1),
+ alergi: z.string().optional(),
});
3 β worker/services/PatientService.ts β kolum = ? = .bind mesti kira bertiga
- INSERT INTO patients (nama) VALUES (?)
+ INSERT INTO patients (nama, alergi) VALUES (?, ?)
...
- .bind(p.nama)
+ .bind(p.nama, p.alergi ?? null)
4 β shared/types.ts
export interface Patient {
nama: string;
+ alergi?: string;
}
5 β src/pages/registrar/PatientRegistrationPage.tsx (react-hook-form)
+ <FormField name="alergi" control={form.control} render={({ field }) => (
+ <Input placeholder="Alergi (jika ada)" {...field} />
+ )} />
Uji: npm run db:migrate:local β npm run dev β isi borang β semak DB (wrangler d1 execute DB --local --command "SELECT ...").
π‘ Sederhana β tambah route API baru
Corak SDD (lihat Β§04): kontrak dulu, kemudian handler, kemudian service.
- Kontrak: tambah schema Request/Response di worker/contracts/{domain}.contracts.ts.
- Handler: dalam worker/routes/{domain}.ts β app.post(path, requireRole('...'), β¦); parse Zod; panggil service; return ok(c, data). Jangan tulis route dalam user-routes.ts (beku).
- Service: logik + akses D1 di worker/services/{Domain}Service.ts; constructor (env, actorId).
- Jika route awam-untuk-GET β daftar dalam PUBLIC_GET_PATHS (worker/index.ts).
Uji: tulis ujian di worker/__tests__/ (D1 sebenar) + curl ke npm run dev.
π Sukar β tambah jadual D1 baru (hujung ke hujung)
Gabungan semua di atas: migrations/NNNN (CREATE TABLE, indeks, FK) β {Domain}Service.ts baru β {domain}.contracts.ts baru β routes/{domain}.ts baru (daftar mount dalam worker/index.ts) β type dalam shared/types.ts β halaman/komponen UI.
Bila extract jadi service: query sama di 3+ tempat, mutasi 2+ statement (withTx), atau peraturan dikuatkuasakan di >1 tempat. Uji: ujian service (TDD) + e2e satu laluan.
π΄ Sangat sukar β ubah algoritma keadilan on-call
Sentuh enjin: worker/oncall/fairness.ts, swaps.ts, dan types.ts (HSR_CONFIG + pemberat). Logik berkait rapat dengan slot, kekosongan, cuti, kredit, dan self-picks (yang dipelihara merentas generate()).
Wajib: baca fail-fail itu sepenuhnya + ujian sedia ada di worker/__tests__/oncall/ dahulu. Tambah ujian yang mengunci tingkah laku sebelum mengubah (characterization). Satu pemberat salah boleh menjadikan jadual tak adil tanpa ralat.
Modul ciri β peta fail & route
Rujukan padat per ciri: di mana kod hidup, route/entry-pointnya, dan gotcha utamanya. Untuk versi naratif bahasa-mudah, lihat Panduan Kod 03. Laluan disahkan 2026-06-18.
| Ciri | Fail teras | Route / entry |
|---|---|---|
| Laporan PG201/PG211 | src/lib/xlsx-utils.ts (barrel) β xlsx-pg201.ts Β· xlsx-pg211.ts Β· xlsx-patient-report.ts Β· xlsx-pg101b.ts Β· xlsx-lampiran-c.ts Β· xlsx-styles.ts Β· xlsx-template-helpers.ts; public/templates/PG201.xlsx Β· PG211.xlsx | ReportingPage.tsx β generate*Excel*() |
| Eksport & muat turun | src/pages/admin/ReportingPage.tsx Β· src/pages/shared/MuatTurunPage.tsx Β· MuatTurunRegistrar.tsx | browser-side; reportFacilityLine β hospital_config |
| Hantar e-mel | worker/email.ts β sendReportEmail() | worker/routes/registrar/email.ts; secrets RESEND_API_KEY / RESEND_FROM_EMAIL / RESEND_PG101B_TO |
| Error handling | src/lib/swallow.ts Β· src/lib/errorReporter.ts Β· worker/db/rows.ts | POST /api/client-errors (queue, dedup, max 10) |
| Pembantu AI | src/components/panduan/AiChatAssistant.tsx Β· ai-chat/useAiChat.ts Β· session.ts Β· prompt.ts Β· image.ts | sesi di localStorage; MAX_SESSIONS=20, HISTORY_TTL |
| Keadilan on-call | worker/oncall/fairness.ts (pure) Β· swaps.ts Β· types.ts (HSR_CONFIG) | worker/routes/oncall.ts β generate(); lihat Β§12 resipi π΄ |
Nota tajam per ciri
PG201/PG211 (M7): isDoNotFill() menguatkuasakan 4 peraturan MOH (R1 ibu≤6, R2 referral-ULANGAN, R3 bersekolah 7β19, R4 pesara≥30). Lajur D mesti ditulis literal 0 (bukan '') atau pengesah AJ/AK jadi FALSE. Gabungan multi-helaian guna createTemplateSheets() model-clone β salinan sel-demi-sel lama merosakkan styles.xml. Entry-side hard-block: src/lib/age-utils.ts + StatusSection.tsx/schema.ts.
E-mel: Resend via fetch dengan AbortSignal.timeout(10s). Tiada RESEND_API_KEY β pulang { ok:false } dengan mesej BM "sila muat turun" (graceful fallback, bukan throw). 403 Resend = domain ujian resend.dev hanya hantar ke alamat sendiri β set RESEND_FROM_EMAIL ke domain disahkan. Badan respons TIDAK dilog penuh (boleh ada PII).
Error handling (corak terlarang Β§11): tiada .catch(() => {}) senyap β guna swallow('sebab') (no-op eksplisit + console.warn dalam DEV) ATAU errorReporter.report() ATAU toast. Selepas query D1, guna rows<T>(result) bukan as any[] (larangan any).
Fairness: enjin tulen (tiada DB/req β unit-testable). Greedy kronologi; skor = beban tertimbang + penalti silang-bulan + tie-break mulberry32 berbenih (deterministik). Tapisan keras R16: tak-tersedia / kerja hari sebelum / maxDutiesPerMonth / leaveCreditMinDays. Slot tanpa calon sah β kekal kosong + direkod konflik (tak paksa pilihan tak selamat). Self-picks dipelihara merentas generate() via worker/oncall/self-picks.ts. Ujian: worker/__tests__/oncall/.
π Panduan Kod 01 β Asas
Set pembelajaran bukan-coder: apa itu kod, 4 bahasa, peta bina-dari-kosong.
π¬ Panduan Kod 02 β Contoh Fail
10 bentuk fail beranotasi baris demi baris (route/service/kontrak/komponen/hook).
π Panduan Kod 03 β Ciri/Modul
Versi naratif Β§13 ini untuk bukan-coder: 6 ciri di sebalik tabir.