Files
satusehat-service/docs/analysis.qmd
T

521 lines
21 KiB
Plaintext

---
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.