update All feture and service

This commit is contained in:
meninjar
2026-06-03 02:54:26 +00:00
parent af32d9cfdd
commit ce0fa1a291
98 changed files with 272469 additions and 1606 deletions
+209
View File
@@ -0,0 +1,209 @@
# Development Log — `service-satusehat`
> Append-only journal of meaningful changes, decisions, and incidents.
> Newest entries on top. Each entry: **date — author — scope — summary**,
> followed by **what / why / how / impact** as needed.
>
> Conventions:
> - `feat` new feature · `fix` bug fix · `refactor` no behaviour change ·
> `chore` housekeeping · `infra` ops / build · `docs` documentation ·
> `incident` production issue · `decision` ADR-lite.
---
## 2026-05-13 — meninjar — `refactor` · scope: `episodeofcare`
**Summary.** Refactored `EpisodeOfCare` usecase to remove environment-variable
reads from the mapper and to inject the organisation ID via the service
constructor.
**What changed (uncommitted as of this entry):**
- `internal/satusehat/usecase/episodeofcare/mapper.go`
- Removed `import "os"` and the `os.Getenv("SATUSEHAT_ORG_ID")` lookup.
- `MapRequestToFHIR` now takes `OrganizationID` purely from `req`.
- `internal/satusehat/usecase/episodeofcare/repository.go`
- Removed the `hiddenCtx struct{ context.Context }` wrapper.
- `executeRequest` now passes `ctx` to `client.DoRequest` directly.
- `internal/satusehat/usecase/episodeofcare/service.go`
- Constructor `NewService(repo, orgID string)` (was `NewService(repo)`).
- Both `Create` and `Update` inject `s.orgID` into `req.OrganizationID`
when the caller leaves it blank.
- `cmd/api/main.go`
- Updated wiring to pass `cfg.SatuSehat.OrgID` into `episodeofcare.NewService`.
**Why.**
1. Pure mappers (no env reads, no I/O) are trivial to unit-test.
2. Removes hidden global coupling — one binary can now serve multiple
facility identities by constructing multiple services with different
`orgID`s.
3. `hiddenCtx` was an undocumented wrapper that obscured the context chain
and broke `context.Value` propagation.
**Impact.**
- No public API change for HTTP callers.
- Other 18 FHIR usecases still read env in their mappers — they are flagged
in `DEVPLAN.md` task **T-04** for the same treatment.
**Follow-ups.**
- Add unit tests for `MapRequestToFHIR` (golden-file style).
- Replicate the pattern in remaining usecases.
---
## 2026-05-13 — meninjar — `docs` · scope: project
**Summary.** Authored four governance documents under `docs/`:
- `docs/PRD.md` — Product Requirements Document.
- `docs/analysis.qmd` — Quarto technical analysis (architecture, modules, gaps).
- `docs/DEVLOG.md` — this file.
- `docs/DEVPLAN.md` — forward roadmap with milestones M1M5.
**Why.** No high-level product or planning docs existed in the repo. The
`README.md` and `DOCUMENT.md` cover how to run the service but not
**what / why / next**.
**Impact.** Onboarding new engineers, communicating with stakeholders, and
prioritising the next two quarters of work all become tractable.
---
## 2026-05-06 — meninjar — `infra` · scope: Docker / Compose
**Summary.** Bumped builder image and reshaped the production Compose file.
**What changed:**
- `Dockerfile`
- `golang:1.22-alpine``golang:1.25-alpine`.
- Added `WORKDIR /app` in the final stage.
- Dropped the `HEALTHCHECK` directive (was firing `/app/main -health`
which is not a real flag — false-failing the container).
- Exposed port changed `8080 → 8196` to match the production listener.
- `docker-compose.dev.yml`
- Container name renamed `service-satusehat → service-satusehat-dev`.
- Removed the orphan `CONFIG_PATH=/app/config.yaml` env (config is loaded
relative to the binary, not from an absolute path).
- `docker-compose.prod.yml`
- Replaced the inline `redis:7-alpine` sidecar with an external network
`service-general_default` that points to the **shared** Redis run by
`service-general`.
- Ports remapped to `8196:8196` (REST) and `8197:8197` (gRPC, future).
- Container renamed `service-general → service-satusehat-prod`.
**Why.**
- Hospital DC standardises on Go 1.25 toolchain.
- Health-check directive was generating false alarms and feeding monitoring
noise — the in-app `/api/v1/health` endpoint already exists.
- Redis must be shared with `service-general` to avoid duplicate caches and
inconsistent rate-limit state.
**Impact.**
- Production stack now requires the external network to exist:
`docker network create -d bridge service-general_default` (or it is
created by the `service-general` repo's compose).
---
## 2026-05-04 → 2026-05-12 — meninjar — `chore` · scope: logs
**Summary.** Daily log files accumulated under `logs/2026/05/` from dev runs.
These are *not* committed; they exist only on the developer machine. They
should be ignored via `.gitignore` (already excluded by repo policy).
---
## af32d9c — meninjar — `feat` · scope: SATUSEHAT (commit on `main`)
**Title:** *penambahan All case Satu sehat*
**Summary.** Added the remaining FHIR usecases so that the service now
covers all 19 SATUSEHAT resources end-to-end.
**Resources covered in this commit batch:**
AllergyIntolerance, CarePlan, ClinicalImpression, Composition, Condition,
DiagnosticReport, Encounter, EpisodeOfCare, ImagingStudy, Immunization,
Medication, MedicationDispense, MedicationRequest, MedicationStatement,
Observation, Procedure, QuestionnaireResponse, ServiceRequest, Specimen
(plus minimal Studies for DICOM linkage).
**Pattern adopted:** `dto.go` + `mapper.go` + `repository.go` + `service.go`
per resource (see Appendix B of `docs/analysis.qmd`).
---
## 135c631 — meninjar — `fix` · scope: SATUSEHAT (commit on `main`)
**Title:** *Perbaikan Service Satu Sehat*
**Summary.** Bug fixes across the SATUSEHAT client and early usecase
modules. Details inferred from the commit name; per-file detail to be
back-filled when a follow-up retrospective is held.
---
## 0adf9ef — meninjar — `fix` · scope: SATUSEHAT (commit on `main`)
**Title:** *Perbaikan Service Satu sehat*
**Summary.** Further fixes after the initial SATUSEHAT integration
landing. Same back-fill caveat as above.
---
## 4e59b96 — meninjar — `chore` · scope: gRPC (commit on `main`)
**Title:** *perbaikan GRPC Generate*
**Summary.** Adjusted the gRPC code-generation tooling. Net result is that
the generation pipeline *runs*, but no `.proto` files are committed and
no services are registered yet — see DEVPLAN M3.
---
## edfaa88 — meninjar — `feat` · scope: project (initial commit)
**Title:** *first commit*
**Summary.** Initial scaffold of the service: Clean Architecture layout,
multi-DB layer, error system, query builder, auth module, master/role
modules, BPJS client skeleton, SATUSEHAT client skeleton, Dockerfile,
docker-compose files, Makefile, README.md, DOCUMENT.md.
---
# How to add a new entry
Copy this template to the top of the file:
```markdown
## YYYY-MM-DD — author — `feat|fix|refactor|chore|infra|docs|incident|decision` · scope: <module>
**Summary.** One-paragraph headline.
**What changed.** Bullet list of file-level changes (use `file:line` when
useful).
**Why.** Motivation, constraint, or incident that drove the change.
**Impact.** API breakage? Ops action required? New env vars?
**Follow-ups.** Anything intentionally left for later, linked to a
DEVPLAN task ID when applicable.
```
Rules:
- **Append-only.** Never rewrite a historical entry; correct via a new
entry that references the older one.
- **Decisions** that are architecturally load-bearing get their own entry
with the `decision` label (think ADR-lite).
- Keep entries terse: enough that a new joiner can reconstruct the
reasoning without paging in the PR.
+195
View File
@@ -0,0 +1,195 @@
# Development Plan — `service-satusehat`
| Field | Value |
|---|---|
| Document Version | 1.0 |
| Status | Draft for review |
| Owner | Backend Engineering — Meninjar |
| Last Updated | 2026-05-13 |
| Horizon | ~2 quarters (M1 → M5) |
> Read this together with `PRD.md` (the *what / why*), `analysis.qmd` (the
> *as-is*), and `DEVLOG.md` (the *what happened*).
---
## 1. Guiding Principles
1. **Stabilise before extending.** The 19 FHIR resources work end-to-end but
rest on near-zero tests and an empty gRPC surface. Hardening comes before
new features.
2. **Pure mappers.** No I/O, no env reads inside `Map*ToFHIR`. Inject from
the service layer. This is the `EpisodeOfCare` pattern applied broadly.
3. **One change, one PR, one CI run.** No more multi-resource omnibus
commits like `af32d9c`.
4. **Audit everything that talks to SATUSEHAT.** Outbound calls are
regulator-visible artefacts.
5. **Production readiness is a checklist, not a feeling.** See §6.
---
## 2. Milestones
### M1 — Hardening (Weeks 13)
> Goal: make the codebase *safe to refactor*. After M1, every PR runs
> through CI with linting + tests, the obvious bugs are fixed, and the
> debug-log noise is gone.
| ID | Task | Effort | Owner | Acceptance |
|-------|--------------------------------------------------------------------------|--------|----------|------------|
| T-01 | Set up CI (GitHub Actions): `go vet`, `golangci-lint`, `go test`, `make audit`, `make security-check`, Docker build | 1 d | Backend | PR triggers green pipeline; status checks required for `main` |
| T-02 | Add `golangci-lint.yml` config (errcheck, gocritic, gosec, revive, govet) | 0.5 d | Backend | `make lint` returns clean baseline (existing offenders allowlisted, not silenced) |
| T-03 | Unit tests for every `Map*ToFHIR` (19 resources) | 4 d | Backend | Golden-file tests; `make test` ≥ 60 % coverage on `internal/satusehat/usecase/**` |
| T-04 | Apply the `EpisodeOfCare` pattern to the remaining 18 usecases (drop env reads, accept `orgID` via constructor) | 3 d | Backend | No usecase mapper imports `os`; main.go wires `cfg.SatuSehat.OrgID` for all |
| T-05 | Replace `fmt.Printf` debug in `pkg/utils/query/**` with `logger.Debug()` | 0.5 d | Backend | `grep -R "fmt.Printf" pkg/utils/query` returns 0 |
| T-06 | Make `NoOp` cache goroutine-safe with `sync.RWMutex` | 0.5 d | Backend | Race-detector tests pass under load |
| T-07 | Delete commented-out Kafka producer + ImagingStudy worker from `cmd/api/main.go`, or move behind a `KAFKA_ENABLED` feature flag | 0.5 d | Backend | `main.go` has no commented blocks > 3 lines |
| T-08 | Rename `internal/master/role/accses``access` | 0.5 d | Backend | One renaming commit; all imports updated |
**Exit criteria:** CI required on `main`, lint clean baseline, FHIR mapper
unit-test coverage ≥ 60 %, no debug `Printf` in `pkg/`.
---
### M2 — Auditability (Weeks 45)
> Goal: every outbound SATUSEHAT call leaves a row we can defend in a
> Kemenkes audit.
| ID | Task | Effort | Owner | Acceptance |
|-------|--------------------------------------------------------------------------|--------|----------|------------|
| T-10 | Design table `satusehat_submission` | 0.5 d | Backend | Columns: `id, resource, method, endpoint, request_id, request_body_hash, response_status, response_body_hash, satusehat_resource_id, error_code, created_at, latency_ms, org_id, user_id` |
| T-11 | Migration + GORM model + CommandRepository / QueryRepository | 1 d | Backend | Goose migration runs; CQRS pair compiles |
| T-12 | Wrap `internal/interfaces/satusehat.Client.DoRequest` with an audit interceptor (decorator) | 1 d | Backend | Every outbound call writes a row; failures still write |
| T-13 | Add request-ID middleware (X-Request-ID propagation, ULID generation if absent) | 0.5 d | Backend | All logs + audit rows carry the same `request_id` |
| T-14 | Retention policy doc — keep submissions for ≥ 25 years per Permenkes 24/2022 | 0.5 d | Backend / Compliance | `docs/RETENTION.md` exists and references the regulation |
| T-15 | Health probe `/health/satusehat` — last successful call within 5 min | 0.5 d | Backend | Returns `200` when at least one submission within the window |
**Exit criteria:** Audit rows for every outbound call across all 19
resources, joinable on `request_id`, retained per policy.
---
### M3 — gRPC Catch-up (Weeks 68)
> Goal: bring the gRPC transport from *empty* to *parity with REST for the
> four hottest resources*.
| ID | Task | Effort | Owner | Acceptance |
|-------|--------------------------------------------------------------------------|--------|----------|------------|
| T-20 | Author `proto/v1/satusehat/{encounter,episodeofcare,condition,observation}.proto` | 2 d | Backend | `make proto` generates clean Go code |
| T-21 | Implement gRPC handlers for the four resources reusing the existing services | 2 d | Backend | gRPC + REST share the **same** service layer |
| T-22 | Register handlers in `cmd/api/main.go` gRPC server | 0.5 d | Backend | `grpcurl … list` shows the four services |
| T-23 | Add buf lint + breaking-change check to CI | 0.5 d | Backend | PRs that break protobuf surface fail CI |
| T-24 | Postman → grpcurl examples in `docs/api/` | 0.5 d | Backend | One example call per service in markdown |
**Exit criteria:** Four resources callable via gRPC, behind the same
auth, with parity behaviour to REST.
---
### M4 — Resilience (Weeks 910)
> Goal: survive a flaky SATUSEHAT without dropping submissions.
| ID | Task | Effort | Owner | Acceptance |
|-------|--------------------------------------------------------------------------|--------|----------|------------|
| T-30 | Retry middleware on `Client.DoRequest`: exponential backoff (250 ms · 2^n, cap 8 s), max 5 attempts, retry on 5xx + network errors only | 1 d | Backend | Unit tests with fake transport |
| T-31 | Circuit breaker (`sony/gobreaker`) per upstream (FHIR, KFA, KYC, Consent, DICOM) | 1 d | Backend | Opens after 5 consecutive failures, half-opens after 30 s |
| T-32 | Idempotency key support — caller supplies `Idempotency-Key`, service dedupes within 24 h via Redis | 1 d | Backend | Replays return the original response, not a duplicate submission |
| T-33 | Rate-limit middleware on inbound (Redis token bucket, key by `user_id` then `ip`) | 1 d | Backend | `cfg.Security.RateLimit.RequestsPerMinute` enforced |
| T-34 | Refresh-token revocation on logout — persist to `revoked_tokens` table; check on every refresh (closes TODO at `internal/auth/service.go:233`) | 1 d | Backend | Logout test passes; refresh returns 401 after logout |
| T-35 | Persist KYC verification result locally (closes TODO in `kyc/service.go`) | 0.5 d | Backend | New table `kyc_verification`; result row on every success |
| T-36 | Standardise cache invalidation in `role/master`, `role/pages`, `role/permission`, `role/access` — invalidate on every command op | 1 d | Backend | TODOs removed; integration test covers stale-cache scenario |
**Exit criteria:** Service tolerates 30 s of SATUSEHAT 5xx without losing a
submission; logout truly invalidates; cache is never stale after a write.
---
### M5 — Production Rollout (Weeks 1112)
> Goal: from "running on a dev box" to "running in the hospital DC, one
> instance, watched."
| ID | Task | Effort | Owner | Acceptance |
|-------|--------------------------------------------------------------------------|--------|----------|------------|
| T-40 | Production `docker-compose.prod.yml` review with ops; document the external network requirement | 0.5 d | Backend / Ops | `docs/deployment.md` updated; one-shot setup script |
| T-41 | Grafana dashboard JSON (request volume, p50/p95/p99 latency, error rate by code, DB pool, cache hit ratio) | 1 d | Backend | Dashboard imports cleanly; screenshot in `docs/` |
| T-42 | Prometheus alert rules (SATUSEHAT 5xx > 5/min, p95 > 5 s for 5 min, DB pool exhaustion) | 0.5 d | Backend / Ops | Rules under `deploy/prom/alerts.yaml` |
| T-43 | Backup strategy for `satusehat_submission` and master DB (daily pg_dump, 30-day retention onsite, 1-year offsite encrypted) | 0.5 d | Ops | `docs/BACKUP.md` exists |
| T-44 | DR runbook — what to do when SATUSEHAT is down, when Redis is down, when the DB primary fails | 1 d | Backend / Ops | `docs/RUNBOOK.md` exists |
| T-45 | Soft launch — 1 hospital, 1 day of shadow traffic | 1 d | All | No P0 incidents; audit table populated |
| T-46 | GA — promote to all sites that depend on SATUSEHAT submission | 0.5 d | All | Stakeholder sign-off |
**Exit criteria:** Service running in production with full observability,
alerts, backups, and a runbook.
---
## 3. Backlog (post-M5, not yet scheduled)
- **B-01** Multi-tenant support: one binary, multiple `org_id`s per
request based on JWT claim (depends on T-04 pattern across all 19).
- **B-02** Event-driven mode: re-enable the commented-out Kafka producer
(`cmd/api/main.go:137141`) so that submissions are emitted as events
for downstream consumers (data warehouse, BI).
- **B-03** Background worker for ImagingStudy DICOM uploads (re-enable
`cmd/api/main.go:286300` behind a worker process flag).
- **B-04** OpenTelemetry tracing (replace ad-hoc request-id with
`traceparent`).
- **B-05** Bulk submission endpoints (`POST /v1/Encounter/bundle`) for
back-fill scenarios.
- **B-06** Mobile-friendly DTOs (slimmer JSON than the full FHIR shape).
- **B-07** Finish or remove the `tools/generate.go` code generator.
- **B-08** Postman → automated contract tests in CI.
---
## 4. Risk Register
| ID | Risk | Likelihood | Impact | Mitigation |
|------|-------------------------------------------------------------------|------------|----------|---------------------------------------------------------------|
| RK-1 | Refactor breaks an in-production FHIR resource | Med | High | T-03 unit tests must land before T-04 refactor cascade |
| RK-2 | SATUSEHAT contract changes mid-quarter | Low | High | Contract tests behind a build tag, run nightly against staging |
| RK-3 | Hospital DC ops cannot operate Prometheus stack | Med | Med | Provide a one-shot Compose + Grafana with default dashboards |
| RK-4 | Single instance is a SPOF | High | Med | M5+ plan to run two instances behind nginx; cache is shared so it's safe |
| RK-5 | Audit table grows unbounded | High | Low | Partition by month + offsite archive (T-14) |
| RK-6 | Refresh-token revocation table grows unbounded | Med | Low | TTL cleanup job; keep only until exp + 7 d |
| RK-7 | gRPC contract evolves and breaks mobile clients | Med | Med | T-23 buf breaking-change check |
---
## 5. Dependencies & Coordination
| Dependency | Needed by | Status |
|-------------------------------------|-----------|--------|
| SATUSEHAT staging credentials | M1+ | ✓ in `.env` |
| Keycloak realm config | M1+ | depends on hospital IAM team |
| External Docker network `service-general_default` | M5 | ✓ created by `service-general` compose |
| Prometheus + Grafana in hospital DC | M5 | ⚠ to confirm with ops |
| Offsite backup target | M5 | ⚠ to confirm with ops |
---
## 6. Definition of Done (per task)
A task is **Done** only when:
1. Code merged to `main` via PR.
2. CI green (lint + test + audit + security-check + Docker build).
3. Tests cover the happy path and at least one error path.
4. `docs/DEVLOG.md` has a new entry referencing the task ID.
5. If user-visible: `docs/PRD.md` updated; if architectural: `docs/analysis.qmd` updated.
6. If touching ops: `docs/deployment.md` / `RUNBOOK.md` updated.
7. No new TODO without a backlog ID.
---
## 7. Cadence
- **Daily**: 15-min stand-up over chat; update task status in the issue tracker.
- **Weekly**: 30-min review — what shipped, what's blocked, what's next.
- **Per milestone**: written retrospective appended to `DEVLOG.md` with
scope `decision`.
+252
View File
@@ -0,0 +1,252 @@
# Product Requirements Document (PRD)
## Service: `service-satusehat`
| Field | Value |
|---|---|
| Document Version | 1.0 |
| Status | Draft |
| Owner | Backend Engineering — Meninjar |
| Last Updated | 2026-05-13 |
| Repository | `goprint/service-satusehat` |
| Primary Language | Go 1.25 |
| Target Audience | Backend engineers, integration partners, ops, hospital IT (RS) |
---
## 1. Overview
### 1.1 Product Summary
`service-satusehat` is a backend microservice that acts as the **integration gateway** between an Indonesian hospital information system (SIMRS / HIS) and Indonesia's national health platforms — primarily **SATUSEHAT** (HL7 FHIR R4) operated by the Ministry of Health, with secondary integration paths for **BPJS Kesehatan** (VClaim, Antrol, Apotek, Aplicare, IHS) and supporting infrastructure (Keycloak SSO, MinIO object storage, Redis cache, Prometheus observability).
The service exposes a **dual transport surface** (REST via Gin and gRPC) and is built on **Clean Architecture + Domain-Driven Design + CQRS**. It centralizes all outbound calls to SATUSEHAT, normalizes FHIR resource construction, manages OAuth2 tokens, and protects upstream callers from FHIR/JSON-Patch complexity.
### 1.2 Problem Statement
Hospitals in Indonesia are mandated by the Ministry of Health to send clinical encounter data to SATUSEHAT (HL7 FHIR). Direct integration is hard because:
1. **Heterogeneous internal data sources** — most hospitals have data spread across multiple databases (PostgreSQL / MySQL / SQL Server) and legacy systems.
2. **Strict FHIR R4 contract** — resource structure, references, codings, and identifier systems must follow SATUSEHAT IG.
3. **OAuth2 token lifecycle** — SATUSEHAT requires short-lived tokens with automatic refresh and concurrency-safe caching.
4. **Auxiliary services** — KFA (drug master), KYC, Consent, DICOM, BPJS all have different auth, signing, and payload conventions.
5. **Observability & auditability** — every regulated submission must be logged, traceable, and retryable.
A single integration service that solves the above once, and is reusable across hospital products, is significantly cheaper than re-implementing the contract in every product team.
### 1.3 Goals
| # | Goal | Success indicator |
|---|---|---|
| G1 | Provide one canonical Go service that submits every required FHIR resource to SATUSEHAT | All 19 FHIR resources implemented with Create/Update/Patch/Get/Search |
| G2 | Abstract OAuth2 + auxiliary auth (KFA, KYC, Consent, DICOM) from callers | Single client handles token caching and header injection |
| G3 | Be deployable in a hospital data center with minimal ops effort | Single Docker image, distroless, Compose stack with dev/prod profiles |
| G4 | Stay observable in production | Prometheus metrics, structured logs, health endpoints, request tracing fields |
| G5 | Stay vendor-agnostic at the storage layer | Multi-DB support (Postgres, MySQL, SQL Server, MongoDB, SQLite) with read replicas |
| G6 | Be safe to refactor and extend | Clean Architecture boundaries, generated code, CQRS separation |
### 1.4 Non-Goals
-**Front-end UI** — this is a backend service only.
-**Long-term clinical data storage** — the hospital's primary database remains the source of truth.
-**Generic FHIR server** — only SATUSEHAT-required resources and profiles are supported, not the full HL7 FHIR spec.
-**BPJS claim submission UI/UX** — only the integration layer is in scope.
-**HL7 v2 messaging** — out of scope.
---
## 2. Target Users & Personas
### 2.1 Primary Users (callers)
| Persona | Description | What they need |
|---|---|---|
| **SIMRS Backend** | Internal hospital ERP/HIS server that emits clinical events | A stable REST/gRPC contract that hides SATUSEHAT details |
| **Mobile / Web App teams** | Patient portal, clinician app | A simple "submit encounter" API; no FHIR construction |
| **Data engineering** | Builds dashboards / reports on submitted resources | Read-back, search, and audit logs |
### 2.2 Secondary Users
| Persona | Description |
|---|---|
| **Ops / SRE** | Runs the container in the hospital data center. Cares about logs, metrics, health, restart safety |
| **Integration developers** | Add new SATUSEHAT resources, debug failures |
| **Compliance / regulators** | Need audit trail of submissions for inspection |
---
## 3. Functional Requirements
### 3.1 SATUSEHAT FHIR Submission (P0 — core value)
The service MUST expose endpoints for the following 19 FHIR resources, each with **Create / Update (full) / Patch (JSON Patch RFC 6902) / Get-by-ID / Search**:
| # | Resource | Use case |
|---|---|---|
| 1 | AllergyIntolerance | Patient allergy registry |
| 2 | CarePlan | Treatment plan |
| 3 | ClinicalImpression | Clinician assessment |
| 4 | Composition | Document grouping |
| 5 | Condition | Diagnosis (ICD-10) |
| 6 | DiagnosticReport | Lab / radiology report |
| 7 | Encounter | Patient visit (rawat jalan/inap/IGD) |
| 8 | EpisodeOfCare | Continuous treatment episode |
| 9 | ImagingStudy | Radiology study link |
| 10 | Immunization | Vaccination record |
| 11 | Medication | Drug definition |
| 12 | MedicationDispense | Pharmacy dispense event |
| 13 | MedicationRequest | Prescription |
| 14 | MedicationStatement | Patient-reported medication |
| 15 | Observation | Vital signs, lab results |
| 16 | Procedure | Performed procedure (ICD-9-CM) |
| 17 | QuestionnaireResponse | Form / triage answers |
| 18 | ServiceRequest | Lab / radiology order |
| 19 | Specimen | Lab specimen |
Plus minimal **Studies (DICOM)** for imaging linkage.
### 3.2 SATUSEHAT Reference Lookups (P0)
The service MUST proxy reads to the following SATUSEHAT registries:
- **Patient** — search by NIK, name, DOB
- **Practitioner** — by NIK or NPP
- **Organization** — facility registry
- **Location** — room/bed
- **KFA** — Katalog Farmasi Alkes (drug & device master)
- **KYC** — verifikasi identitas
- **Auth** — token refresh / validate
### 3.3 Authentication & Authorization (P0)
- **Inbound**: JWT-based auth (with Keycloak, static-token, and hybrid modes). RBAC via Role / Page / Permission / Access master tables.
- **Outbound**: OAuth2 client-credentials flow to SATUSEHAT with concurrency-safe in-memory token caching (≥1 minute buffer before expiry).
- **BPJS**: HMAC-SHA256 signature header per BPJS spec (cons-id + secret).
### 3.4 Storage & Master Data (P1)
- Multi-database support (Postgres preferred) for:
- Auth (users, sessions, tokens)
- Role / Page / Permission / Access (RBAC master)
- Audit log of SATUSEHAT submissions (TBD — see §7 Gaps)
- Read replicas with round-robin load balancing.
- GORM as primary ORM; raw SQL via sqlx / squirrel where ORM is too slow.
### 3.5 Cross-Cutting (P1)
- Structured logging (logrus) to console + daily file `logs/YYYY/MM/YYYY-MM-DD.log`.
- Prometheus metrics: HTTP request counts/latency, DB pool stats, cache hit ratio, error counters by code.
- Health endpoints: overall + per-database + external (SATUSEHAT, KFA, KYC).
- i18n-ready error system (English + Indonesian).
- Soft-delete + audit trail at the master-data layer.
### 3.6 Object Storage (P2)
- MinIO integration for storing supporting artifacts (e.g., DICOM tarballs, KYC scans, consent PDFs). Buckets configurable per environment.
---
## 4. Non-Functional Requirements
| Category | Requirement |
|---|---|
| **Performance** | P95 latency for outbound SATUSEHAT create ≤ 2 s when SATUSEHAT responds within SLA; service overhead ≤ 50 ms |
| **Throughput** | ≥ 100 sustained writes/sec per service instance (single-node, 4 vCPU) |
| **Availability** | 99.5 % (hospital data-center, single instance acceptable; horizontal scale-out supported) |
| **Recovery** | Graceful shutdown ≤ 5 s; in-flight requests drained on SIGTERM |
| **Security** | No plaintext secrets in logs; parameterised queries; CORS configurable; rate-limit configurable |
| **Compliance** | Comply with Permenkes 24/2022 (rekam medis elektronik) and SATUSEHAT IG R4 |
| **Observability** | Every outbound SATUSEHAT call MUST be traceable by `request_id` (TBD) |
| **Portability** | Single static binary, distroless image, runs on amd64 Linux |
| **Time zone** | All timestamps in `Asia/Jakarta` (WIB); FHIR payloads use ISO-8601 with offset |
---
## 5. Out-of-Scope Items (deliberately excluded)
- Web frontend / admin UI.
- Long-term clinical archival.
- HL7 v2 messaging.
- Direct BPJS claim file generation (RBAC adjustments only).
- Patient-facing mobile API.
---
## 6. Dependencies
### 6.1 External (third-party)
| Dependency | Purpose | Notes |
|---|---|---|
| SATUSEHAT FHIR R4 | Primary integration target | Staging + production base URLs configured |
| SATUSEHAT KFA | Drug & device master | Separate endpoint and headers |
| SATUSEHAT KYC | Identity verification | Public/private key pair (config) |
| SATUSEHAT Consent | Patient consent | Webhook secret configured |
| SATUSEHAT DICOM | Imaging study upload | Separate endpoint |
| BPJS VClaim / Antrol / Apotek / Aplicare / IHS | Insurance integration | HMAC-SHA256 auth |
| Keycloak | SSO / OAuth2 issuer (optional) | JWKS validation |
### 6.2 Internal
- PostgreSQL 12+ (recommended)
- Redis 6+ (cache, rate limit) — optional but recommended
- MinIO (S3-compatible) — optional
- Prometheus (scrape `/metrics`)
---
## 7. Known Gaps & Risks (carried into DEVPLAN)
| # | Gap | Severity | Owner |
|---|---|---|---|
| R1 | gRPC defined in config but **no `.proto` files committed** and registry empty | High | Backend |
| R2 | Test coverage ≈ **2 files total** — query builder only | High | Backend |
| R3 | Rate-limit config exists, no middleware implementation | Medium | Backend |
| R4 | Auth logout does **not** revoke refresh tokens in DB (TODO at `internal/auth/service.go:233`) | Medium | Backend |
| R5 | KYC service does **not** persist verification to local DB | Medium | Backend |
| R6 | Role / Page / Permission services have TODO cache invalidation | Medium | Backend |
| R7 | 60+ `fmt.Printf` debug calls in `pkg/utils/query` — leaks to stdout in production | Low | Backend |
| R8 | NoOp cache fallback is not thread-safe (map without mutex) | Medium | Backend |
| R9 | Folder name typo `internal/master/role/accses/` (should be `access`) | Low | Backend |
| R10 | Audit-log table for SATUSEHAT submissions not yet defined | High | Backend |
| R11 | No CI pipeline committed | High | Ops |
---
## 8. Success Metrics
| Metric | Target |
|---|---|
| SATUSEHAT submission success rate | ≥ 99 % (per resource, per hour) |
| Coverage on `internal/satusehat/usecase/**` | ≥ 60 % unit tests, ≥ 1 contract test per resource |
| Mean time to add a new FHIR resource | ≤ 1 day (via code-gen template) |
| Production crash-loop incidents | 0 per month |
| Time to detect a SATUSEHAT outage | ≤ 1 minute via health probe |
---
## 9. Release Plan (high-level — detail in DEVPLAN)
| Milestone | Description |
|---|---|
| **M1 — Hardening** | Fix R1R3, R7, R8; add CI; unit-test FHIR mappers |
| **M2 — Auditability** | Implement audit-log table + middleware emitting one row per outbound SATUSEHAT call (R10) |
| **M3 — gRPC** | Author proto files; generate; register services for at least Encounter, EpisodeOfCare, Condition, Observation |
| **M4 — Resilience** | Retry with exponential backoff for SATUSEHAT 5xx + circuit breaker |
| **M5 — Production rollout** | Single-instance deployment in hospital DC, then dual-instance behind nginx |
---
## 10. Glossary
| Term | Meaning |
|---|---|
| **SATUSEHAT** | Indonesia national health-data platform (Kemenkes) |
| **FHIR** | Fast Healthcare Interoperability Resources (HL7 R4) |
| **BPJS** | Badan Penyelenggara Jaminan Sosial — Indonesian social insurance |
| **VClaim** | BPJS claim API |
| **KFA** | Katalog Farmasi Alkes (drug/device catalogue) |
| **KYC** | Know Your Customer — identity verification |
| **SIMRS** | Sistem Informasi Manajemen Rumah Sakit (hospital management system) |
| **IHS** | Indonesia Health Services (BPJS) |
| **CQRS** | Command Query Responsibility Segregation |
| **DDD** | Domain-Driven Design |
+520
View File
@@ -0,0 +1,520 @@
---
title: "service-satusehat — Technical Analysis"
subtitle: "Architecture, modules, gaps, and risks"
author: "Backend Engineering"
date: "2026-05-13"
format:
html:
toc: true
toc-depth: 3
number-sections: true
code-fold: false
theme: cosmo
embed-resources: true
pdf:
toc: true
number-sections: true
colorlinks: true
execute:
echo: true
eval: false
---
# Executive Summary
`service-satusehat` is a production-track Go microservice that implements an
**integration gateway** between a hospital information system (SIMRS) and the
Indonesian SATUSEHAT FHIR R4 platform, with secondary integration paths to
BPJS, Keycloak, MinIO, and Redis.
The codebase is built on **Clean Architecture + Domain-Driven Design + CQRS**,
serves both **REST (Gin)** and **gRPC** transports, and supports five
relational/NoSQL databases. As of 2026-05-13:
- **19 FHIR resources** are wired end-to-end (Create / Update / Patch / Get / Search).
- **7 reference registries** are proxied (Patient, Practitioner, Organization,
Location, KFA, KYC, Auth).
- The error system, logger, query builder, and Prometheus metrics layer are
mature and reusable.
- **Critical gaps**: virtually zero test coverage, empty gRPC implementation,
no audit-log table, no CI.
This document presents the technical analysis in depth; the companion files
`PRD.md`, `DEVPLAN.md`, and `DEVLOG.md` cover product framing, forward plan,
and ongoing change record respectively.
---
# Repository Layout
```{.text}
service-satusehat/
├── cmd/
│ ├── api/main.go # primary entry point (REST + gRPC)
│ ├── seeder/main.go # data seeder
│ └── monitoring/main.go # standalone monitoring entry
├── internal/
│ ├── auth/ # authentication module (CQRS)
│ ├── master/role/ # RBAC master: master, pages, permission, accses
│ ├── infrastructure/
│ │ ├── cache/ # Redis + NoOp cache
│ │ ├── config/ # YAML + env loader
│ │ ├── container/ # DI container
│ │ ├── database/ # multi-DB manager + migrations
│ │ ├── monitoring/ # health, metrics, middleware
│ │ └── transport/ # http (Gin) + grpc
│ ├── interfaces/
│ │ ├── bpjs/ # BPJS client (HMAC-signed)
│ │ ├── minio/ # object storage
│ │ └── satusehat/ # SATUSEHAT FHIR client + token cache
│ └── satusehat/
│ ├── common/ # shared mappers / DTOs
│ ├── reference/ # patient, practitioner, organization,
│ │ # location, kfa, kyc, auth
│ └── usecase/ # 19 FHIR resource modules
│ └── <resource>/
│ ├── dto.go
│ ├── mapper.go
│ ├── repository.go
│ └── service.go
├── pkg/
│ ├── crypto/ # RSA helpers
│ ├── errors/ # AppError, codes, builder, i18n, grpc/http mapping
│ ├── logger/ # logrus-based structured logger
│ ├── response/ # standard HTTP envelopes
│ └── utils/
│ ├── custom/ # time helpers (WIB)
│ ├── query/ # multi-dialect SQL + Mongo query builder
│ └── validator/ # validator-v10 translator
├── docs/ # this folder
├── tools/ # generator + generator config
├── scripts/ # build / proto / migrate / seed shell scripts
├── Dockerfile / Dockerfile.dev
├── docker-compose.{dev,prod,monitoring,redis,network}.yml
├── Makefile
└── config.yaml
```
---
# Architecture
## Layering
```{.mermaid}
flowchart TB
T["Transport Layer<br/>REST (Gin) • gRPC"]
A["Application Layer<br/>auth • role • SATUSEHAT usecases"]
D["Domain Layer<br/>CQRS: Command + Query repositories"]
I["Infrastructure Layer<br/>DB • Cache • SATUSEHAT client • BPJS • MinIO • Logger • Metrics"]
T --> A --> D --> I
```
- **Transport** is thin: parse HTTP/gRPC, validate input, call service.
- **Application** orchestrates use-cases; one usecase per FHIR resource.
- **Domain** is the CQRS boundary — every repository is split into
`CommandRepository` (write) and `QueryRepository` (read).
- **Infrastructure** owns I/O concerns and exposes interfaces upward.
## CQRS in practice
Every domain module wires **two repository implementations** at boot:
```{.go}
authCmdRepo := auth.NewCommandRepository(primaryDB, logger)
authQueryRepo := auth.NewQueryRepository(primaryDB, logger)
authService := auth.NewService(authCmdRepo, authQueryRepo, cacheManager, cfg)
```
This is wired uniformly for `auth`, `role/master`, `role/pages`,
`role/permission`. The pattern lets reads route to read replicas while writes
target the primary — see `database.GetReadDB()` for the round-robin selector.
## SATUSEHAT integration flow
```{.mermaid}
sequenceDiagram
participant Caller
participant Service as service-satusehat
participant Client as internal/interfaces/satusehat.Client
participant SS as SATUSEHAT FHIR
Caller->>Service: POST /api/v1/encounter
Service->>Service: validate DTO
Service->>Service: MapRequestToFHIR(req)
Service->>Client: DoRequest(ctx, POST, "/Encounter", payload)
Client->>Client: GetAccessToken() — cached if valid
alt token expired or missing
Client->>SS: POST /oauth2/v1/accesstoken
SS-->>Client: access_token (≈1h)
end
Client->>SS: POST /Encounter (Bearer ...)
SS-->>Client: FHIR response
Client-->>Service: FHIRResponse{ResourceID, Outcome}
Service-->>Caller: 200 OK with mapped envelope
```
Key properties:
- **Token caching** is in-memory, mutex-protected, and uses a 1-minute safety
buffer before expiry (`internal/interfaces/satusehat/client.go`).
- **Auth URL normalisation** auto-appends `/accesstoken` + query parameters
if the configured URL is missing them.
- Auxiliary paths (`DoKFA`, `DoKYC`, `DoConsent`) inject the right scoped
headers transparently.
---
# Module Inventory
## SATUSEHAT FHIR Usecases
All 19 modules follow the same four-file template:
| File | Responsibility |
|---------------|----------------|
| `dto.go` | Request, Update, Patch DTOs with validator-v10 tags |
| `mapper.go` | `MapRequestToFHIR(req) → FHIRPayload` |
| `repository.go` | Thin wrapper over `SatuSehatClient.DoRequest` |
| `service.go` | Validation + orchestration + Mapper + Repository |
| # | Resource | Status | Notes |
|---|-----------------------|--------|-------|
| 1 | AllergyIntolerance | ✓ CRUDS | |
| 2 | CarePlan | ✓ CRUDS | |
| 3 | ClinicalImpression | ✓ CRUDS | |
| 4 | Composition | ✓ CRUDS | |
| 5 | Condition | ✓ CRUDS | |
| 6 | DiagnosticReport | ✓ CRUDS | |
| 7 | Encounter | ✓ CRUDS | most complex mapper |
| 8 | EpisodeOfCare | ✓ CRUDS | **active refactor** — see DEVLOG 2026-05-13 |
| 9 | ImagingStudy | ✓ CRUDS | linked with Studies |
| 10 | Immunization | ✓ CRUDS | |
| 11 | Medication | ✓ CRUDS | |
| 12 | MedicationDispense | ✓ CRUDS | |
| 13 | MedicationRequest | ✓ CRUDS | |
| 14 | MedicationStatement | ✓ CRUDS | |
| 15 | Observation | ✓ CRUDS | vitals + lab |
| 16 | Procedure | ✓ CRUDS | |
| 17 | QuestionnaireResponse | ✓ CRUDS | |
| 18 | ServiceRequest | ✓ CRUDS | |
| 19 | Specimen | ✓ CRUDS | |
| | Studies (DICOM) | minimal | repo only, no full service |
> Legend: **CRUDS** = Create, Update, Patch, Get-by-ID, Search.
## SATUSEHAT Reference Modules
| Module | Purpose |
|---------------|---------|
| `patient` | Search by NIK / name / DOB |
| `practitioner`| Search by NIK or NPP |
| `organization`| Facility registry |
| `location` | Room / bed registry |
| `kfa` | Drug & device master |
| `kyc` | Identity verification (TODO: persist result locally) |
| `auth` | Token introspection / refresh |
## Application Modules
| Module | Files | Notes |
|--------------------------|-------|-------|
| `internal/auth` | 4 | JWT + Keycloak + static + hybrid auth |
| `internal/master/role/master` | 5 | Role CRUD |
| `internal/master/role/pages` | 5 | Page/menu master |
| `internal/master/role/permission` | 5 | Role↔Page↔Action mappings |
| `internal/master/role/accses` | 5 | Access list (folder typo) |
## Infrastructure
| Component | Maturity | Notes |
|------------------|----------|-------|
| `database` | High | 5 DB types, read replicas, pool tuning, migrations, LISTEN/NOTIFY |
| `cache` | Medium | Redis + NoOp factory; NoOp is not goroutine-safe |
| `config` | High | Full env + YAML + validate |
| `monitoring` | High | Prometheus + health + repo wrapper |
| `transport/http` | High | Gin + handlers + middleware + Swagger |
| `transport/grpc` | **Low** | proto/ empty, registry empty, reflection only |
| `container` | Medium | Hand-rolled DI |
## Cross-cutting (`pkg/`)
| Package | LOC (approx) | Notes |
|------------------|--------------|-------|
| `errors` | ~2000 | AppError, codes, http+grpc mapping, i18n, recovery, metrics |
| `logger` | ~400 | logrus-backed, daily-file output, context aware |
| `utils/query` | ~3500 | SQL + Mongo + dialects + filters + injection guard |
| `utils/validator`| ~150 | go-playground/validator translator |
| `response` | ~100 | Standard `{success, data, error, meta}` envelope |
---
# Configuration
## Sources and precedence
1. **Environment variables** (highest precedence).
2. **`config.yaml`** in working directory.
3. **Defaults baked into `config.Config`**.
Loading happens at startup via `config.LoadConfig()` and is followed by
`cfg.Validate()` which fails fast on missing required fields per auth /
SATUSEHAT type.
## Key configuration sections
| Section | Notable keys |
|---------------|--------------|
| `server.rest` | port, mode, read/write timeout |
| `server.grpc` | port, reflection_enabled |
| `databases.*` | type, host, credentials, pool, replicas, SSL |
| `auth` | type=jwt/static/keycloak/hybrid, keycloak.*, jwt.secret |
| `bpjs` | base_url, cons_id, secret_key, user_keys.* |
| `satusehat` | org_id, fasyankes_id, client_id, secret, auth_url, base_url, kfa_url, kyc_url, dicom_url |
| `cache` | enabled, redis.*, ttl.default/session/rate_limit |
| `minio` | endpoint, access/secret, ssl, buckets |
| `security` | trusted_origins, max_input_length, rate_limit.requests_per_minute |
| `logger` | level, format, output |
## Secrets handling
- All secrets sourced from environment (no plaintext in `config.yaml`).
- BPJS HMAC signing isolated in `internal/interfaces/bpjs/crypto.go`.
- KYC RSA key pair encoded as base64 env (`pkg/crypto/rsa.go`).
- Logger does **not** dump credentials at startup.
---
# Data Layer
## Supported databases
| Engine | Driver(s) | Purpose |
|------------|--------------------------|------------------------|
| PostgreSQL | `pgx/v5`, `pq`, GORM | Primary, recommended |
| MySQL | GORM mysql | Alternate primary |
| SQL Server | GORM sqlserver | Legacy SIMRS sources |
| SQLite | GORM sqlite | Local dev / tests |
| MongoDB | `mongo-go-driver` | Optional document store|
## Features
- **Singleton `dbManager`** behind `once.Do` for one-time initialisation.
- **Pool tuning** driven by `runtime.NumCPU()` if not configured:
`maxOpen = cores*2 + 1`, `maxIdle = maxOpen/2`.
- **Read replicas** with round-robin via `GetReadDB(name)`.
- **Migrations**: SQL files in `internal/infrastructure/database/sql/`
plus GORM AutoMigrate; tracked in a `Migrations` table.
- **LISTEN/NOTIFY** support for Postgres (`ListenForChanges`,
`NotifyChange`).
- **Health** per-database with pool stats in `Health()`.
## Query builder (`pkg/utils/query`)
A custom builder that supports:
- **Dialects**: PostgreSQL, MySQL, SQL Server, SQLite (syntax differences
centralised in `dialects.go`).
- **Operators**: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`,
`between`, full-text, date range.
- **Logical composition**: `AND`, `OR`, nested groups.
- **Pagination**: offset + limit.
- **Sorting**: multi-field ASC/DESC.
- **Mongo**: aggregation pipeline emission.
- **Injection guard**: regex blacklist + parameterised binding
(`sql_security.go`).
> ⚠ Multiple `fmt.Printf` debug calls remain in `sql_builder.go` and
> `mongo_execution.go`. These should be replaced with `logger.Debug()`
> before production rollout (DEVPLAN M1).
---
# Transport
## REST (Gin)
- Entry: `internal/infrastructure/transport/http/servers`.
- Handlers grouped by domain under `handlers/main/` and `handlers/satusehat/`.
- Middleware stack: CORS → recovery → request-id → auth → rate-limit (TBD).
- Routes registered via `RegisterRoutes(router, registry)` where `registry`
is the `ServiceRegistry` struct populated in `cmd/api/main.go`.
- Swagger: `swaggo/gin-swagger`, served at `/swagger/index.html`.
## gRPC
- Server bootstraps in `cmd/api/main.go` with optional reflection.
- `proto/v1/` directory **exists but contains no `.proto` files**.
- No services are currently registered with the gRPC server — clients
receive `Unimplemented`. See DEVPLAN M3 for the gRPC catch-up plan.
---
# Authentication & Authorization
## Inbound
| Mode | Description |
|-----------|-------------|
| `jwt` | Service signs and validates its own JWTs |
| `static` | Bearer-token list from env `AUTH_STATIC_TOKENS` |
| `keycloak`| Validate against Keycloak JWKS, audience checked |
| `hybrid` | Try Keycloak; fallback to static/JWT |
Service exposes:
- `Login(username, password) → access + refresh`
- `RefreshToken(refresh) → access + refresh`
- `ValidateToken(token) → claims`
- `Logout(token) → ok` ← **does not yet persist revocation** (TODO at
`internal/auth/service.go:233`)
- `GenerateJWT`, `VerifyJWT` for the local-only path.
## Outbound (SATUSEHAT)
- OAuth2 client-credentials, single concurrent `GetAccessToken()`,
mutex-protected.
- 1-minute pre-expiry refresh to avoid token race.
- Auxiliary services (`KFA`, `KYC`, `Consent`) reuse the same token where
scopes allow.
---
# Observability
| Surface | Detail |
|--------------|--------|
| **Logging** | `logrus`, JSON or text; daily file rotation under `logs/YYYY/MM/`. Fields: service, env, request_id (TBD), latency, status, error_code |
| **Metrics** | Prometheus client; HTTP req count/latency, DB pool stats, cache hit ratio, error count by code |
| **Health** | `GET /api/v1/health`, `/health/database`, `/health/external` |
| **Tracing** | Not yet — only request-id propagation planned |
---
# Build, Packaging, Deployment
## Build
- Go 1.25, modules.
- Multi-stage Dockerfile: `golang:1.25-alpine` → `gcr.io/distroless/static:nonroot`.
- CGO disabled, static binary, runs as `nonroot`.
- Exposes `8196` (REST) and `8197` (gRPC, when wired).
## Compose stacks
| File | Purpose |
|-------------------------------|---------|
| `docker-compose.dev.yml` | Hot-reload dev with `Dockerfile.dev` + air |
| `docker-compose.prod.yml` | Production single instance, joins external network `service-general_default` |
| `docker-compose.monitoring.yml` | Prometheus / Grafana side-car |
| `docker-compose.redis.yml` | Stand-alone Redis |
| `docker-compose.network.yml` | Creates shared external network |
## Make targets
`dev`, `prod`, `logs`, `clean`, `security-check` (gosec), `audit`
(govulncheck), `test`, `test-coverage`, `proto`, `proto-all`, `seeder-*`.
---
# Security Review (quick pass)
| Control | State |
|------------------------|-------|
| Parameterised queries | ✓ enforced by GORM + sqlx + builder |
| SQL injection regex guard | ✓ in `pkg/utils/query/sql_security.go` |
| CORS | ✓ configurable `trusted_origins` |
| Rate limit | ⚠ config exists, middleware missing |
| Secrets in env only | ✓ |
| TLS for outbound | ✓ via Go default; configurable for DB |
| Token revocation | ⚠ logout not persisted |
| Audit log of writes | ⚠ no dedicated table |
| Dependency scan | ✓ `make audit` (govulncheck) |
| SAST | ✓ `make security-check` (gosec) |
---
# Testing
| Path | Tests |
|---------------------------------------|-------|
| `pkg/utils/query/dialects_test.go` | ✓ |
| `pkg/utils/query/sql_builder_test.go` | ✓ |
| **Everything else** | ✗ |
> Test coverage is the single highest risk in the codebase. DEVPLAN M1
> prioritises mapper unit tests for every FHIR resource (cheap, high value).
---
# Gaps, Risks, and Recommendations
| ID | Gap | Severity | Recommendation |
|-----|--------------------------------------------------------------------|----------|----------------|
| R1 | gRPC empty | High | Author proto, generate, register at least the 4 most-used resources |
| R2 | Near-zero tests | High | Mapper unit tests first; integration tests behind build tag |
| R3 | No rate-limit middleware | Medium | Add Redis-backed token-bucket middleware; key by user/IP |
| R4 | Logout does not revoke | Medium | Persist revoked refresh tokens; check on every refresh |
| R5 | KYC result not persisted | Medium | Add `kyc_verification` table; write on success |
| R6 | Cache invalidation TODOs in role services | Medium | Standardise cache keys + invalidate on every Cmd write |
| R7 | `fmt.Printf` debug leaks | Low | Replace with `logger.Debug()` |
| R8 | NoOp cache not thread-safe | Medium | Add `sync.RWMutex` or refuse fallback in production |
| R9 | Folder name `accses` typo | Low | Rename module after one cycle of approved PRs |
| R10 | No SATUSEHAT submission audit table | High | Add `satusehat_submission` table + repository wrapper logging |
| R11 | No CI | High | GitHub Actions: lint → test → build → docker → push |
| R12 | Commented-out Kafka producer + ImagingStudy worker in `main.go` | Low | Either delete or move behind feature flags |
| R13 | Empty `tools/generate.go` | Low | Either remove or finish the code-gen story |
---
# Open Questions
1. **Audit retention** — how long must SATUSEHAT submission logs be kept?
(Permenkes 24/2022 mandates ≥ 25 years for medical records — does the
integration log count?)
2. **Idempotency** — do callers retry safely? Should the service de-duplicate
by `(resource, identifier, hash)` before re-submission?
3. **Multi-tenant** — is one deployment per hospital, or shared across
hospitals with `org_id` partitioning?
4. **DR** — Redis is shared external; does the hospital DC have a failover?
---
# Appendix A — Active Refactor: `EpisodeOfCare`
The most recently touched module. Diff summary (uncommitted as of
2026-05-13):
- `mapper.go` — removed `os.Getenv("SATUSEHAT_ORG_ID")` fallback; now relies
purely on `req.OrganizationID`. Mapping becomes pure / deterministic.
- `repository.go` — removed `hiddenCtx` wrapper; calls
`client.DoRequest(ctx, …)` directly.
- `service.go` — constructor now takes `orgID string`; injects into
`req.OrganizationID` when caller leaves it blank. Both `Create` and
`Update` honour the default.
Why it matters:
- **Testability**: pure mappers without env reads are trivial to unit-test.
- **Multi-tenant readiness**: each service instance can hold its own org
identity, paving the way for one binary serving multiple facilities.
- **Pattern**: this refactor should be replicated across the other 18 FHIR
modules (DEVPLAN M1 task T-04).
---
# Appendix B — Conventions for New FHIR Modules
When adding a new FHIR resource:
1. **Create folder** `internal/satusehat/usecase/<resource>/`.
2. **Define DTOs** in `dto.go` with `validator` tags.
3. **Write mapper** in `mapper.go`: pure function `Map…ToFHIR(req)` returning
`satusehat.FHIRPayload`. **No env reads, no I/O.**
4. **Repository** in `repository.go`: only thin `executeRequest` wrappers for
POST / PUT / PATCH / GET / GET search.
5. **Service** in `service.go`: validate, inject defaults (e.g. `orgID`),
call mapper, call repository.
6. **Wire in `cmd/api/main.go`** under the appropriate factory block.
7. **Add Swagger** comment block above the handler.
8. **Add unit tests** for the mapper at minimum.
+697
View File
@@ -0,0 +1,697 @@
# Flat JSON Bodies — All SatuSehat Use Cases
> Flat (non-nested) POST body for **every** FHIR resource currently
> exposed by `service-satusehat`.
>
> **Status:** ✅ **Implemented**. As of 2026-05-13, all 19 FHIR usecases +
> EpisodeOfCare + QuestionnaireResponse accept the flat shape below. Internal
> mappers compose the nested FHIR R4 payload before submission to SATUSEHAT.
> No `*DTO` types from `internal/satusehat/common` are exposed at the API
> surface anymore.
>
> Conventions used in every body below:
>
> - `*_id` carries the **raw identifier**; the mapper composes
> `Patient/{id}`, `Practitioner/{id}`, `Encounter/{id}`, etc.
> - `*_name` (or `*_display`) is the human-readable display.
> - `*_code` + `*_display` (+ optional `*_system`) replace nested
> `CodeableConceptDTO`.
> - `*_value` + `*_unit` (+ optional `*_system`, `*_code`) replace nested
> `QuantityDTO`.
> - Date-times use ISO-8601 `2026-05-13T08:00:00+07:00` (RFC 3339).
> Encounter alone accepts the simpler `YYYY-MM-DD HH:MM:SS` form for
> `period_start` / `period_end` via the project's `CustomTime` (WIB).
> - Status / class / intent values match the validator `oneof=` lists in the
> DTOs verbatim.
> - `organization_id` is optional — if omitted, the service injects
> `cfg.SatuSehat.OrgID` at the boundary.
---
## 1. Encounter
`POST /satusehat/encounter`
```json
{
"encounter_id": "ENC-0012345",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Andi",
"location_id": "a6bab5d0-ba3c-4f73-8450-f44d6ca8e9d4",
"location_name": "Poli Umum",
"status": "arrived",
"class": "AMB",
"period_start": "2026-05-13 08:00:00",
"period_end": "2026-05-13 09:30:00",
"diagnosis_condition_id": "C-0001",
"diagnosis_use_code": "AD",
"diagnosis_use_display": "Admission diagnosis",
"diagnosis_rank": 1
}
```
---
## 2. EpisodeOfCare
`POST /satusehat/episodeofcare`
```json
{
"episode_of_care_id": "EOC-12345",
"organization_id": "10000004",
"managing_organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"care_manager_id": "N10000001",
"care_manager_name": "Dr. Andi",
"status": "active",
"type_code": "HACC",
"type_display": "Home and Community Care",
"period_start": "2026-05-13T08:00:00+07:00",
"period_end": "2026-08-13T17:00:00+07:00",
"diagnosis_condition_id": "C-0001",
"diagnosis_role_code": "CC",
"diagnosis_role_display": "Chief complaint",
"diagnosis_rank": 1
}
```
---
## 3. Condition
`POST /satusehat/condition`
```json
{
"condition_id": "COND-9001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"clinical_status": "active",
"category_code": "encounter-diagnosis",
"category_display": "Encounter Diagnosis",
"code_system": "http://hl7.org/fhir/sid/icd-10",
"code": "J06.9",
"code_display": "Acute upper respiratory infection, unspecified",
"onset_date_time": "2026-05-13T08:15:00+07:00",
"recorded_date": "2026-05-13T08:20:00+07:00"
}
```
---
## 4. Observation
`POST /satusehat/observation`
```json
{
"observation_id": "OBS-7001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"encounter_id": "ENC-0012345",
"performer_id": "N10000001",
"performer_name": "Dr. Andi",
"status": "final",
"category_code": "vital-signs",
"category_display": "Vital Signs",
"code_system": "http://loinc.org",
"code": "8867-4",
"code_display": "Heart rate",
"effective_datetime": "2026-05-13T08:10:00+07:00",
"issued": "2026-05-13T08:12:00+07:00",
"value_quantity_value": 80,
"value_quantity_unit": "beats/minute",
"value_quantity_system": "http://unitsofmeasure.org",
"value_quantity_code": "/min",
"body_site_code": "40983000",
"body_site_display": "Arm",
"interpretation_code": "N",
"interpretation_display": "Normal",
"reference_range_low_value": 60,
"reference_range_high_value": 100,
"reference_range_unit": "beats/minute",
"reference_range_text": "Normal adult resting heart rate"
}
```
---
## 5. AllergyIntolerance
`POST /satusehat/allergyintolerance`
```json
{
"allergy_id": "ALG-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"encounter_display": "Poli Umum 13 Mei 2026",
"recorder_id": "N10000001",
"recorder_display": "Dr. Andi",
"clinical_status": "active",
"verification_status": "confirmed",
"category": "medication",
"code_system": "http://snomed.info/sct",
"code": "294505008",
"code_display": "Allergy to amoxicillin",
"recorded_date": "2026-05-13T08:25:00+07:00"
}
```
---
## 6. CarePlan
`POST /satusehat/careplan`
```json
{
"care_plan_id": "CP-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_display": "Budi Santoso",
"encounter_id": "ENC-0012345",
"encounter_display": "Poli Umum 13 Mei 2026",
"author_id": "N10000001",
"author_display": "Dr. Andi",
"status": "active",
"intent": "plan",
"category_code": "assess-plan",
"category_display": "Assessment and Plan of Treatment",
"title": "Rencana perawatan ISPA",
"description": "Antibiotik 5 hari, kontrol H+3",
"created_date": "2026-05-13T08:30:00+07:00",
"goal_ids": ["GOAL-001", "GOAL-002"]
}
```
---
## 7. ClinicalImpression
`POST /satusehat/clinicalimpression`
```json
{
"clinical_impression_id": "CI-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_display": "Budi Santoso",
"encounter_id": "ENC-0012345",
"encounter_display": "Poli Umum 13 Mei 2026",
"assessor_id": "N10000001",
"assessor_display": "Dr. Andi",
"status": "completed",
"code_system": "http://snomed.info/sct",
"code": "162673000",
"code_display": "General examination of patient",
"description": "Pasien sadar, demam, batuk produktif",
"effective_datetime": "2026-05-13T08:35:00+07:00",
"date": "2026-05-13T08:40:00+07:00",
"summary": "ISPA non-pneumonia",
"problem_condition_ids": ["COND-9001"],
"finding_code": "386661006",
"finding_display": "Fever",
"prognosis_code": "170968001",
"prognosis_display": "Prognosis good"
}
```
---
## 8. Composition
`POST /satusehat/composition`
```json
{
"composition_id": "COMP-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_display": "Budi Santoso",
"encounter_id": "ENC-0012345",
"encounter_display": "Poli Umum 13 Mei 2026",
"author_id": "N10000001",
"author_display": "Dr. Andi",
"status": "final",
"type_system": "http://loinc.org",
"type_code": "11488-4",
"type_display": "Consult note",
"category_code": "LP173421-1",
"category_display": "Report",
"title": "Catatan Konsultasi Poli Umum",
"date": "2026-05-13T09:00:00+07:00",
"section_title": "Anamnesis & Pemeriksaan",
"section_code": "55109-3",
"section_display": "Reason for visit Narrative",
"section_text": "Pasien datang dengan keluhan batuk dan demam selama 3 hari."
}
```
---
## 9. DiagnosticReport
`POST /satusehat/diagnosticreport`
```json
{
"diagnostic_id": "DR-5001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"encounter_id": "ENC-0012345",
"performer_id": "N10000001",
"performer_name": "Dr. Andi",
"status": "final",
"category_code": "LAB",
"category_display": "Laboratory",
"code_system": "http://loinc.org",
"code": "58410-2",
"code_display": "Complete blood count (hemogram) panel",
"effective_datetime": "2026-05-13T09:10:00+07:00",
"issued": "2026-05-13T09:30:00+07:00",
"result_observation_ids": ["OBS-7001", "OBS-7002"],
"specimen_ids": ["SPC-3001"],
"imaging_study_ids": [],
"conclusion_code": "162673000",
"conclusion_display": "General examination of patient",
"conclusion": "Hasil dalam batas normal"
}
```
---
## 10. ImagingStudy
`POST /satusehat/imagingstudy`
```json
{
"accession_number": "ACC-0001",
"organization_id": "10000004",
"service_request_id": "SR-0001",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Andi",
"status": "available",
"started": "2026-05-13T09:00:00+07:00",
"number_of_series": 1,
"number_of_instances": 24,
"procedure_code": "168731009",
"procedure_display": "Chest X-ray",
"description": "Thorax PA"
}
```
---
## 11. Immunization
`POST /satusehat/immunization`
```json
{
"immunization_id": "IMM-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"encounter_display": "Poli Umum 13 Mei 2026",
"performer_id": "N10000001",
"performer_name": "Dr. Andi",
"location_id": "a6bab5d0-ba3c-4f73-8450-f44d6ca8e9d4",
"location_name": "Poli Umum",
"status": "completed",
"vaccine_code_system": "http://sys-ids.kemkes.go.id/vaccine",
"vaccine_code": "1010101010",
"vaccine_display": "Sinovac",
"occurrence_date_time": "2026-05-13T09:05:00+07:00",
"primary_source": true,
"lot_number": "BATCH-XYZ-2026-001",
"dose_quantity_value": 0.5,
"dose_quantity_unit": "mL",
"dose_quantity_system": "http://unitsofmeasure.org",
"dose_quantity_code": "mL",
"route_code": "IM",
"route_display": "Intramuscular"
}
```
---
## 12. Medication
`POST /satusehat/medication`
```json
{
"medication_id": "MED-0001",
"organization_id": "10000004",
"manufacturer_id": "100099999",
"status_code": "active",
"kfa_code": "92000001",
"kfa_display": "Paracetamol 500 mg tablet",
"form_code": "TAB",
"form_display": "Tablet",
"batch_number": "BTC-XYZ-2026-09",
"expiration_date": "2027-12-31T00:00:00+07:00"
}
```
---
## 13. MedicationDispense
`POST /satusehat/medicationdispense`
```json
{
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"practitioner_id": "N10000001",
"practitioner_name": "Apt. Sari",
"location_id": "a6bab5d0-ba3c-4f73-8450-f44d6ca8e9d4",
"location_name": "Apotek Rawat Jalan",
"medication_id": "MED-0001",
"medication_display": "Paracetamol 500 mg tablet",
"medication_request_id": "MR-0001",
"prescription_id": "RX-2026-000123",
"prescription_item_id": "RX-2026-000123-1",
"status": "completed",
"category": "outpatient",
"prepared_date": "2026-05-13 09:40:00",
"handed_over_date": "2026-05-13 09:45:00",
"quantity_value": 10,
"quantity_unit": "TAB",
"days_supply_value": 5,
"dosage_text": "1 tablet, 3x sehari sesudah makan",
"timing_frequency": 3,
"timing_period": 1,
"timing_period_unit": "d",
"dose_quantity_value": 1,
"dose_quantity_unit": "TAB"
}
```
---
## 14. MedicationRequest
`POST /satusehat/medicationrequest`
```json
{
"medicationrequest_id": "MR-0001",
"prescription_item_id": "RX-2026-000123-1",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Andi",
"medication_id": "MED-0001",
"medication_display": "Paracetamol 500 mg tablet",
"category": "outpatient",
"priority": "routine",
"status": "active",
"intent": "order",
"authored_on": "2026-05-13T09:35:00+07:00",
"reason_code": "J06.9",
"reason_display": "Acute upper respiratory infection",
"course_of_therapy_code": "acute",
"course_of_therapy_display": "Short course (acute) therapy",
"dosage_text": "1 tablet, 3x sehari sesudah makan",
"additional_instruction": "Habiskan",
"patient_instruction": "Minum dengan air putih",
"timing_frequency": 3,
"timing_period": 1,
"timing_period_unit": "d",
"route_code": "O",
"route_display": "Oral",
"dose_quantity_value": 1,
"dose_quantity_unit": "TAB",
"dispense_interval": 0,
"dispense_value": 15,
"dispense_unit": "TAB",
"supply_duration": 5,
"validity_period_start": "2026-05-13T09:35:00+07:00",
"validity_period_end": "2026-05-20T23:59:59+07:00"
}
```
---
## 15. MedicationStatement
`POST /satusehat/medicationstatement`
```json
{
"statement_id": "MS-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"information_source_id": "P03647103112",
"information_source_name": "Budi Santoso",
"status": "active",
"category_code": "outpatient",
"category_display": "Outpatient",
"medication_code_system": "http://sys-ids.kemkes.go.id/kfa",
"medication_code": "92000001",
"medication_display": "Paracetamol 500 mg tablet",
"effective_date_time": "2026-05-13T09:35:00+07:00",
"date_asserted": "2026-05-13T09:36:00+07:00",
"dosage_text": "1 tablet, 3x sehari sesudah makan",
"dosage_patient_instruction": "Minum dengan air putih",
"dosage_route_code": "O",
"dosage_route_display": "Oral",
"dose_quantity_value": 1,
"dose_quantity_unit": "TAB"
}
```
---
## 16. Procedure
`POST /satusehat/procedure`
```json
{
"procedure_id": "PROC-0001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"encounter_id": "ENC-0012345",
"performer_id": "N10000001",
"performer_name": "Dr. Andi",
"status": "completed",
"category_code": "103693007",
"category_display": "Diagnostic procedure",
"code_system": "http://hl7.org/fhir/sid/icd-9-cm",
"code": "89.61",
"code_display": "Continuous blood gas monitoring",
"performed_date_time": "2026-05-13T09:15:00+07:00",
"performed_start": "2026-05-13T09:15:00+07:00",
"performed_end": "2026-05-13T09:25:00+07:00",
"reason_code": "J06.9",
"reason_display": "Acute upper respiratory infection",
"body_site_code": "302551006",
"body_site_display": "Entire thorax",
"note": "Pasien kooperatif, prosedur selesai tanpa komplikasi"
}
```
---
## 17. QuestionnaireResponse
`POST /satusehat/questionnaireresponse`
```json
{
"questionnaire_response_id": "QR-0001",
"organization_id": "10000004",
"questionnaire_url": "https://fhir.kemkes.go.id/Questionnaire/Q0007",
"patient_id": "P03647103112",
"encounter_id": "ENC-0012345",
"author_id": "N10000001",
"author_name": "Dr. Andi",
"source_id": "P03647103112",
"source_name": "Budi Santoso",
"status": "completed",
"authored": "2026-05-13T09:00:00+07:00",
"items": [
{
"link_id": "1",
"text": "Apakah Anda merokok?",
"answer_boolean": false
},
{
"link_id": "2",
"text": "Berapa suhu tubuh Anda hari ini?",
"answer_quantity_value": 38.2,
"answer_quantity_unit": "Cel"
}
]
}
```
> Note: `items` remains an array because QuestionnaireResponse is
> intrinsically list-shaped — but each item is itself flat.
---
## 18. ServiceRequest
`POST /satusehat/servicerequest`
```json
{
"organization_id": "10000004",
"patient_id": "P03647103112",
"patient_name": "Budi Santoso",
"encounter_id": "ENC-0012345",
"requester_id": "N10000001",
"requester_name": "Dr. Andi",
"performer_id": "N10000099",
"performer_name": "Lab Pathology Unit",
"status": "active",
"intent": "order",
"code": "58410-2",
"display": "Complete blood count panel",
"authored_on": "2026-05-13T09:05:00+07:00"
}
```
---
## 19. Specimen
`POST /satusehat/specimen`
```json
{
"specimen_id": "SPC-3001",
"organization_id": "10000004",
"patient_id": "P03647103112",
"status": "available",
"type_system": "http://terminology.hl7.org/CodeSystem/v2-0487",
"type_code": "BLD",
"type_display": "Whole blood",
"received_date_time": "2026-05-13T09:20:00+07:00",
"collected_date_time": "2026-05-13T09:10:00+07:00",
"collector_id": "N10000001",
"collector_name": "Perawat Sari",
"collection_quantity_value": 5,
"collection_quantity_unit": "mL",
"collection_method_code": "129316008",
"collection_method_display": "Aspiration - action",
"body_site_code": "368208006",
"body_site_display": "Left upper arm",
"fasting_status_code": "F",
"fasting_status_display": "Fasting",
"processing_procedure_code": "9718006",
"processing_procedure_display": "Centrifugation",
"processing_time_datetime": "2026-05-13T09:25:00+07:00",
"conditions": ["refrigerated"],
"request_service_request_ids": ["SR-0001"]
}
```
---
## 20. Studies (DICOM upload)
The `studies` module today is a thin DICOM tarball uploader and does not
take a structured body. The recommended request is `multipart/form-data`:
```http
POST /satusehat/dicom/studies/upload
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXyz
------WebKitFormBoundaryXyz
Content-Disposition: form-data; name="organization_id"
10000004
------WebKitFormBoundaryXyz
Content-Disposition: form-data; name="patient_id"
P03647103112
------WebKitFormBoundaryXyz
Content-Disposition: form-data; name="accession_number"
ACC-0001
------WebKitFormBoundaryXyz
Content-Disposition: form-data; name="file"; filename="study.tar.gz"
Content-Type: application/gzip
<binary DICOM tarball>
------WebKitFormBoundaryXyz--
```
If a JSON metadata-only endpoint is added later, the flat shape would be:
```json
{
"organization_id": "10000004",
"patient_id": "P03647103112",
"accession_number": "ACC-0001",
"study_instance_uid": "1.2.840.113619.2.5.1762583153.1762583153.1762583153.1",
"modality": "CR",
"started": "2026-05-13T09:00:00+07:00",
"description": "Thorax PA"
}
```
---
## Implementation notes
All 20 usecases now share the same internal pattern:
1. **`dto.go`** — pure flat struct with `binding:"required,..."` tags only on
scalar fields. No `common.ReferenceDTO` / `common.CodeableConceptDTO`
imports at the request boundary.
2. **`mapper.go`** — `MapRequestToFHIR(req)` builds the nested FHIR R4
payload. Mapper is pure: no env reads, no I/O.
3. **`repository.go`** — thin wrapper over `SatuSehatClient.DoRequest`. The
old `hiddenCtx` wrapper has been removed; `context.Context` flows through
unchanged for proper deadline / cancellation propagation.
4. **`service.go`** — `NewService(repo, orgID string)` takes the default
organisation identifier from `cfg.SatuSehat.OrgID` at boot. When a caller
omits `organization_id` in the request body, the service fills it in.
The full audit trail is in `docs/DEVLOG.md`.
## Field-shape cheat-sheet (nested → flat)
| Nested FHIR field | Flat replacement |
| --- | --- |
| `subject.reference = "Patient/X"` | `patient_id = "X"` (+ optional `patient_name`) |
| `encounter.reference = "Encounter/X"` | `encounter_id = "X"` (+ `encounter_display`) |
| `performer[].reference = "Practitioner/X"` | `performer_id = "X"` (+ `performer_name`) |
| `location[].location.reference = "Location/X"` | `location_id = "X"` (+ `location_name`) |
| `managingOrganization.reference = "Organization/X"` | `managing_organization_id = "X"` |
| `code.system / .code / .display` | `code_system / code / code_display` |
| `category.code / .display` | `category_code / category_display` |
| `valueQuantity.value / .unit / .system / .code` | `value_quantity_value / _unit / _system / _code` |
| `period.start / .end` | `period_start / period_end` |
| `dosage.route.code` | `route_code` / `dosage_route_code` |
When a list of references exists (e.g. `result_observation_ids`,
`specimen_ids`), keep it as a flat array of IDs — the mapper turns them
into `["Observation/{id}", …]`.