penambahan All case Satu sehat

This commit is contained in:
meninjar
2026-05-04 03:48:43 +00:00
parent 135c631021
commit af32d9cfdd
127 changed files with 617698 additions and 1168 deletions
+4 -3
View File
@@ -62,9 +62,10 @@ func (s *service) Login(ctx context.Context, req LoginRequest) (*LoginResponse,
}
dummyUsers := map[string]dummyUser{
"[email protected]": {"password123", "1001", "admin", "Admin Dummy"},
"[email protected]": {"password123", "1002", "user", "User Dummy"},
"[email protected]": {"password123", "1003", "manager", "Manager Dummy"},
"[email protected]": {"password123", "1001", "admin", "Admin Dummy"},
"[email protected]": {"password123", "1002", "user", "User Dummy"},
"[email protected]": {"password123", "1003", "manager", "Manager Dummy"},
"[email protected]": {"password123", "1004", "worker", "Worker Dummy"},
}
user, exists := dummyUsers[req.Email]
+30 -20
View File
@@ -124,16 +124,21 @@ type BpjsConfig struct {
}
type SatuSehatConfig struct {
Enabled bool `mapstructure:"enabled"`
OrgID string `mapstructure:"org_id"`
FasyakesID string `mapstructure:"fasyakes_id"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
AuthURL string `mapstructure:"auth_url"`
BaseURL string `mapstructure:"base_url"`
ConsentURL string `mapstructure:"consent_url"`
KFAURL string `mapstructure:"kfa_url"`
Timeout time.Duration `mapstructure:"timeout"`
Enabled bool `mapstructure:"enabled"`
OrgID string `mapstructure:"org_id"`
FasyakesID string `mapstructure:"fasyakes_id"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
AuthURL string `mapstructure:"auth_url"`
BaseURL string `mapstructure:"base_url"`
ConsentURL string `mapstructure:"consent_url"`
KFAURL string `mapstructure:"kfa_url"`
KYCURL string `mapstructure:"kyc_url"`
DicomURL string `mapstructure:"dicom_url"`
KYCPublicKeyB64 string `mapstructure:"kyc_public_key_b64"`
KYCPrivateKeyB64 string `mapstructure:"kyc_private_key_b64"`
WebhookSecret string `mapstructure:"webhook_secret"`
Timeout time.Duration `mapstructure:"timeout"`
}
type SwaggerConfig struct {
@@ -271,16 +276,21 @@ func LoadConfig() *Config {
Timeout: parseDuration(getEnv("BPJS_TIMEOUT", "30s")),
},
SatuSehat: SatuSehatConfig{
Enabled: getEnvAsBool("SATUSEHAT_ENABLED", getEnvAsBool("SATU_SEHAT_ENABLED", false)),
OrgID: getEnv("SATUSEHAT_ORG_ID", getEnv("BRIDGING_SATUSEHAT_ORG_ID", "")),
FasyakesID: getEnv("SATUSEHAT_FASYAKES_ID", getEnv("BRIDGING_SATUSEHAT_FASYAKES_ID", "")),
ClientID: getEnv("SATUSEHAT_CLIENT_ID", getEnv("BRIDGING_SATUSEHAT_CLIENT_ID", "")),
ClientSecret: getEnv("SATUSEHAT_CLIENT_SECRET", getEnv("BRIDGING_SATUSEHAT_CLIENT_SECRET", "")),
AuthURL: getEnv("SATUSEHAT_AUTH_URL", getEnv("BRIDGING_SATUSEHAT_AUTH_URL", "https://api-satusehat.kemkes.go.id/oauth2/v1")),
BaseURL: getEnv("SATUSEHAT_BASE_URL", getEnv("BRIDGING_SATUSEHAT_BASE_URL", "https://api-satusehat.kemkes.go.id/fhir-r4/v1")),
ConsentURL: getEnv("SATUSEHAT_CONSENT_URL", getEnv("BRIDGING_SATUSEHAT_CONSENT_URL", "https://api-satusehat.dto.kemkes.go.id/consent/v1")),
KFAURL: getEnv("SATUSEHAT_KFA_URL", getEnv("BRIDGING_SATUSEHAT_KFA_URL", "https://api-satusehat.kemkes.go.id/kfa-v2")),
Timeout: parseDuration(getEnv("SATUSEHAT_TIMEOUT", getEnv("BRIDGING_SATUSEHAT_TIMEOUT", "30s"))),
Enabled: getEnvAsBool("SATUSEHAT_ENABLED", getEnvAsBool("SATU_SEHAT_ENABLED", false)),
OrgID: getEnv("SATUSEHAT_ORG_ID", getEnv("BRIDGING_SATUSEHAT_ORG_ID", "")),
FasyakesID: getEnv("SATUSEHAT_FASYAKES_ID", getEnv("BRIDGING_SATUSEHAT_FASYAKES_ID", "")),
ClientID: getEnv("SATUSEHAT_CLIENT_ID", getEnv("BRIDGING_SATUSEHAT_CLIENT_ID", "")),
ClientSecret: getEnv("SATUSEHAT_CLIENT_SECRET", getEnv("BRIDGING_SATUSEHAT_CLIENT_SECRET", "")),
AuthURL: getEnv("SATUSEHAT_AUTH_URL", getEnv("BRIDGING_SATUSEHAT_AUTH_URL", "https://api-satusehat.kemkes.go.id/oauth2/v1")),
BaseURL: getEnv("SATUSEHAT_BASE_URL", getEnv("BRIDGING_SATUSEHAT_BASE_URL", "https://api-satusehat.kemkes.go.id/fhir-r4/v1")),
ConsentURL: getEnv("SATUSEHAT_CONSENT_URL", getEnv("BRIDGING_SATUSEHAT_CONSENT_URL", "https://api-satusehat.dto.kemkes.go.id/consent/v1")),
KFAURL: getEnv("SATUSEHAT_KFA_URL", getEnv("BRIDGING_SATUSEHAT_KFA_URL", "https://api-satusehat.kemkes.go.id/kfa-v2")),
KYCURL: getEnv("SATUSEHAT_KYC_URL", getEnv("BRIDGING_SATUSEHAT_KYC_URL", "https://api-satusehat.dto.kemkes.go.id/kyc/v1")),
DicomURL: getEnv("SATUSEHAT_DICOM_URL", getEnv("BRIDGING_SATUSEHAT_DICOM_URL", "https://api-satusehat.kemkes.go.id/dicom/v1/dicomWeb/studies")),
KYCPublicKeyB64: getEnv("KYC_PUBLIC_KEY_B64", ""),
KYCPrivateKeyB64: getEnv("KYC_PRIVATE_KEY_B64", ""),
WebhookSecret: getEnv("SATUSEHAT_WEBHOOK_SECRET", getEnv("BRIDGING_SATUSEHAT_WEBHOOK_SECRET", "")),
Timeout: parseDuration(getEnv("SATUSEHAT_TIMEOUT", getEnv("BRIDGING_SATUSEHAT_TIMEOUT", "30s"))),
},
Swagger: SwaggerConfig{
Title: getEnv("SWAGGER_TITLE", "SERVICE API"),
@@ -0,0 +1,122 @@
package reference
import (
"encoding/base64"
"io"
"net/http"
"strings"
"service/internal/infrastructure/transport/http/middleware"
"service/internal/satusehat/reference/kyc"
"service/pkg/crypto"
"service/pkg/response"
"github.com/gin-gonic/gin"
)
type KYCHandler struct {
service kyc.Service
}
func NewKYCHandler(service kyc.Service) *KYCHandler {
return &KYCHandler{
service: service,
}
}
// GenerateKeys godoc
//
// @Summary Generate Static RSA Keys for KYC
// @Description Menghasilkan pasangan kunci RSA 2048-bit dalam format Base64 untuk disimpan di .env (KYC_PRIVATE_KEY_B64 & KYC_PUBLIC_KEY_B64)
// @Tags Satu Sehat - KYC
// @Produce json
// @Success 200 {object} response.Response
// @Router /satusehat/reference/kyc/generate-keys [get]
func (h *KYCHandler) GenerateKeys(c *gin.Context) {
privPEM, pubPEM, err := crypto.GenerateRSAKeyPair(2048)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Gagal membuat RSA Keypair", err.Error())
return
}
responseData := map[string]interface{}{
"instructions": "Salin nilai di bawah ini dan masukkan ke dalam file .env Anda",
"KYC_PRIVATE_KEY_B64": base64.StdEncoding.EncodeToString([]byte(privPEM)),
"KYC_PUBLIC_KEY_B64": base64.StdEncoding.EncodeToString([]byte(pubPEM)),
}
response.Success(c, http.StatusOK, "Berhasil membuat kunci RSA Statis", responseData)
}
// GenerateURL godoc
//
// @Summary Generate KYC URL
// @Description Membuat link validasi KYC Satu Sehat
// @Tags Satu Sehat - KYC
// @Accept json
// @Produce json
// @Param request body kyc.GenerateURLRequest true "Payload KYC"
// @Success 200 {object} response.Response
// @Router /satusehat/reference/kyc/generate-url [post]
// @Security BearerAuth
func (h *KYCHandler) GenerateURL(c *gin.Context) {
var req kyc.GenerateURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "Format request tidak valid", err.Error())
return
}
// NOTE: Public Key statis otomatis disisipkan oleh layer Service yang membaca dari konfigurasi .env
data, err := h.service.GenerateURL(c.Request.Context(), req)
if err != nil {
handleSatuSehatError(c, err)
return
}
responseData := map[string]interface{}{
"kyc_data": data,
}
response.Success(c, http.StatusOK, "Berhasil membuat link KYC", responseData)
}
// Callback godoc
//
// @Summary Webhook/Callback KYC
// @Description Menerima notifikasi dari Satu Sehat setelah proses KYC selesai
// @Tags Satu Sehat - KYC
// @Accept text/plain
// @Produce json
// @Param request body string true "Payload Webhook Terenkripsi (-----BEGIN ENCRYPTED MESSAGE-----...)"
// @Success 200 {object} response.Response
// @Router /satusehat/reference/kyc/callback [post]
func (h *KYCHandler) Callback(c *gin.Context) {
// Ekstrak token dari header Authorization (Format: "Bearer <token>")
authHeader := c.GetHeader("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
// Payload dari webhook Kemenkes berupa string terenkripsi (bukan JSON langsung)
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
response.Error(c, http.StatusBadRequest, "Gagal membaca body request", err.Error())
return
}
encryptedPayload := string(bodyBytes)
if err := h.service.HandleCallback(c.Request.Context(), encryptedPayload, token); err != nil {
handleSatuSehatError(c, err)
return
}
response.Success(c, http.StatusOK, "Callback berhasil diproses", nil)
}
func (h *KYCHandler) RegisterRoutes(router *gin.RouterGroup) {
group := router.Group("/satusehat/reference/kyc")
{
group.GET("/generate-keys", h.GenerateKeys)
group.POST("/generate-url", h.GenerateURL)
// Rate limit khusus untuk endpoint callback: max 2 Request/Detik, Burst Limit 5
group.POST("/callback", middleware.MemoryRateLimitMiddleware(2.0, 5), h.Callback)
}
}
@@ -25,7 +25,7 @@ func (h *CarePlanHandler) RegisterRoutes(router *gin.RouterGroup) {
func (h *CarePlanHandler) Create(c *gin.Context) {
var req careplan.CarePlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithLog(c, err, http.StatusBadRequest, "Invalid request", validator.TranslateError(err))
response.ErrorWithLog(c, err, http.StatusBadRequest, "Format permintaan tidak valid", validator.TranslateError(err))
return
}
res, err := h.service.Create(c.Request.Context(), req)
@@ -51,7 +51,7 @@ func (h *CarePlanHandler) Update(c *gin.Context) {
func (h *CarePlanHandler) Patch(c *gin.Context) {
var req careplan.CarePlanPatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithLog(c, err, http.StatusBadRequest, "Invalid patch", validator.TranslateError(err))
response.ErrorWithLog(c, err, http.StatusBadRequest, "Format permintaan patch tidak valid", validator.TranslateError(err))
return
}
res, err := h.service.Patch(c.Request.Context(), c.Param("id"), req)
@@ -27,7 +27,7 @@ func (h *DiagnosticReportHandler) RegisterRoutes(router *gin.RouterGroup) {
func (h *DiagnosticReportHandler) Create(c *gin.Context) {
var req diagnosticreport.DiagnosticReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithLog(c, err, http.StatusBadRequest, "Invalid request", validator.TranslateError(err))
response.ErrorWithLog(c, err, http.StatusBadRequest, "Format payload JSON tidak valid", validator.TranslateError(err))
return
}
res, err := h.service.Create(c.Request.Context(), req)
@@ -40,7 +40,7 @@ func (h *DiagnosticReportHandler) Create(c *gin.Context) {
func (h *DiagnosticReportHandler) Update(c *gin.Context) {
var req diagnosticreport.DiagnosticReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithLog(c, err, http.StatusBadRequest, "Invalid request", validator.TranslateError(err))
response.ErrorWithLog(c, err, http.StatusBadRequest, "Format payload JSON tidak valid", validator.TranslateError(err))
return
}
res, err := h.service.Update(c.Request.Context(), c.Param("id"), req)
@@ -53,7 +53,7 @@ func (h *DiagnosticReportHandler) Update(c *gin.Context) {
func (h *DiagnosticReportHandler) Patch(c *gin.Context) {
var req diagnosticreport.DiagnosticReportPatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithLog(c, err, http.StatusBadRequest, "Invalid patch", validator.TranslateError(err))
response.ErrorWithLog(c, err, http.StatusBadRequest, "Format patch payload tidak valid", validator.TranslateError(err))
return
}
res, err := h.service.Patch(c.Request.Context(), c.Param("id"), req)
@@ -0,0 +1,60 @@
package usecase
import (
"io"
"net/http"
"service/internal/satusehat/usecase/studies"
"service/pkg/response"
"github.com/gin-gonic/gin"
)
type DicomStudiesHandler struct {
service studies.Service
}
func NewDicomStudiesHandler(service studies.Service) *DicomStudiesHandler {
return &DicomStudiesHandler{
service: service,
}
}
func (h *DicomStudiesHandler) RegisterRoutes(router *gin.RouterGroup) {
// URL akan menjadi: POST /api/v1/satusehat/dicom/studies
group := router.Group("/satusehat/dicom/studies")
{
group.POST("", h.Upload)
}
}
func (h *DicomStudiesHandler) Upload(c *gin.Context) {
// Ambil file dari request FormData dengan key "file"
file, err := c.FormFile("file")
if err != nil {
response.Error(c, http.StatusBadRequest, "File DICOM tidak ditemukan pada form-data key 'file'", err.Error())
return
}
openedFile, err := file.Open()
if err != nil {
response.Error(c, http.StatusInternalServerError, "Gagal membuka file DICOM sementara", err.Error())
return
}
defer openedFile.Close()
dicomBytes, err := io.ReadAll(openedFile)
if err != nil {
response.Error(c, http.StatusInternalServerError, "Gagal membaca byte dari file DICOM", err.Error())
return
}
result, err := h.service.UploadDICOM(c.Request.Context(), dicomBytes)
if err != nil {
// Fungsi ini berasal dari encounter_handler.go di package yang sama
handleSatuSehatError(c, err)
return
}
response.Success(c, http.StatusOK, "Berhasil mengunggah file DICOM ke SatuSehat STOW-RS", result.FullResponse)
}
@@ -3,7 +3,6 @@ package usecase
import (
"errors"
"net/http"
"strconv"
"time"
"service/internal/interfaces/satusehat"
@@ -29,7 +28,7 @@ func (h *EncounterHandler) RegisterRoutes(router *gin.RouterGroup) {
group := router.Group("/satusehat/encounter")
{
group.POST("", h.Create)
group.POST("/sync/:idxdaftar", h.SyncFromSIMRS)
// group.POST("/sync/:idxdaftar", h.SyncFromSIMRS)
group.GET("", h.Search)
group.GET("/:id", h.GetByID)
group.PUT("/:id", h.Update)
@@ -132,18 +131,18 @@ func (h *EncounterHandler) Search(c *gin.Context) {
response.Success(c, http.StatusOK, "Successfully retrieved Encounters", result.FullResponse)
}
func (h *EncounterHandler) SyncFromSIMRS(c *gin.Context) {
idxdaftarStr := c.Param("idxdaftar")
idxdaftar, err := strconv.ParseInt(idxdaftarStr, 10, 64)
if err != nil {
response.ErrorWithLog(c, err, http.StatusBadRequest, "Format idxdaftar tidak valid", nil)
return
}
// func (h *EncounterHandler) SyncFromSIMRS(c *gin.Context) {
// idxdaftarStr := c.Param("idxdaftar")
// idxdaftar, err := strconv.ParseInt(idxdaftarStr, 10, 64)
// if err != nil {
// response.ErrorWithLog(c, err, http.StatusBadRequest, "Format idxdaftar tidak valid", nil)
// return
// }
result, err := h.service.SyncFromSIMRS(c.Request.Context(), idxdaftar)
if err != nil {
handleSatuSehatError(c, err)
return
}
response.Success(c, http.StatusOK, "Successfully synced Encounter from SIMRS", result.FullResponse)
}
// result, err := h.service.SyncFromSIMRS(c.Request.Context(), idxdaftar)
// if err != nil {
// handleSatuSehatError(c, err)
// return
// }
// response.Success(c, http.StatusOK, "Successfully synced Encounter from SIMRS", result.FullResponse)
// }
@@ -32,6 +32,7 @@ type ModuleHandlers struct {
SatuSehatOrganization *satuSehatRefHttp.OrganizationHandler
SatuSehatLocation *satuSehatRefHttp.LocationHandler
SatuSehatKFA *satuSehatRefHttp.KFAHandler
SatuSehatKYC *satuSehatRefHttp.KYCHandler
SSAllergyIntolerance *satuSehatUsecaseHttp.AllergyIntoleranceHandler
SSCarePlan *satuSehatUsecaseHttp.CarePlanHandler
SSClinicalImpression *satuSehatUsecaseHttp.ClinicalImpressionHandler
@@ -51,6 +52,7 @@ type ModuleHandlers struct {
SSQuestionnaireResponse *satuSehatUsecaseHttp.QuestionnaireResponseHandler
SSServiceRequest *satuSehatUsecaseHttp.ServiceRequestHandler
SSSpecimen *satuSehatUsecaseHttp.SpecimenHandler
SSDicomStudies *satuSehatUsecaseHttp.DicomStudiesHandler
}
// SetupRoutes mendaftarkan seluruh endpoint API secara dinamis berdasarkan
@@ -128,7 +130,7 @@ func SetupRoutes(
// Private routes (Membutuhkan Autentikasi)
protected := v1.Group("")
protected.Use(middleware.AuthMiddleware(cfg, cacheManager))
protected.Use(middleware.AuthMiddleware(cfg, cacheManager)) // Di-comment sementara untuk proses development/testing
{
if h.Auth != nil {
h.Auth.RegisterProtectedRoutes(protected)
@@ -168,6 +170,9 @@ func SetupRoutes(
if h.SatuSehatKFA != nil {
h.SatuSehatKFA.RegisterRoutes(satuSehatGroup)
}
if h.SatuSehatKYC != nil {
h.SatuSehatKYC.RegisterRoutes(satuSehatGroup)
}
if h.SSAllergyIntolerance != nil {
h.SSAllergyIntolerance.RegisterRoutes(satuSehatGroup)
}
@@ -225,6 +230,9 @@ func SetupRoutes(
if h.SSSpecimen != nil {
h.SSSpecimen.RegisterRoutes(satuSehatGroup)
}
if h.SSDicomStudies != nil {
h.SSDicomStudies.RegisterRoutes(satuSehatGroup)
}
}
}
}
@@ -13,6 +13,7 @@ import (
rolePermission "service/internal/master/role/permission"
satuSehatAuth "service/internal/satusehat/reference/auth"
satuSehatKfa "service/internal/satusehat/reference/kfa"
"service/internal/satusehat/reference/kyc"
satuSehatLocation "service/internal/satusehat/reference/location"
satuSehatOrganization "service/internal/satusehat/reference/organization"
satuSehatPatient "service/internal/satusehat/reference/patient"
@@ -36,6 +37,7 @@ import (
"service/internal/satusehat/usecase/questionnaireresponse"
"service/internal/satusehat/usecase/servicerequest"
"service/internal/satusehat/usecase/specimen"
"service/internal/satusehat/usecase/studies"
)
// MasterServices menampung kumpulan service untuk domain Master Data
@@ -53,6 +55,7 @@ type SatuSehatServices struct {
Organization satuSehatOrganization.Service
Location satuSehatLocation.Service
KFA satuSehatKfa.Service
KYC kyc.Service
// Usecase Services
AllergyIntolerance allergyintolerance.Service
@@ -74,6 +77,7 @@ type SatuSehatServices struct {
QuestionnaireResponse questionnaireresponse.Service
ServiceRequest servicerequest.Service
Specimen specimen.Service
DicomStudies studies.Service
}
// ServiceRegistry berfungsi sebagai Dependency Injection Container untuk transport HTTP.
@@ -104,6 +104,9 @@ func NewHTTPServer(
if registry.SatuSehat.KFA != nil {
appHandlers.SatuSehatKFA = satuSehatRefHttp.NewKFAHandler(registry.SatuSehat.KFA)
}
if registry.SatuSehat.KYC != nil {
appHandlers.SatuSehatKYC = satuSehatRefHttp.NewKYCHandler(registry.SatuSehat.KYC)
}
// Inisialisasi Handlers Modul Satu Sehat Usecase
if registry.SatuSehat.AllergyIntolerance != nil {
@@ -163,6 +166,9 @@ func NewHTTPServer(
if registry.SatuSehat.Specimen != nil {
appHandlers.SSSpecimen = satuSehatUsecaseHttp.NewSpecimenHandler(registry.SatuSehat.Specimen)
}
if registry.SatuSehat.DicomStudies != nil {
appHandlers.SSDicomStudies = satuSehatUsecaseHttp.NewDicomStudiesHandler(registry.SatuSehat.DicomStudies)
}
}
// Inisialisasi Health Handler dengan dependensi yang benar
+116 -14
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"strconv"
"strings"
@@ -88,6 +90,16 @@ func (c *client) GetAccessToken(ctx context.Context) (map[string]interface{}, er
if resp.StatusCode != http.StatusOK {
logger.Default().Error("SatuSehat Auth Error", logger.Int("status", resp.StatusCode), logger.String("response", string(respBody)))
// Coba parse response body sebagai FHIR OperationOutcome untuk meneruskan status code asli ke client
var outcome map[string]interface{}
if err := json.Unmarshal(respBody, &outcome); err == nil && outcome["resourceType"] == "OperationOutcome" {
return nil, &ErrorOperationOutcome{
StatusCode: resp.StatusCode,
Outcome: outcome,
}
}
return nil, fmt.Errorf("SatuSehat Auth failed with status %d: %s", resp.StatusCode, string(respBody))
}
@@ -153,21 +165,35 @@ func (c *client) do(ctx context.Context, baseURL, method, endpoint string, body
endpointPath := strings.TrimLeft(endpoint, "/")
targetURL := fmt.Sprintf("%s/%s", baseURL, endpointPath)
// Marshal body terlebih dahulu agar bisa digunakan berulang kali jika butuh retry
var jsonBody []byte
// Identifikasi dan format body berdasarkan tipenya (string/plaintext atau struct/JSON)
var reqBody []byte
var contentType string
if body != nil {
var err error
jsonBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
switch v := body.(type) {
case string:
// Jika body berupa string murni (seperti encrypted payload KYC), jangan di-marshal JSON
reqBody = []byte(v)
contentType = "text/plain"
default:
var err error
reqBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
if method == "PATCH" {
contentType = "application/json-patch+json"
} else {
contentType = "application/json"
}
}
}
// Helper function untuk merakit ulang request (sangat berguna untuk skenario Retry)
buildReq := func(accessToken string) (*http.Request, error) {
var bodyReader io.Reader
if jsonBody != nil {
bodyReader = bytes.NewBuffer(jsonBody)
if reqBody != nil {
bodyReader = bytes.NewBuffer(reqBody)
}
req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
if err != nil {
@@ -175,12 +201,8 @@ func (c *client) do(ctx context.Context, baseURL, method, endpoint string, body
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
if jsonBody != nil {
if method == "PATCH" {
req.Header.Set("Content-Type", "application/json-patch+json")
} else {
req.Header.Set("Content-Type", "application/json")
}
if reqBody != nil {
req.Header.Set("Content-Type", contentType)
}
return req, nil
}
@@ -272,3 +294,83 @@ func (c *client) DoKFA(ctx context.Context, method, endpoint string, body interf
func (c *client) DoConsent(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
return c.do(ctx, c.cfg.ConsentURL, method, endpoint, body)
}
func (c *client) DoKYC(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error) {
return c.do(ctx, c.cfg.KYCURL, method, endpoint, body)
}
// UploadDICOM mengunggah file biner .dcm langsung ke SatuSehat STOW-RS endpoint
func (c *client) UploadDICOM(ctx context.Context, dicomBytes []byte) ([]byte, error) {
if !c.cfg.Enabled {
return nil, fmt.Errorf("SatuSehat integration is disabled")
}
authData, err := c.GetAccessToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get SatuSehat access token: %w", err)
}
token, _ := authData["access_token"].(string)
// 1. Buat body multipart
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 2. Buat header part khusus untuk DICOM (Kemenkes mewajibkan application/dicom)
h := make(textproto.MIMEHeader)
h.Set("Content-Type", "application/dicom")
part, err := writer.CreatePart(h)
if err != nil {
return nil, fmt.Errorf("gagal membuat multipart frame: %w", err)
}
// 3. Tulis biner DICOM ke dalam part
if _, err := part.Write(dicomBytes); err != nil {
return nil, fmt.Errorf("gagal menulis biner DICOM: %w", err)
}
writer.Close()
// 4. Siapkan HTTP Request
// NOTE: Content-Type untuk STOW-RS BUKAN multipart/form-data, TAPI multipart/related
contentType := fmt.Sprintf(`multipart/related; type="application/dicom"; boundary=%s`, writer.Boundary())
req, err := http.NewRequestWithContext(ctx, "POST", c.cfg.DicomURL, body)
if err != nil {
return nil, fmt.Errorf("failed to create DICOM request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/dicom+json") // Format balasan Kemenkes
logger.Default().Info("🛫 Mengirim DICOM ke SatuSehat", logger.String("target_url", c.cfg.DicomURL))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
logger.Default().Error("SatuSehat DICOM API Error",
logger.Int("status_code", resp.StatusCode),
logger.String("response", string(respBody)),
)
var outcome map[string]interface{}
if err := json.Unmarshal(respBody, &outcome); err == nil && outcome["resourceType"] == "OperationOutcome" {
return nil, &ErrorOperationOutcome{
StatusCode: resp.StatusCode,
Outcome: outcome,
}
}
return nil, fmt.Errorf("DICOM Upload Error (HTTP %d): %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
+4
View File
@@ -18,6 +18,10 @@ type SatuSehatClient interface {
DoKFA(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
// DoConsent mengirimkan HTTP request ke endpoint Consent (ConsentURL).
DoConsent(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
// DoKYC mengirimkan HTTP request ke endpoint KYC (KYCURL).
DoKYC(ctx context.Context, method, endpoint string, body interface{}) ([]byte, error)
// UploadDICOM mengirimkan file biner .dcm menggunakan protokol STOW-RS (multipart/related).
UploadDICOM(ctx context.Context, dicomBytes []byte) ([]byte, error)
}
// FHIRPayload adalah struktur data fleksibel berbentuk map untuk membangun JSON payload FHIR Satu Sehat.
+461
View File
@@ -0,0 +1,461 @@
# 📚 Dokumentasi Lengkap API Satu Sehat (GoPrint Builder)
**Base URL**: `http://localhost:8080/api/v1/satusehat` *(sesuaikan dengan host dan port environment Anda)*
**Otorisasi**: Membutuhkan Header `Authorization: Bearer <token_internal_aplikasi_anda>`
Dokumen ini memuat panduan lengkap penggunaan endpoint dan struktur **JSON Body (POST)** untuk modul referensi (Master Data) maupun modul usecase (Transaksional Klinis). Aplikasi GoPrint bertindak sebagai *proxy builder* yang akan mengonversi struktur JSON datar (*flat*) Anda menjadi format HL7 FHIR standar Kemenkes.
---
## 🔐 1. Otorisasi (Auth) Kemenkes
Aplikasi otomatis mengurus *Access Token* ke Kemenkes. Namun jika dibutuhkan secara manual, Anda bisa menggunakan:
- `GET /reference/auth/token` : Mendapatkan token aktif dari *cache* (digunakan oleh API internal).
- `POST /reference/auth/token/refresh` : Memaksa (*bypass cache*) untuk mengambil token baru.
---
## 🏢 2. Modul Reference (Master Data)
Modul ini digunakan untuk meregistrasi entitas dasar. URL menggunakan prefix `/reference/{modul}`.
### A. Organization (Faskes / Departemen)
`POST /reference/organization`
```json
{
"active": true,
"type_code": "prov",
"type_display": "Healthcare Provider",
"identifier_system": "http://sys-ids.kemkes.go.id/organization/10000004",
"identifier_value": "10000004",
"name": "RSUP Dr. Cipto Mangunkusumo",
"phone": "021-1500135",
"email": "[email protected]",
"url": "https://www.rscm.co.id/",
"address": "Jl. Diponegoro No.71, Kenari, Senen",
"city": "Jakarta Pusat",
"postal_code": "10430",
"country_code": "ID",
"part_of_id": "1000000"
}
```
### B. Location (Lokasi / Ruangan)
`POST /reference/location`
```json
{
"identifier_system": "http://sys-ids.kemkes.go.id/location/10000004",
"identifier_value": "LOK-001",
"status": "active",
"name": "Poli Penyakit Dalam",
"description": "Poliklinik Penyakit Dalam Gedung A",
"physical_type_code": "ro",
"physical_type_display": "Room",
"managing_organization_id": "10000004",
"part_of_id": "b017aa54-f1df-4ec2-9d84-8823815d7228"
}
```
### C. Patient (Pasien)
`POST /reference/patient`
```json
{
"name": "BUDI SANTOSO",
"nik": "3573012345678901",
"ihs_number": "P0123456789",
"gender": "male",
"birth_date": "1990-12-31",
"phone": "08123456789",
"email": "[email protected]",
"is_active": true
}
```
### D. Practitioner (Tenaga Medis)
`POST /reference/practitioner`
```json
{
"nik": "3301234567890123",
"name": "Dr. Siti Aminah, Sp.PD",
"gender": "female",
"birth_date": "1985-05-15",
"is_active": true
}
```
---
## 🩺 3. Modul Usecase (Data Klinis)
Semua endpoint ini langsung menggunakan nama resource, contoh: `POST /encounter` atau `POST /condition`.
### 1. Encounter (Kunjungan)
Mencatat registrasi/kedatangan pasien ke faskes.
`POST /encounter`
```json
{
"encounter_id": "KUNJ-001",
"organization_id": "10000004",
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah",
"location_id": "b017aa54-f1df-4ec2-9d84-8823815d7228",
"location_name": "Poli Umum",
"status": "arrived",
"class": "AMB",
"period_start": "2023-10-12T08:00:00+07:00"
}
```
*(Status valid: `planned`, `arrived`, `in-progress`, `finished`, `cancelled`)*
### 2. Condition (Diagnosis / Keluhan)
Mencatat hasil diagnosis ICD-10 pasien.
`POST /condition`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"clinical_status": "active",
"category_code": "encounter-diagnosis",
"category_display": "Encounter Diagnosis",
"code": "J06.9",
"display": "Acute upper respiratory infection, unspecified",
"onset_date_time": "2023-10-12T08:15:00+07:00",
"recorded_date": "2023-10-12T08:20:00+07:00"
}
```
### 3. Observation (Tanda Vital / Fisik)
Mencatat TTV seperti Nadi, Tensi, Suhu, dll (Standar LOINC).
`POST /observation`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "final",
"category_code": "vital-signs",
"category_display": "Vital Signs",
"code": "8867-4",
"display": "Heart rate",
"value": 85,
"unit": "beats/minute",
"unit_code": "/min",
"effective_date_time": "2023-10-12T08:10:00+07:00"
}
```
### 4. Procedure (Tindakan Medis / ICD-9 CM)
Mencatat tindakan medis atau operasi yang dilakukan ke pasien.
`POST /procedure`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"category_code": "387713003",
"category_display": "Surgical procedure",
"code": "373632001",
"display": "Minor surgery",
"performed_date_time": "2023-10-12T09:30:00+07:00",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah"
}
```
### 5. AllergyIntolerance (Alergi)
Mencatat alergi pasien terhadap obat, makanan, atau lingkungan.
`POST /allergyintolerance`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"clinical_status": "active",
"verification_status": "confirmed",
"category": "medication",
"code": "373410000",
"display": "Allergy to Penicillin",
"criticality": "high",
"recorded_date": "2023-10-12T08:10:00+07:00"
}
```
### 6. ClinicalImpression (Kesan Klinis / Triase)
Mencatat hasil asesmen klinis awal atau triase.
`POST /clinicalimpression`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"description": "Pasien tampak sadar penuh, tidak ada tanda kegawatdaruratan",
"summary": "Observasi ringan",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah",
"effective_date_time": "2023-10-12T08:15:00+07:00"
}
```
### 7. Medication (Master Obat per Kunjungan)
Mendefinisikan obat berdasarkan KFA (Kamus Farmasi Alat Kesehatan).
`POST /medication`
```json
{
"status_code": "active",
"kfa_code": "93000940",
"kfa_display": "Paracetamol 500 mg Tablet",
"form_code": "BS019",
"form_display": "Tablet",
"manufacturer_id": "10000004",
"batch_number": "BATCH12345",
"expiration_date": "2025-12-31T00:00:00Z"
}
```
### 8. MedicationRequest (Resep Obat)
Permintaan/resep obat dari Dokter ke Apotek.
`POST /medicationrequest`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"intent": "order",
"medication_id": "medication-uuid-from-kfa",
"medication_display": "Paracetamol 500 mg Tablet",
"practitioner_id": "N10000001",
"authored_on": "2023-10-12T08:45:00+07:00",
"dosage_instruction": "Diminum 3 kali sehari setelah makan",
"dispense_quantity": 10
}
```
### 9. MedicationDispense (Penyerahan Obat / Apotek)
Pencatatan penyerahan obat fisik kepada pasien oleh Apoteker.
`POST /medicationdispense`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"medication_request_id": "request-uuid-here",
"status": "completed",
"medication_id": "medication-uuid-from-kfa",
"medication_display": "Paracetamol 500 mg Tablet",
"practitioner_id": "N10000002",
"practitioner_name": "Apt. Budi, S.Farm",
"handed_over_date": "2023-10-12T09:00:00+07:00",
"quantity": 10
}
```
### 10. MedicationStatement (Pernyataan Penggunaan Obat)
Riwayat penggunaan obat yang dilaporkan oleh pasien.
`POST /medicationstatement`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"medication_id": "medication-uuid-from-kfa",
"medication_display": "Paracetamol 500 mg Tablet",
"effective_date_time": "2023-10-12T08:00:00+07:00",
"dosage_text": "Sering minum paracetamol bebas saat pusing"
}
```
### 11. ServiceRequest (Permintaan Layanan / Lab / Rujukan)
Permintaan tindakan lebih lanjut, seperti rujuk poli, rawat inap, atau periksa Lab/Radiologi.
`POST /servicerequest`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"intent": "order",
"category_code": "108252007",
"category_display": "Laboratory procedure",
"code": "1044-2",
"display": "Darah Rutin",
"requester_id": "N10000001",
"authored_on": "2023-10-12T08:30:00+07:00"
}
```
### 12. DiagnosticReport (Hasil Lab / Radiologi)
Laporan hasil pengecekan Lab atau Radiologi (penggabungan berbagai Observation).
`POST /diagnosticreport`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "final",
"category_code": "LAB",
"category_display": "Laboratory",
"code": "1044-2",
"display": "Hasil Darah Rutin",
"practitioner_id": "N10000001",
"issued_date": "2023-10-12T11:00:00+07:00",
"conclusion": "Semua indikator darah dalam batas normal."
}
```
### 13. Specimen (Sampel Lab)
Data pencatatan spesimen (misal: darah, urin) yang diambil dari tubuh pasien.
`POST /specimen`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"type_code": "119297000",
"type_display": "Blood specimen",
"collection_date_time": "2023-10-12T10:00:00+07:00",
"collector_id": "N10000001",
"received_date_time": "2023-10-12T10:15:00+07:00"
}
```
### 14. ImagingStudy (Pencitraan Radiologi)
Data meta terkait pemeriksaan Radiologi (X-Ray, MRI, CT-Scan).
`POST /imagingstudy`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "available",
"started_date_time": "2023-10-12T10:30:00+07:00",
"number_of_series": 1,
"number_of_instances": 5,
"procedure_code": "XR",
"procedure_display": "X-Ray Chest"
}
```
### 15. Immunization (Riwayat Vaksin)
Pencatatan pemberian imunisasi/vaksinasi.
`POST /immunization`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"vaccine_code": "J07BM01",
"vaccine_display": "COVID-19 Vaccine",
"occurrence_date_time": "2023-10-12T09:00:00+07:00",
"practitioner_id": "N10000001",
"location_id": "b017aa54-f1df-4ec2-9d84-8823815d7228",
"dose_quantity": 0.5,
"dose_unit": "ml"
}
```
### 16. CarePlan (Rencana Perawatan)
Rencana medis/keperawatan masa depan untuk pasien.
`POST /careplan`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"intent": "plan",
"title": "Rencana Diet Diabetes",
"description": "Pasien harus mematuhi diet rendah gula dan karbohidrat.",
"practitioner_id": "N10000001",
"created_date": "2023-10-12T10:00:00+07:00"
}
```
### 17. EpisodeOfCare (Episode Perawatan)
Mengelompokkan serangkaian *Encounter* (kunjungan) yang merujuk pada satu kondisi klinis / episode penyakit yang sama.
`POST /episodeofcare`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"organization_id": "10000004",
"status": "active",
"period_start": "2023-10-01T08:00:00+07:00",
"managing_practitioner_id": "N10000001"
}
```
### 18. Composition (Resume Medis / Dokumen Klinis)
Menggabungkan berbagai resource (Diagnosis, Obat, Tindakan) menjadi satu resume / dokumen medis yang utuh secara hukum.
`POST /composition`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "final",
"type_code": "18842-5",
"type_display": "Discharge Summary",
"title": "Resume Pulang Pasien Rawat Inap",
"date": "2023-10-15T12:00:00+07:00",
"author_id": "N10000001",
"author_name": "Dr. Siti Aminah"
}
```
### 19. QuestionnaireResponse (Hasil Kuesioner Medis)
Jawaban atas asesmen spesifik (misal: Skrining Nyeri, Skrining Jatuh, Tumbuh Kembang).
`POST /questionnaireresponse`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"questionnaire_url": "http://sys-ids.kemkes.go.id/questionnaire/Nyeri",
"authored_date": "2023-10-12T08:05:00+07:00",
"author_practitioner_id": "N10000002",
"items": [
{
"linkId": "1.1",
"text": "Tingkat Nyeri (0-10)",
"answer_value_integer": 3
}
]
}
```
---
## 💡 4. Standar Method GET, PUT, & PATCH
Semua modul mendukung operasi standar berikut:
1. **`GET /{modul}`** (Search)
Melakukan pencarian. Parameter URL (*Query String*) disesuaikan per modul (misal `?patient=P012...` atau `?name=Budi`).
2. **`GET /{modul}/{id}`** (Get By ID)
Mengambil detail data utuh (dalam format FHIR) berdasarkan ID kemenkes.
3. **`PUT /{modul}/{id}`** (Replace)
Mengirimkan *payload JSON* penuh persis seperti `POST` untuk me-replace (mengganti) dokumen lama.
4. **`PATCH /{modul}/{id}`** (Partial Update)
Menggunakan standar *JSON Patch (RFC 6902)* untuk mengubah satu/beberapa *field* spesifik tanpa mengubah keseluruhan dokumen.
**Contoh Request Body (Array of Objects):**
```json
[
{
"op": "replace",
"path": "/status",
"value": "finished"
}
]
```
+463
View File
@@ -0,0 +1,463 @@
# 📚 Dokumentasi Lengkap API Satu Sehat (GoPrint Builder)
**Base URL**: `http://localhost:8080/api/v1/satusehat` *(sesuaikan dengan host dan port environment Anda)*
**Otorisasi**: Membutuhkan Header `Authorization: Bearer <token_internal_aplikasi_anda>`
Dokumen ini memuat panduan lengkap penggunaan endpoint dan struktur **JSON Body (POST)** untuk modul referensi (Master Data) maupun modul usecase (Transaksional Klinis). Aplikasi GoPrint bertindak sebagai *proxy builder* yang akan mengonversi struktur JSON datar (*flat*) Anda menjadi format HL7 FHIR standar Kemenkes.
---
## 🔐 1. Otorisasi (Auth) Kemenkes
Aplikasi otomatis mengurus *Access Token* ke Kemenkes. Namun jika dibutuhkan secara manual, Anda bisa menggunakan:
- `GET /reference/auth/token` : Mendapatkan token aktif dari *cache* (digunakan oleh API internal).
- `POST /reference/auth/token/refresh` : Memaksa (*bypass cache*) untuk mengambil token baru.
---
## 🏢 2. Modul Reference (Master Data)
Modul ini digunakan untuk meregistrasi entitas dasar. URL menggunakan prefix `/reference/{modul}`.
### A. Organization (Faskes / Departemen)
`POST /reference/organization`
```json
{
"active": true,
"type_code": "prov",
"type_display": "Healthcare Provider",
"identifier_system": "http://sys-ids.kemkes.go.id/organization/10000004",
"identifier_value": "10000004",
"name": "RSUP Dr. Cipto Mangunkusumo",
"phone": "021-1500135",
"email": "[email protected]",
"url": "https://www.rscm.co.id/",
"address": "Jl. Diponegoro No.71, Kenari, Senen",
"city": "Jakarta Pusat",
"postal_code": "10430",
"country_code": "ID",
"part_of_id": "1000000"
}
```
### B. Location (Lokasi / Ruangan)
`POST /reference/location`
```json
{
"identifier_system": "http://sys-ids.kemkes.go.id/location/10000004",
"identifier_value": "LOK-001",
"status": "active",
"name": "Poli Penyakit Dalam",
"description": "Poliklinik Penyakit Dalam Gedung A",
"physical_type_code": "ro",
"physical_type_display": "Room",
"managing_organization_id": "10000004",
"part_of_id": "b017aa54-f1df-4ec2-9d84-8823815d7228"
}
```
### C. Patient (Pasien)
`POST /reference/patient`
```json
{
"name": "BUDI SANTOSO",
"nik": "3573012345678901",
"ihs_number": "P0123456789",
"gender": "male",
"birth_date": "1990-12-31",
"phone": "08123456789",
"email": "[email protected]",
"is_active": true
}
```
### D. Practitioner (Tenaga Medis)
`POST /reference/practitioner`
```json
{
"nik": "3301234567890123",
"name": "Dr. Siti Aminah, Sp.PD",
"gender": "female",
"birth_date": "1985-05-15",
"is_active": true
}
```
---
## 🩺 3. Modul Usecase (Data Klinis)
Semua endpoint ini langsung menggunakan nama resource, contoh: `POST /encounter` atau `POST /condition`.
### 1. Encounter (Kunjungan)
Mencatat registrasi/kedatangan pasien ke faskes.
`POST /encounter`
```json
{
"encounter_id": "KUNJ-001",
"organization_id": "10000004",
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah",
"location_id": "b017aa54-f1df-4ec2-9d84-8823815d7228",
"location_name": "Poli Umum",
"status": "arrived",
"class": "AMB",
"period_start": "2023-10-12T08:00:00+07:00"
}
```
*(Status valid: `planned`, `arrived`, `in-progress`, `finished`, `cancelled`)*
### 2. Condition (Diagnosis / Keluhan)
Mencatat hasil diagnosis ICD-10 pasien.
`POST /condition`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"clinical_status": "active",
"category_code": "encounter-diagnosis",
"category_display": "Encounter Diagnosis",
"code": "J06.9",
"display": "Acute upper respiratory infection, unspecified",
"onset_date_time": "2023-10-12T08:15:00+07:00",
"recorded_date": "2023-10-12T08:20:00+07:00"
}
```
### 3. Observation (Tanda Vital / Fisik)
Mencatat TTV seperti Nadi, Tensi, Suhu, dll (Standar LOINC).
`POST /observation`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "final",
"category_code": "vital-signs",
"category_display": "Vital Signs",
"code": "8867-4",
"display": "Heart rate",
"value": 85,
"unit": "beats/minute",
"unit_code": "/min",
"effective_date_time": "2023-10-12T08:10:00+07:00"
}
```
### 4. Procedure (Tindakan Medis / ICD-9 CM)
Mencatat tindakan medis atau operasi yang dilakukan ke pasien.
`POST /procedure`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"category_code": "387713003",
"category_display": "Surgical procedure",
"code": "373632001",
"display": "Minor surgery",
"performed_date_time": "2023-10-12T09:30:00+07:00",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah"
}
```
### 5. AllergyIntolerance (Alergi)
Mencatat alergi pasien terhadap obat, makanan, atau lingkungan.
`POST /allergyintolerance`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"clinical_status": "active",
"verification_status": "confirmed",
"category": "medication",
"code": "373410000",
"display": "Allergy to Penicillin",
"criticality": "high",
"recorded_date": "2023-10-12T08:10:00+07:00"
}
```
### 6. ClinicalImpression (Kesan Klinis / Triase)
Mencatat hasil asesmen klinis awal atau triase.
`POST /clinicalimpression`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"description": "Pasien tampak sadar penuh, tidak ada tanda kegawatdaruratan",
"summary": "Observasi ringan",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah",
"effective_date_time": "2023-10-12T08:15:00+07:00"
}
```
### 7. Medication (Master Obat per Kunjungan)
Mendefinisikan obat berdasarkan KFA (Kamus Farmasi Alat Kesehatan).
`POST /medication`
```json
{
"status_code": "active",
"kfa_code": "93000940",
"kfa_display": "Paracetamol 500 mg Tablet",
"form_code": "BS019",
"form_display": "Tablet",
"manufacturer_id": "10000004",
"batch_number": "BATCH12345",
"expiration_date": "2025-12-31T00:00:00Z"
}
```
### 8. MedicationRequest (Resep Obat)
Permintaan/resep obat dari Dokter ke Apotek.
`POST /medicationrequest`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"intent": "order",
"medication_id": "medication-uuid-from-kfa",
"medication_display": "Paracetamol 500 mg Tablet",
"practitioner_id": "N10000001",
"authored_on": "2023-10-12T08:45:00+07:00",
"dosage_instruction": "Diminum 3 kali sehari setelah makan",
"dispense_quantity": 10
}
```
### 9. MedicationDispense (Penyerahan Obat / Apotek)
Pencatatan penyerahan obat fisik kepada pasien oleh Apoteker.
`POST /medicationdispense`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"medication_request_id": "request-uuid-here",
"status": "completed",
"medication_id": "medication-uuid-from-kfa",
"medication_display": "Paracetamol 500 mg Tablet",
"practitioner_id": "N10000002",
"practitioner_name": "Apt. Budi, S.Farm",
"handed_over_date": "2023-10-12T09:00:00+07:00",
"quantity": 10
}
```
### 10. MedicationStatement (Pernyataan Penggunaan Obat)
Riwayat penggunaan obat yang dilaporkan oleh pasien.
`POST /medicationstatement`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"medication_id": "medication-uuid-from-kfa",
"medication_display": "Paracetamol 500 mg Tablet",
"effective_date_time": "2023-10-12T08:00:00+07:00",
"dosage_text": "Sering minum paracetamol bebas saat pusing"
}
```
### 11. ServiceRequest (Permintaan Layanan / Lab / Rujukan)
Permintaan tindakan lebih lanjut, seperti rujuk poli, rawat inap, atau periksa Lab/Radiologi.
`POST /servicerequest`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"intent": "order",
"category_code": "108252007",
"category_display": "Laboratory procedure",
"code": "1044-2",
"display": "Darah Rutin",
"requester_id": "N10000001",
"authored_on": "2023-10-12T08:30:00+07:00"
}
```
### 12. DiagnosticReport (Hasil Lab / Radiologi)
Laporan hasil pengecekan Lab atau Radiologi (penggabungan berbagai Observation).
`POST /diagnosticreport`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "final",
"category_code": "LAB",
"category_display": "Laboratory",
"code": "1044-2",
"display": "Hasil Darah Rutin",
"practitioner_id": "N10000001",
"issued_date": "2023-10-12T11:00:00+07:00",
"conclusion": "Semua indikator darah dalam batas normal."
}
```
### 13. Specimen (Sampel Lab)
Data pencatatan spesimen (misal: darah, urin) yang diambil dari tubuh pasien.
`POST /specimen`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"type_code": "119297000",
"type_display": "Blood specimen",
"collection_date_time": "2023-10-12T10:00:00+07:00",
"collector_id": "N10000001",
"received_date_time": "2023-10-12T10:15:00+07:00"
}
```
### 14. ImagingStudy (Pencitraan Radiologi)
Data meta terkait pemeriksaan Radiologi (X-Ray, MRI, CT-Scan).
`POST /imagingstudy`
```json
{
"organization_id": "10000004",
"accession_number": "USG-SEN25-011815",
"service_request_id": "c8d9f768-2237-4da5-b0ad-9cd5e6087a9a",
"patient_id": "P20395354720",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "available",
"started": "2025-06-10T11:41:46+07:00",
"number_of_series": 1,
"number_of_instances": 5,
"procedure_code": "CT",
"procedure_display": "CT Scan",
"description": "Keterangan hasil pemeriksaan"
}
```
### 15. Immunization (Riwayat Vaksin)
Pencatatan pemberian imunisasi/vaksinasi.
`POST /immunization`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"vaccine_code": "J07BM01",
"vaccine_display": "COVID-19 Vaccine",
"occurrence_date_time": "2023-10-12T09:00:00+07:00",
"practitioner_id": "N10000001",
"location_id": "b017aa54-f1df-4ec2-9d84-8823815d7228",
"dose_quantity": 0.5,
"dose_unit": "ml"
}
```
### 16. CarePlan (Rencana Perawatan)
Rencana medis/keperawatan masa depan untuk pasien.
`POST /careplan`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "active",
"intent": "plan",
"title": "Rencana Diet Diabetes",
"description": "Pasien harus mematuhi diet rendah gula dan karbohidrat.",
"practitioner_id": "N10000001",
"created_date": "2023-10-12T10:00:00+07:00"
}
```
### 17. EpisodeOfCare (Episode Perawatan)
Mengelompokkan serangkaian *Encounter* (kunjungan) yang merujuk pada satu kondisi klinis / episode penyakit yang sama.
`POST /episodeofcare`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"organization_id": "10000004",
"status": "active",
"period_start": "2023-10-01T08:00:00+07:00",
"managing_practitioner_id": "N10000001"
}
```
### 18. Composition (Resume Medis / Dokumen Klinis)
Menggabungkan berbagai resource (Diagnosis, Obat, Tindakan) menjadi satu resume / dokumen medis yang utuh secara hukum.
`POST /composition`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "final",
"title": "Resume Pulang Pasien Rawat Inap",
"date": "2023-10-15T12:00:00Z",
"practitioner_id": "N10000001",
"practitioner_name": "Dr. Siti Aminah"
}
```
### 19. QuestionnaireResponse (Hasil Kuesioner Medis)
Jawaban atas asesmen spesifik (misal: Skrining Nyeri, Skrining Jatuh, Tumbuh Kembang).
`POST /questionnaireresponse`
```json
{
"patient_id": "P0123456789",
"patient_name": "Budi Santoso",
"encounter_id": "5fa23d13-0943-4318-b295-eb1ecfa7384a",
"status": "completed",
"questionnaire_url": "http://sys-ids.kemkes.go.id/questionnaire/Nyeri",
"authored_date": "2023-10-12T08:05:00+07:00",
"author_practitioner_id": "N10000002",
"items": [
{
"linkId": "1.1",
"text": "Tingkat Nyeri (0-10)",
"answer_value_integer": 3
}
]
}
```
---
## 💡 4. Standar Method GET, PUT, & PATCH
Semua modul mendukung operasi standar berikut:
1. **`GET /{modul}`** (Search)
Melakukan pencarian. Parameter URL (*Query String*) disesuaikan per modul (misal `?patient=P012...` atau `?name=Budi`).
2. **`GET /{modul}/{id}`** (Get By ID)
Mengambil detail data utuh (dalam format FHIR) berdasarkan ID kemenkes.
3. **`PUT /{modul}/{id}`** (Replace)
Mengirimkan *payload JSON* penuh persis seperti `POST` untuk me-replace (mengganti) dokumen lama.
4. **`PATCH /{modul}/{id}`** (Partial Update)
Menggunakan standar *JSON Patch (RFC 6902)* untuk mengubah satu/beberapa *field* spesifik tanpa mengubah keseluruhan dokumen.
**Contoh Request Body (Array of Objects):**
```json
[
{
"op": "replace",
"path": "/status",
"value": "finished"
}
]
```
+23
View File
@@ -0,0 +1,23 @@
package common
// ReferenceDTO merepresentasikan referensi standar ke resource FHIR lain.
type ReferenceDTO struct {
Reference string `json:"reference,omitempty"`
Display string `json:"display,omitempty"`
}
// CodeableConceptDTO merepresentasikan konsep kode HL7/SNOMED/LOINC.
type CodeableConceptDTO struct {
System string `json:"system,omitempty"`
Code string `json:"code,omitempty"`
Display string `json:"display,omitempty"`
Text string `json:"text,omitempty"`
}
// QuantityDTO merepresentasikan besaran nilai beserta unit pengukurannya.
type QuantityDTO struct {
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
System string `json:"system,omitempty"`
Code string `json:"code,omitempty"`
}
+83
View File
@@ -0,0 +1,83 @@
-- Tabel Utama Produk KFA
CREATE TABLE kfa_products (
kfa_code VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
nama_dagang VARCHAR(255),
active BOOLEAN DEFAULT true,
state VARCHAR(50),
generik BOOLEAN,
-- Klasifikasi & Atribut Dasar (Disimpan sebagai JSONB untuk efisiensi)
dosage_form JSONB, -- { "code": "BS066", "name": "Tablet" }
farmalkes_type JSONB, -- { "code": "medicine", "group": "farmasi", "name": "Obat" }
uom JSONB, -- { "name": "Tablet" }
rute_pemberian JSONB, -- { "code": "O", "name": "Oral" }
controlled_drug JSONB, -- { "code": "3", "name": "Obat Keras" }
-- Harga & Metrik Fisik
fix_price NUMERIC(12, 2),
het_price NUMERIC(12, 2),
dose_per_unit NUMERIC(10, 2),
net_weight NUMERIC(10, 2),
net_weight_uom_name VARCHAR(20),
volume NUMERIC(10, 2),
volume_uom_name VARCHAR(20),
score_tkdn NUMERIC(5, 2),
rxterm INT,
-- Data Legal & Manufaktur
manufacturer VARCHAR(255),
registrar VARCHAR(255),
nie VARCHAR(100),
farmalkes_hscode VARCHAR(100),
kode_lkpp VARCHAR(100),
tayang_lkpp BOOLEAN,
-- Teks Panjang (HTML String)
description TEXT,
indication TEXT,
side_effect TEXT,
warning TEXT,
-- Kompleks/Nested JSONB (Data yang jarang di-join tapi sering dibaca)
atc_info JSONB, -- Menggabungkan atc_l1 sampai atc_l5 & atc_ddd
fornas JSONB, -- Struktur dokumen fornas
dosage_usage JSONB, -- Array of dosage usage objects
tags JSONB, -- Array of tags [{code, name}]
identifier_ids JSONB, -- Array of identifiers [{code, name, source_name}]
product_template JSONB, -- Data template referensi KFA
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
-- Indeks untuk pencarian teks yang cepat
CREATE INDEX idx_kfa_products_name ON kfa_products(name);
CREATE INDEX idx_kfa_products_kode_lkpp ON kfa_products(kode_lkpp);
-- Indeks GIN untuk pencarian dalam JSONB (misal mencari produk berdasarkan tags)
-- CREATE INDEX idx_kfa_products_tags ON kfa_products USING GIN (tags); -- Catatan: Buka komentar ini HANYA jika Anda menggunakan PostgreSQL
-- Tabel Relasi: Zat Aktif (Penting untuk dibuat tabel terpisah agar bisa query "Obat dengan kandungan X")
CREATE TABLE kfa_active_ingredients (
id SERIAL PRIMARY KEY,
product_kfa_code VARCHAR(50) REFERENCES kfa_products(kfa_code) ON DELETE CASCADE,
kfa_code VARCHAR(50),
zat_aktif VARCHAR(255) NOT NULL,
kekuatan_zat_aktif VARCHAR(100),
active BOOLEAN DEFAULT true,
state VARCHAR(50),
updated_at TIMESTAMP
);
CREATE INDEX idx_ingredient_zat_aktif ON kfa_active_ingredients(zat_aktif);
-- Tabel Relasi: Varian Kemasan
CREATE TABLE kfa_packaging (
id SERIAL PRIMARY KEY,
product_kfa_code VARCHAR(50) REFERENCES kfa_products(kfa_code) ON DELETE CASCADE,
kfa_code VARCHAR(50),
name VARCHAR(255),
pack_price NUMERIC(12, 2),
qty INT,
uom_id VARCHAR(50)
);
+15
View File
@@ -0,0 +1,15 @@
package kyc
// GenerateURLRequest merepresentasikan payload untuk membuat link validasi KYC
type GenerateURLRequest struct {
AgentName string `json:"agent_name" binding:"required"`
AgentNIK string `json:"agent_nik" binding:"required,len=16"`
PublicKey string `json:"public_key"` // Diisi otomatis oleh backend
}
// CallbackRequest merepresentasikan payload webhook/callback dari Satu Sehat setelah KYC selesai.
type CallbackRequest struct {
Status string `json:"status"`
Message string `json:"message"`
Data map[string]interface{} `json:"data"`
}
@@ -0,0 +1,73 @@
package kyc
import (
"context"
"encoding/json"
"service/internal/interfaces/satusehat"
"service/pkg/crypto"
"service/pkg/errors"
)
type Repository interface {
// Menerima privKeyPEM untuk keperluan decrypt hasil response
GenerateURL(ctx context.Context, payload GenerateURLRequest, privKeyPEM string) (*satusehat.FHIRResponse, error)
}
type repository struct {
client satusehat.SatuSehatClient
}
func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
// hiddenCtx membungkus context untuk mencegah *HTTP client*
// melakukan type-assertion ke *gin.Context dan melakukan auto-print error.
type hiddenCtx struct {
context.Context
}
func (r *repository) GenerateURL(ctx context.Context, payload GenerateURLRequest, privKeyPEM string) (*satusehat.FHIRResponse, error) {
// 1. Marshal JSON asli
jsonPayload, _ := json.Marshal(payload)
// 2. Enkripsi JSON Payload menggunakan fungsi AES/RSA yang baru dibuat
encryptedStringPayload, err := crypto.EncryptSatuSehatPayload(jsonPayload)
if err != nil {
return nil, errors.InternalError().Message("Gagal mengenkripsi payload").Cause(err).Build()
}
// Endpoint ini mengarah ke /generate-url dengan method POST
// NOTE: Pastikan r.client.DoKYC mendukung pengiriman string dan otomatis
// memberikan HTTP Header Content-Type: text/plain
respData, err := r.client.DoKYC(hiddenCtx{ctx}, "POST", "/generate-url", encryptedStringPayload)
if err != nil {
return nil, errors.InternalError().
Message("Failed to generate KYC URL to SatuSehat").
Cause(err).
Build()
}
// 3. Dekripsi response dari Kemenkes menggunakan Private Key kita
decryptedData, decErr := crypto.DecryptSatuSehatPayload(string(respData), privKeyPEM)
if decErr != nil {
// Terkadang jika validasi NIK gagal, Kemenkes membalas JSON plaintext berisi {"metadata": {"code":"400"...}}
// Jika gagal decrypt, kita asumsikan Kemenkes mengirim plaintext error, kita parse langsung.
decryptedData = respData
}
var result map[string]interface{}
if err := json.Unmarshal(decryptedData, &result); err != nil {
return nil, errors.InternalError().
Message("Failed to parse KYC response").
Cause(err).
Metadata("raw_response", string(decryptedData)).
Build()
}
return &satusehat.FHIRResponse{
FullResponse: result,
RawResponse: respData,
}, nil
}
@@ -0,0 +1,80 @@
package kyc
import (
"context"
"encoding/base64"
"encoding/json"
"service/internal/infrastructure/config"
"service/internal/interfaces/satusehat"
"service/pkg/crypto"
"service/pkg/errors"
"service/pkg/logger"
)
type Service interface {
GenerateURL(ctx context.Context, req GenerateURLRequest) (*satusehat.FHIRResponse, error)
HandleCallback(ctx context.Context, encryptedPayload string, token string) error
}
type service struct {
repo Repository
cfg config.SatuSehatConfig
}
func NewService(repo Repository, cfg config.SatuSehatConfig) Service {
return &service{repo: repo, cfg: cfg}
}
func (s *service) GenerateURL(ctx context.Context, req GenerateURLRequest) (*satusehat.FHIRResponse, error) {
// Gunakan public key statis dari konfigurasi (disimpan dalam format Base64 di .env)
pubKeyBytes, err := base64.StdEncoding.DecodeString(s.cfg.KYCPublicKeyB64)
if err != nil {
return nil, errors.InternalError().Message("Gagal mendecode Public Key dari konfigurasi").Cause(err).Build()
}
req.PublicKey = string(pubKeyBytes)
// Decode private key untuk mendekripsi balasan generate URL dari Kemenkes
privKeyBytes, err := base64.StdEncoding.DecodeString(s.cfg.KYCPrivateKeyB64)
if err != nil {
return nil, errors.InternalError().Message("Gagal mendecode Private Key dari konfigurasi").Cause(err).Build()
}
privKeyPEM := string(privKeyBytes)
// Lempar privKeyPEM ke repo agar Kemenkes response dapat didekripsi
return s.repo.GenerateURL(ctx, req, privKeyPEM)
}
func (s *service) HandleCallback(ctx context.Context, encryptedPayload string, token string) error {
// Validasi Security Token dari Kemenkes
expectedToken := s.cfg.WebhookSecret
if expectedToken == "" {
logger.Default().Warn("SATUSEHAT_WEBHOOK_SECRET belum dikonfigurasi. Validasi token webhook dilewati.")
} else if token != expectedToken {
logger.Default().Warn("Webhook KYC gagal divalidasi: Token tidak cocok", logger.String("received_token", token))
return errors.UnauthorizedError().Message("Invalid security webhook token").Build()
}
// 1. Dapatkan Private Key statis Anda dari config/env var
privKeyBytes, err := base64.StdEncoding.DecodeString(s.cfg.KYCPrivateKeyB64)
if err != nil {
return errors.InternalError().Message("Gagal mendecode Private Key dari konfigurasi").Cause(err).Build()
}
privKeyPEM := string(privKeyBytes)
// 2. Dekripsi payload yang masuk
decryptedBytes, err := crypto.DecryptSatuSehatPayload(encryptedPayload, privKeyPEM)
if err != nil {
return errors.InternalError().Message("Gagal mendekripsi payload webhook dari SatuSehat").Cause(err).Build()
}
// 3. Unmarshal ke bentuk Struct
var payload CallbackRequest
if err := json.Unmarshal(decryptedBytes, &payload); err != nil {
return errors.InternalError().Message("Gagal memparsing JSON hasil dekripsi").Cause(err).Build()
}
// TODO: Tambahkan logika update ke database lokal (misal: tabel user/agent) berdasarkan hasil KYC.
// Saat ini, kita log payload yang diterima dari Kemenkes.
logger.Default().Info("Menerima Webhook KYC dari SatuSehat", logger.Any("payload", payload))
return nil
}
@@ -1,17 +1,25 @@
package allergyintolerance
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type AllergyIntoleranceRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
ClinicalStatus string `json:"clinical_status" binding:"required,oneof=active inactive resolved"`
VerificationStatus string `json:"verification_status" binding:"required,oneof=unconfirmed presumed confirmed refuted entered-in-error"`
Category string `json:"category" binding:"required,oneof=food medication environment biologic"`
Code string `json:"code" binding:"required"` // SNOMED CT Code
Display string `json:"display" binding:"required"` // Display Name
RecordedDate time.Time `json:"recorded_date" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
AllergyID string `json:"allergy_id,omitempty"`
ClinicalStatus string `json:"clinical_status" binding:"required,oneof=active inactive resolved"`
VerificationStatus string `json:"verification_status" binding:"required,oneof=unconfirmed presumed confirmed refuted entered-in-error"`
Category []string `json:"category,omitempty"`
Code *common.CodeableConceptDTO `json:"code" binding:"required"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
EncounterDisplay string `json:"encounter_display,omitempty"`
RecordedDate *time.Time `json:"recorded_date" binding:"required"`
RecorderID string `json:"recorder_id,omitempty"`
RecorderDisplay string `json:"recorder_display,omitempty"`
}
type AllergyIntolerancePatchRequest []map[string]interface{}
@@ -1,42 +1,115 @@
package allergyintolerance
import (
"os"
"strings"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req AllergyIntoleranceRequest) satusehat.FHIRPayload {
return satusehat.NewFHIRPayload("AllergyIntolerance").
Set("clinicalStatus", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
"code": req.ClinicalStatus,
},
},
}).
Set("verificationStatus", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification",
"code": req.VerificationStatus,
},
},
}).
Set("category", []string{req.Category}).
Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://snomed.info/sct",
"code": req.Code,
"display": req.Display,
},
},
}).
Set("patient", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("recordedDate", req.RecordedDate.Format(time.RFC3339))
var verificationStatusDisplayMap = map[string]string{
"unconfirmed": "Unconfirmed",
"presumed": "Presumed",
"confirmed": "Confirmed",
"refuted": "Refuted",
"entered-in-error": "Entered in Error",
}
func MapRequestToFHIR(req AllergyIntoleranceRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("AllergyIntolerance")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.AllergyID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/allergy/" + orgID,
"use": "official",
"value": req.AllergyID,
},
})
}
if req.ClinicalStatus != "" {
payload.Set("clinicalStatus", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
"code": req.ClinicalStatus,
"display": strings.ToUpper(req.ClinicalStatus[:1]) + req.ClinicalStatus[1:],
},
},
})
}
if req.VerificationStatus != "" {
display, ok := verificationStatusDisplayMap[req.VerificationStatus]
if !ok {
display = strings.ToUpper(req.VerificationStatus[:1]) + req.VerificationStatus[1:]
}
payload.Set("verificationStatus", map[string]interface{}{
"coding": []map[string]interface{}{
{"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification", "code": req.VerificationStatus, "display": display},
},
})
}
if len(req.Category) > 0 {
payload.Set("category", req.Category)
}
if req.Code != nil {
sys := req.Code.System
if sys == "" {
sys = "http://snomed.info/sct" // Default to SNOMED
}
coding := map[string]interface{}{
"system": sys,
"code": req.Code.Code,
}
if req.Code.Display != "" {
coding["display"] = req.Code.Display
}
codeConcept := map[string]interface{}{
"coding": []map[string]interface{}{coding},
}
if req.Code.Text != "" {
codeConcept["text"] = req.Code.Text
}
payload.Set("code", codeConcept)
}
if req.PatientID != "" {
subject := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientName != "" {
subject["display"] = req.PatientName
}
payload.Set("patient", subject)
}
if req.EncounterID != "" {
encounter := map[string]interface{}{"reference": "Encounter/" + req.EncounterID}
if req.EncounterDisplay != "" {
encounter["display"] = req.EncounterDisplay
}
payload.Set("encounter", encounter)
}
if req.RecordedDate != nil && !req.RecordedDate.IsZero() {
payload.Set("recordedDate", req.RecordedDate.Format(time.RFC3339))
}
if req.RecorderID != "" {
recorder := map[string]interface{}{"reference": "Practitioner/" + req.RecorderID}
if req.RecorderDisplay != "" {
recorder["display"] = req.RecorderDisplay
}
payload.Set("recorder", recorder)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -19,15 +20,24 @@ type repository struct{ client satusehat.SatuSehatClient }
func NewRepository(client satusehat.SatuSehatClient) Repository { return &repository{client: client} }
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
resourceID = id
@@ -25,12 +25,23 @@ func (s *service) Update(ctx context.Context, id string, req AllergyIntoleranceR
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.Update(ctx, id, MapRequestToFHIR(req).Set("id", id))
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req AllergyIntolerancePatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("AllergyIntolerance ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("AllergyIntolerance ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
+20 -11
View File
@@ -1,18 +1,27 @@
package careplan
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type CarePlanRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
Status string `json:"status" binding:"required,oneof=draft active on-hold revoked completed entered-in-error unknown"`
Intent string `json:"intent" binding:"required,oneof=proposal plan order option"`
Title string `json:"title" binding:"required"`
Description string `json:"description" binding:"required"`
CreatedDate time.Time `json:"created_date" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
CarePlanID string `json:"care_plan_id,omitempty"`
Status string `json:"status" binding:"required,oneof=draft active on-hold revoked completed entered-in-error unknown"`
Intent string `json:"intent" binding:"required,oneof=proposal plan order option"`
Category []common.CodeableConceptDTO `json:"category,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientDisplay string `json:"patient_display,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
EncounterDisplay string `json:"encounter_display,omitempty"`
CreatedDate *time.Time `json:"created_date,omitempty"`
AuthorID string `json:"author_id,omitempty"` // cth: "N10000001"
AuthorDisplay string `json:"author_display,omitempty"`
GoalIDs []string `json:"goal_ids,omitempty"` // Akan direferensikan sebagai "Goal/{id}"
}
type CarePlanPatchRequest []map[string]interface{}
+78 -15
View File
@@ -1,24 +1,87 @@
package careplan
import (
"os"
"service/internal/interfaces/satusehat"
"time"
)
func MapRequestToFHIR(req CarePlanRequest) satusehat.FHIRPayload {
return satusehat.NewFHIRPayload("CarePlan").
Set("status", req.Status).
Set("intent", req.Intent).
Set("title", req.Title).
Set("description", req.Description).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{"reference": "Encounter/" + req.EncounterID}).
Set("author", map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
}).
Set("created", req.CreatedDate.Format(time.RFC3339))
payload := satusehat.NewFHIRPayload("CarePlan")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.CarePlanID != "" {
payload.Set("identifier", []map[string]interface{}{
{"system": "http://sys-ids.kemkes.go.id/careplan/" + orgID, "use": "official", "value": req.CarePlanID},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Intent != "" {
payload.Set("intent", req.Intent)
}
if len(req.Category) > 0 {
var categories []map[string]interface{}
for _, cat := range req.Category {
sys := cat.System
if sys == "" {
sys = "http://snomed.info/sct" // Default value jika kosong
}
coding := map[string]interface{}{"system": sys, "code": cat.Code}
if cat.Display != "" {
coding["display"] = cat.Display
}
concept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if cat.Text != "" {
concept["text"] = cat.Text
}
categories = append(categories, concept)
}
payload.Set("category", categories)
}
if req.Title != "" {
payload.Set("title", req.Title)
}
if req.Description != "" {
payload.Set("description", req.Description)
}
if req.PatientID != "" {
subject := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientDisplay != "" {
subject["display"] = req.PatientDisplay
}
payload.Set("subject", subject)
}
if req.EncounterID != "" {
encounter := map[string]interface{}{"reference": "Encounter/" + req.EncounterID}
if req.EncounterDisplay != "" {
encounter["display"] = req.EncounterDisplay
}
payload.Set("encounter", encounter)
}
if req.AuthorID != "" {
author := map[string]interface{}{"reference": "Practitioner/" + req.AuthorID}
if req.AuthorDisplay != "" {
author["display"] = req.AuthorDisplay
}
payload.Set("author", author)
}
if req.CreatedDate != nil && !req.CreatedDate.IsZero() {
payload.Set("created", req.CreatedDate.Format(time.RFC3339))
}
if len(req.GoalIDs) > 0 {
var goals []map[string]interface{}
for _, id := range req.GoalIDs {
goals = append(goals, map[string]interface{}{"reference": "Goal/" + id})
}
payload.Set("goal", goals)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -19,14 +20,22 @@ type repository struct{ client satusehat.SatuSehatClient }
func NewRepository(client satusehat.SatuSehatClient) Repository { return &repository{client: client} }
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
+12 -1
View File
@@ -24,12 +24,23 @@ func (s *service) Update(ctx context.Context, id string, req CarePlanRequest) (*
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.Update(ctx, id, MapRequestToFHIR(req).Set("id", id))
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req CarePlanPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("CarePlan ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("CarePlan ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
@@ -1,17 +1,40 @@
package clinicalimpression
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type ClinicalImpressionRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
Status string `json:"status" binding:"required,oneof=in-progress completed entered-in-error"`
Date time.Time `json:"date" binding:"required"`
Summary string `json:"summary" binding:"required"`
Description string `json:"description" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
ClinicalImpressionID string `json:"clinical_impression_id,omitempty"`
Status string `json:"status" binding:"required,oneof=in-progress completed entered-in-error"`
Code *common.CodeableConceptDTO `json:"code,omitempty"`
Description string `json:"description,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientDisplay string `json:"patient_display,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
EncounterDisplay string `json:"encounter_display,omitempty"`
EffectiveDateTime *time.Time `json:"effective_datetime,omitempty"`
Date *time.Time `json:"date,omitempty"`
AssessorID string `json:"assessor_id,omitempty"`
AssessorDisplay string `json:"assessor_display,omitempty"`
Problem []common.ReferenceDTO `json:"problem,omitempty"`
Investigations []InvestigationDTO `json:"investigations,omitempty"`
Summary string `json:"summary,omitempty"`
Findings []FindingDTO `json:"findings,omitempty"`
Prognosis []*common.CodeableConceptDTO `json:"prognosis,omitempty"`
}
type InvestigationDTO struct {
Code *common.CodeableConceptDTO `json:"code,omitempty"`
Item []common.ReferenceDTO `json:"item,omitempty"`
}
type FindingDTO struct {
ItemCodeableConcept *common.CodeableConceptDTO `json:"item_codeable_concept,omitempty"`
ItemReference *common.ReferenceDTO `json:"item_reference,omitempty"`
}
type ClinicalImpressionPatchRequest []map[string]interface{}
@@ -1,23 +1,186 @@
package clinicalimpression
import (
"os"
"service/internal/interfaces/satusehat"
"time"
)
func MapRequestToFHIR(req ClinicalImpressionRequest) satusehat.FHIRPayload {
return satusehat.NewFHIRPayload("ClinicalImpression").
Set("status", req.Status).
Set("description", req.Description).
Set("summary", req.Summary).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{"reference": "Encounter/" + req.EncounterID}).
Set("assessor", map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
}).
Set("date", req.Date.Format(time.RFC3339))
payload := satusehat.NewFHIRPayload("ClinicalImpression")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.ClinicalImpressionID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/clinicalimpression/" + orgID,
"use": "official",
"value": req.ClinicalImpressionID,
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Code != nil {
sys := req.Code.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.Code.Code}
if req.Code.Display != "" {
coding["display"] = req.Code.Display
}
payload.Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
coding,
},
})
}
if req.Description != "" {
payload.Set("description", req.Description)
}
if req.PatientID != "" {
subject := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientDisplay != "" {
subject["display"] = req.PatientDisplay
}
payload.Set("subject", subject)
}
if req.EncounterID != "" {
encounter := map[string]interface{}{"reference": "Encounter/" + req.EncounterID}
if req.EncounterDisplay != "" {
encounter["display"] = req.EncounterDisplay
}
payload.Set("encounter", encounter)
}
if req.EffectiveDateTime != nil && !req.EffectiveDateTime.IsZero() {
payload.Set("effectiveDateTime", req.EffectiveDateTime.Format(time.RFC3339))
}
if req.Date != nil && !req.Date.IsZero() {
payload.Set("date", req.Date.Format(time.RFC3339))
}
if req.AssessorID != "" {
assessor := map[string]interface{}{"reference": "Practitioner/" + req.AssessorID}
if req.AssessorDisplay != "" {
assessor["display"] = req.AssessorDisplay
}
payload.Set("assessor", assessor)
}
if len(req.Problem) > 0 {
var problems []map[string]interface{}
for _, p := range req.Problem {
problem := map[string]interface{}{"reference": p.Reference}
if p.Display != "" {
problem["display"] = p.Display
}
problems = append(problems, problem)
}
payload.Set("problem", problems)
}
if len(req.Investigations) > 0 {
var investigations []map[string]interface{}
for _, inv := range req.Investigations {
invObject := map[string]interface{}{}
if inv.Code != nil {
codeConcept := map[string]interface{}{}
sys := inv.Code.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": inv.Code.Code}
if inv.Code.Display != "" {
coding["display"] = inv.Code.Display
}
codeConcept["coding"] = []map[string]interface{}{coding}
if inv.Code.Text != "" {
codeConcept["text"] = inv.Code.Text
}
invObject["code"] = codeConcept
}
if len(inv.Item) > 0 {
var items []map[string]interface{}
for _, item := range inv.Item {
ref := map[string]interface{}{"reference": item.Reference}
if item.Display != "" {
ref["display"] = item.Display
}
items = append(items, ref)
}
invObject["item"] = items
}
investigations = append(investigations, invObject)
}
payload.Set("investigation", investigations)
}
if req.Summary != "" {
payload.Set("summary", req.Summary)
}
if len(req.Findings) > 0 {
var findings []map[string]interface{}
for _, f := range req.Findings {
findingObject := map[string]interface{}{}
if f.ItemCodeableConcept != nil {
sys := f.ItemCodeableConcept.System
if sys == "" {
sys = "http://hl7.org/fhir/sid/icd-10"
}
coding := map[string]interface{}{"system": sys, "code": f.ItemCodeableConcept.Code}
if f.ItemCodeableConcept.Display != "" {
coding["display"] = f.ItemCodeableConcept.Display
}
findingObject["itemCodeableConcept"] = map[string]interface{}{
"coding": []map[string]interface{}{coding},
}
}
if f.ItemReference != nil {
ref := map[string]interface{}{"reference": f.ItemReference.Reference}
if f.ItemReference.Display != "" {
ref["display"] = f.ItemReference.Display
}
findingObject["itemReference"] = ref
}
if len(findingObject) > 0 {
findings = append(findings, findingObject)
}
}
payload.Set("finding", findings)
}
if len(req.Prognosis) > 0 {
var prognosis []map[string]interface{}
for _, p := range req.Prognosis {
if p != nil {
sys := p.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": p.Code}
if p.Display != "" {
coding["display"] = p.Display
}
prognosis = append(prognosis, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("prognosisCodeableConcept", prognosis)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -19,14 +20,21 @@ type repository struct{ client satusehat.SatuSehatClient }
func NewRepository(client satusehat.SatuSehatClient) Repository { return &repository{client: client} }
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -24,12 +24,23 @@ func (s *service) Update(ctx context.Context, id string, req ClinicalImpressionR
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.Update(ctx, id, MapRequestToFHIR(req).Set("id", id))
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req ClinicalImpressionPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
+30 -10
View File
@@ -1,17 +1,37 @@
package composition
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type CompositionRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
Title string `json:"title" binding:"required"`
Status string `json:"status" binding:"required,oneof=preliminary final amended entered-in-error"` // preliminary, final, amended, entered-in-error
Date time.Time `json:"date" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
CompositionID string `json:"composition_id,omitempty"`
Status string `json:"status" binding:"required,oneof=preliminary final amended entered-in-error"`
Type *common.CodeableConceptDTO `json:"type" binding:"required"`
Category []*common.CodeableConceptDTO `json:"category,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientDisplay string `json:"patient_display,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
EncounterDisplay string `json:"encounter_display,omitempty"`
Date *time.Time `json:"date" binding:"required"`
Author []common.ReferenceDTO `json:"author" binding:"required,min=1"`
Title string `json:"title" binding:"required"`
Sections []SectionDTO `json:"sections,omitempty"`
}
type SectionDTO struct {
Title string `json:"title,omitempty"`
Code *common.CodeableConceptDTO `json:"code,omitempty"`
Text *NarrativeDTO `json:"text,omitempty"`
Entries []common.ReferenceDTO `json:"entries,omitempty"`
}
type NarrativeDTO struct {
Status string `json:"status,omitempty"`
Div string `json:"div,omitempty"`
}
// CompositionPatchRequest merepresentasikan payload operasi JSON Patch.
type CompositionPatchRequest []map[string]interface{}
+131 -24
View File
@@ -1,38 +1,145 @@
package composition
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req CompositionRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Composition").
Set("status", req.Status).
Set("type", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://loinc.org",
"code": "11503-0", // Example: Medical records
"display": "Medical records",
},
},
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("date", req.Date.Format(time.RFC3339)).
Set("title", req.Title)
payload := satusehat.NewFHIRPayload("Composition")
if req.PractitionerID != "" {
payload.Append("author", map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.CompositionID != "" {
payload.Set("identifier", map[string]interface{}{
"system": "http://sys-ids.kemkes.go.id/composition/" + orgID,
"value": req.CompositionID,
})
}
status := req.Status
if req.Status != "" {
status = req.Status
} else {
status = "final"
}
payload.Set("status", status)
if req.Type != nil {
sys := req.Type.System
if sys == "" {
sys = "http://loinc.org"
}
coding := map[string]interface{}{"system": sys, "code": req.Type.Code}
if req.Type.Display != "" {
coding["display"] = req.Type.Display
}
typeConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Type.Text != "" {
typeConcept["text"] = req.Type.Text
}
payload.Set("type", typeConcept)
}
if len(req.Category) > 0 {
var categories []map[string]interface{}
for _, c := range req.Category {
if c != nil {
sys := c.System
if sys == "" {
sys = "http://loinc.org"
}
coding := map[string]interface{}{"system": sys, "code": c.Code}
if c.Display != "" {
coding["display"] = c.Display
}
categories = append(categories, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("category", categories)
}
if req.PatientID != "" {
subj := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientDisplay != "" {
subj["display"] = req.PatientDisplay
}
payload.Set("subject", subj)
}
if req.EncounterID != "" {
enc := map[string]interface{}{"reference": "Encounter/" + req.EncounterID}
if req.EncounterDisplay != "" {
enc["display"] = req.EncounterDisplay
}
payload.Set("encounter", enc)
}
if req.Date != nil && !req.Date.IsZero() {
payload.Set("date", req.Date.Format(time.RFC3339))
}
if req.Title != "" {
payload.Set("title", req.Title)
}
if len(req.Author) > 0 {
var authors []map[string]interface{}
for _, a := range req.Author {
author := map[string]interface{}{"reference": a.Reference}
if a.Display != "" {
author["display"] = a.Display
}
authors = append(authors, author)
}
payload.Set("author", authors)
}
if len(req.Sections) > 0 {
var sections []map[string]interface{}
for _, s := range req.Sections {
sec := map[string]interface{}{}
if s.Title != "" {
sec["title"] = s.Title
}
if s.Code != nil {
sys := s.Code.System
if sys == "" {
sys = "http://loinc.org"
}
coding := map[string]interface{}{"system": sys, "code": s.Code.Code}
if s.Code.Display != "" {
coding["display"] = s.Code.Display
}
sec["code"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if s.Text != nil {
status := s.Text.Status
if status == "" {
status = "generated"
}
sec["text"] = map[string]interface{}{
"status": status,
"div": s.Text.Div,
}
}
if len(s.Entries) > 0 {
var entries []map[string]interface{}
for _, e := range s.Entries {
entry := map[string]interface{}{"reference": e.Reference}
if e.Display != "" {
entry["display"] = e.Display
}
entries = append(entries, entry)
}
sec["entry"] = entries
}
sections = append(sections, sec)
}
payload.Set("section", sections)
}
return payload
}
@@ -7,6 +7,7 @@ import (
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -25,22 +26,26 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
if err != nil {
return nil, err
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
resourceID = id
}
return &satusehat.FHIRResponse{
ID: resourceID,
FullResponse: result,
@@ -25,17 +25,14 @@ func NewService(repo Repository) Service {
}
func (s *service) Create(ctx context.Context, req CompositionRequest) (*satusehat.FHIRResponse, error) {
fhirPayload := MapRequestToFHIR(req)
return s.repo.Create(ctx, fhirPayload)
return s.repo.Create(ctx, MapRequestToFHIR(req))
}
func (s *service) Update(ctx context.Context, id string, req CompositionRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Composition ID is required").Build()
}
fhirPayload := MapRequestToFHIR(req)
fhirPayload.Set("id", id)
return s.repo.Update(ctx, id, fhirPayload)
}
func (s *service) Patch(ctx context.Context, id string, req CompositionPatchRequest) (*satusehat.FHIRResponse, error) {
@@ -48,6 +45,9 @@ func (s *service) Patch(ctx context.Context, id string, req CompositionPatchRequ
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Composition ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
+15 -11
View File
@@ -1,18 +1,22 @@
package condition
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type ConditionRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
ClinicalStatus string `json:"clinical_status" binding:"required,oneof=active recurrence relapse inactive remission resolved"` // active, recurrence, relapse, inactive, remission, resolved
CategoryCode string `json:"category_code" binding:"required"` // problem-list-item, encounter-diagnosis
CategoryDisplay string `json:"category_display" binding:"required"`
Code string `json:"code" binding:"required"` // SNOMED CT / ICD-10 code
Display string `json:"display" binding:"required"` // Diagnosis text
OnsetDateTime time.Time `json:"onset_date_time" binding:"required"`
RecordedDate time.Time `json:"recorded_date" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
ConditionID string `json:"condition_id,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
ClinicalStatus string `json:"clinical_status" binding:"required,oneof=active recurrence relapse inactive remission resolved"`
Category *common.CodeableConceptDTO `json:"category,omitempty"`
Code *common.CodeableConceptDTO `json:"code,omitempty" binding:"required"`
OnsetDateTime *time.Time `json:"onset_date_time,omitempty"`
RecordedDate *time.Time `json:"recorded_date,omitempty"`
}
type ConditionPatchRequest []map[string]interface{}
+71 -30
View File
@@ -1,50 +1,91 @@
package condition
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req ConditionRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Condition").
Set("clinicalStatus", map[string]interface{}{
payload := satusehat.NewFHIRPayload("Condition")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.ConditionID != "" {
payload.Set("identifier", []map[string]interface{}{
{"system": "http://sys-ids.kemkes.go.id/condition/" + orgID, "use": "official", "value": req.ConditionID},
})
}
if req.ClinicalStatus != "" {
payload.Set("clinicalStatus", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": req.ClinicalStatus,
},
{"system": "http://terminology.hl7.org/CodeSystem/condition-clinical", "code": req.ClinicalStatus},
},
}).
Set("category", []map[string]interface{}{
})
}
if req.Category != nil {
sys := req.Category.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/condition-category"
}
coding := map[string]interface{}{
"system": sys,
"code": req.Category.Code,
}
if req.Category.Display != "" {
coding["display"] = req.Category.Display
}
payload.Set("category", []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/condition-category",
"code": req.CategoryCode,
"display": req.CategoryDisplay,
},
coding,
},
},
}).
Set("code", map[string]interface{}{
})
}
if req.Code != nil {
sys := req.Code.System
if sys == "" {
sys = "http://snomed.info/sct" // Default value jika tidak diset
}
coding := map[string]interface{}{
"system": sys,
"code": req.Code.Code,
}
if req.Code.Display != "" {
coding["display"] = req.Code.Display
}
codeConcept := map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://snomed.info/sct",
"code": req.Code,
"display": req.Display,
},
coding,
},
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("onsetDateTime", req.OnsetDateTime.Format(time.RFC3339)).
Set("recordedDate", req.RecordedDate.Format(time.RFC3339))
}
if req.Code.Text != "" {
codeConcept["text"] = req.Code.Text
}
payload.Set("code", codeConcept)
}
if req.PatientID != "" {
subject := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientName != "" {
subject["display"] = req.PatientName
}
payload.Set("subject", subject)
}
if req.EncounterID != "" {
payload.Set("encounter", map[string]interface{}{"reference": "Encounter/" + req.EncounterID})
}
if req.OnsetDateTime != nil && !req.OnsetDateTime.IsZero() {
payload.Set("onsetDateTime", req.OnsetDateTime.Format(time.RFC3339))
}
if req.RecordedDate != nil && !req.RecordedDate.IsZero() {
payload.Set("recordedDate", req.RecordedDate.Format(time.RFC3339))
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -24,15 +25,23 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
@@ -40,11 +49,7 @@ func (r *repository) executeRequest(ctx context.Context, method, endpoint string
resourceID = id
}
return &satusehat.FHIRResponse{
ID: resourceID,
FullResponse: result,
RawResponse: resp,
}, nil
return &satusehat.FHIRResponse{ID: resourceID, FullResponse: result, RawResponse: resp}, nil
}
func (r *repository) Create(ctx context.Context, payload interface{}) (*satusehat.FHIRResponse, error) {
@@ -57,6 +57,5 @@ func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRRespon
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
// Tambahkan validasi untuk query params jika diperlukan
return s.repo.Search(ctx, queryParams)
}
@@ -1,20 +1,28 @@
package diagnosticreport
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type DiagnosticReportRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
Status string `json:"status" binding:"required,oneof=registered partial preliminary final amended corrected appended cancelled entered-in-error unknown"`
CategoryCode string `json:"category_code" binding:"required"` // e.g. LAB
CategoryDisplay string `json:"category_display" binding:"required"`
Code string `json:"code" binding:"required"` // LOINC code
Display string `json:"display" binding:"required"`
Issued time.Time `json:"issued" binding:"required"`
Conclusion string `json:"conclusion" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
DiagnosticID string `json:"diagnostic_id,omitempty"` // e.g. "5234342"
Status string `json:"status" binding:"required,oneof=registered partial preliminary final amended corrected appended cancelled entered-in-error unknown"`
Category []*common.CodeableConceptDTO `json:"category,omitempty"`
Code *common.CodeableConceptDTO `json:"code" binding:"required"`
PatientID string `json:"patient_id" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
EffectiveDateTime *time.Time `json:"effective_datetime,omitempty"`
Issued *time.Time `json:"issued,omitempty"`
Performer []common.ReferenceDTO `json:"performer,omitempty"`
Result []common.ReferenceDTO `json:"result,omitempty"`
Specimen []common.ReferenceDTO `json:"specimen,omitempty"`
BasedOn []common.ReferenceDTO `json:"based_on,omitempty"`
ImagingStudy []common.ReferenceDTO `json:"imaging_study,omitempty"`
ConclusionCode []*common.CodeableConceptDTO `json:"conclusion_code,omitempty"`
Conclusion string `json:"conclusion,omitempty"`
}
type DiagnosticReportPatchRequest []map[string]interface{}
@@ -1,41 +1,169 @@
package diagnosticreport
import (
"service/internal/interfaces/satusehat"
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req DiagnosticReportRequest) satusehat.FHIRPayload {
return satusehat.NewFHIRPayload("DiagnosticReport").
Set("status", req.Status).
Set("category", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("DiagnosticReport")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.DiagnosticID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/v2-0074",
"code": req.CategoryCode,
"display": req.CategoryDisplay,
},
},
"system": "http://sys-ids.kemkes.go.id/diagnostic/" + orgID + "/lab",
"use": "official",
"value": req.DiagnosticID,
},
}).
Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://loinc.org",
"code": req.Code,
"display": req.Display,
},
},
}).
Set("subject", map[string]interface{}{
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if len(req.Category) > 0 {
var categories []map[string]interface{}
for _, c := range req.Category {
if c != nil {
sys := c.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/v2-0074"
}
coding := map[string]interface{}{"system": sys, "code": c.Code}
if c.Display != "" {
coding["display"] = c.Display
}
categories = append(categories, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("category", categories)
}
if req.Code != nil {
sys := req.Code.System
if sys == "" {
sys = "http://loinc.org"
}
coding := map[string]interface{}{"system": sys, "code": req.Code.Code}
if req.Code.Display != "" {
coding["display"] = req.Code.Display
}
codeConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Code.Text != "" {
codeConcept["text"] = req.Code.Text
}
payload.Set("code", codeConcept)
}
if req.PatientID != "" {
payload.Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{"reference": "Encounter/" + req.EncounterID}).
Set("performer", []map[string]interface{}{
{"reference": "Practitioner/" + req.PractitionerID, "display": req.PractitionerName},
}).
Set("issued", req.Issued.Format(time.RFC3339)).
Set("conclusion", req.Conclusion)
})
}
if req.EncounterID != "" {
payload.Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
})
}
if req.EffectiveDateTime != nil && !req.EffectiveDateTime.IsZero() {
payload.Set("effectiveDateTime", req.EffectiveDateTime.Format(time.RFC3339))
}
if req.Issued != nil && !req.Issued.IsZero() {
payload.Set("issued", req.Issued.Format(time.RFC3339))
}
if len(req.Performer) > 0 {
var performers []map[string]interface{}
for _, p := range req.Performer {
perf := map[string]interface{}{"reference": p.Reference}
if p.Display != "" {
perf["display"] = p.Display
}
performers = append(performers, perf)
}
payload.Set("performer", performers)
}
if len(req.Result) > 0 {
var results []map[string]interface{}
for _, r := range req.Result {
res := map[string]interface{}{"reference": r.Reference}
if r.Display != "" {
res["display"] = r.Display
}
results = append(results, res)
}
payload.Set("result", results)
}
if len(req.Specimen) > 0 {
var specimens []map[string]interface{}
for _, s := range req.Specimen {
spec := map[string]interface{}{"reference": s.Reference}
if s.Display != "" {
spec["display"] = s.Display
}
specimens = append(specimens, spec)
}
payload.Set("specimen", specimens)
}
if len(req.BasedOn) > 0 {
var basedOns []map[string]interface{}
for _, b := range req.BasedOn {
bo := map[string]interface{}{"reference": b.Reference}
if b.Display != "" {
bo["display"] = b.Display
}
basedOns = append(basedOns, bo)
}
payload.Set("basedOn", basedOns)
}
if len(req.ImagingStudy) > 0 {
var imagingStudies []map[string]interface{}
for _, i := range req.ImagingStudy {
is := map[string]interface{}{"reference": i.Reference}
if i.Display != "" {
is["display"] = i.Display
}
imagingStudies = append(imagingStudies, is)
}
payload.Set("imagingStudy", imagingStudies)
}
if len(req.ConclusionCode) > 0 {
var concCodes []map[string]interface{}
for _, c := range req.ConclusionCode {
if c != nil {
sys := c.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": c.Code}
if c.Display != "" {
coding["display"] = c.Display
}
concCodes = append(concCodes, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("conclusionCode", concCodes)
}
if req.Conclusion != "" {
payload.Set("conclusion", req.Conclusion)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -19,14 +20,21 @@ type repository struct{ client satusehat.SatuSehatClient }
func NewRepository(client satusehat.SatuSehatClient) Repository { return &repository{client: client} }
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -24,12 +24,23 @@ func (s *service) Update(ctx context.Context, id string, req DiagnosticReportReq
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.Update(ctx, id, MapRequestToFHIR(req).Set("id", id))
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req DiagnosticReportPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
+31 -35
View File
@@ -1,44 +1,40 @@
package encounter
import (
"database/sql"
"time"
"service/internal/satusehat/common"
"service/pkg/utils/custom"
)
// EncounterRequest merepresentasikan data input untuk membuat atau memperbarui Encounter.
type EncounterRequest struct {
EncounterID string `json:"encounter_id" binding:"required"`
OrganizationID string `json:"organization_id" binding:"required"`
EpisodeOfCareID string `json:"episode_of_care_id,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
LocationID string `json:"location_id" binding:"required"`
LocationName string `json:"location_name" binding:"required"`
Status string `json:"status" binding:"required,oneof=arrived in-progress finished cancelled"` // arrived, in-progress, finished, cancelled
Class string `json:"class" binding:"required,oneof=AMB IMP EMER"` // AMB (ambulatory), IMP (inpatient), EMER (emergency)
PeriodStart time.Time `json:"period_start" binding:"required"`
PeriodEnd *time.Time `json:"period_end,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
EncounterID string `json:"encounter_id,omitempty"`
Status string `json:"status" binding:"required,oneof=planned arrived triaged in-progress onleave finished cancelled entered-in-error unknown"`
Class string `json:"class" binding:"required,oneof=AMB EMER IMP"`
Subject *common.ReferenceDTO `json:"subject" binding:"required"`
Participant []common.ReferenceDTO `json:"participant,omitempty"`
Location []LocationDTO `json:"location,omitempty"`
PeriodStart *custom.CustomTime `json:"period_start" binding:"required"`
PeriodEnd *custom.CustomTime `json:"period_end,omitempty"`
Diagnosis []DiagnosisDTO `json:"diagnosis,omitempty"`
StatusHistory []StatusHistoryDTO `json:"status_history,omitempty"`
}
type LocationDTO struct {
Location *common.ReferenceDTO `json:"location,omitempty"`
PeriodStart *custom.CustomTime `json:"period_start,omitempty"`
PeriodEnd *custom.CustomTime `json:"period_end,omitempty"`
}
type DiagnosisDTO struct {
Condition *common.ReferenceDTO `json:"condition,omitempty"`
Use *common.CodeableConceptDTO `json:"use,omitempty"`
Rank int `json:"rank,omitempty"`
}
type StatusHistoryDTO struct {
Status string `json:"status,omitempty"`
PeriodStart *custom.CustomTime `json:"period_start,omitempty"`
PeriodEnd *custom.CustomTime `json:"period_end,omitempty"`
}
// EncounterPatchRequest merepresentasikan payload operasi JSON Patch.
type EncounterPatchRequest []map[string]interface{}
// PendaftaranDB merepresentasikan baris data dari tabel t_pendaftaran
type PendaftaranDB struct {
IdxDaftar int64 `db:"idxdaftar"`
NoMR sql.NullString `db:"nomr"`
JamReg sql.NullTime `db:"jamreg"`
MasukPoly sql.NullTime `db:"masukpoly"`
KeluarPoly sql.NullTime `db:"keluarpoly"`
Batal sql.NullString `db:"batal"`
KdPoly sql.NullInt64 `db:"kdpoly"`
PoliNameHFIS sql.NullString `db:"poli_name_hfis"`
DokterIDHFIS sql.NullString `db:"dokter_id_hfis"`
DokterNameHFIS sql.NullString `db:"dokter_name_hfis"`
PasienNIK sql.NullString `db:"pasien_nik"`
DokterNIK sql.NullString `db:"dokter_nik"`
PatientIHS sql.NullString `db:"patient_ihs"`
PractitionerIHS sql.NullString `db:"practitioner_ihs"`
}
+147 -111
View File
@@ -1,141 +1,177 @@
package encounter
import (
"fmt"
"os"
"time"
"service/internal/interfaces/satusehat"
)
// MapRequestToFHIR mengubah EncounterRequest (DTO internal) menjadi objek Payload FHIR.
// Penggunaan FHIRPayload (.Set dan .Append) akan memudahkan penulisan dan maintenance JSON yang kompleks.
func MapRequestToFHIR(req EncounterRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Encounter").
Set("status", req.Status).
Set("class", map[string]interface{}{
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
payload := satusehat.NewFHIRPayload("Encounter")
if orgID != "" && req.EncounterID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/encounter/" + orgID,
"value": req.EncounterID,
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Class != "" {
classDisplay := "ambulatory"
switch req.Class {
case "EMER":
classDisplay = "emergency"
case "IMP":
classDisplay = "inpatient encounter"
}
payload.Set("class", map[string]interface{}{
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": req.Class,
"display": getClassDisplay(req.Class),
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
"display": classDisplay,
})
}
if req.OrganizationID != "" {
payload.Set("serviceProvider", map[string]interface{}{
"reference": "Organization/" + req.OrganizationID,
})
if req.EncounterID != "" {
payload.Append("identifier", map[string]interface{}{
"system": "http://sys-ids.kemkes.go.id/encounter/" + req.OrganizationID,
"value": req.EncounterID,
})
if req.Subject != nil {
subj := map[string]interface{}{"reference": req.Subject.Reference}
if req.Subject.Display != "" {
subj["display"] = req.Subject.Display
}
payload.Set("subject", subj)
}
if req.EpisodeOfCareID != "" {
payload.Append("episodeOfCare", map[string]interface{}{
"reference": "EpisodeOfCare/" + req.EpisodeOfCareID,
})
}
if req.PractitionerID != "" {
payload.Append("participant", map[string]interface{}{
"type": []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationType",
"code": "ATND",
"display": "attender",
if len(req.Participant) > 0 {
var participants []map[string]interface{}
for _, p := range req.Participant {
ind := map[string]interface{}{"reference": p.Reference}
if p.Display != "" {
ind["display"] = p.Display
}
participants = append(participants, map[string]interface{}{
"type": []map[string]interface{}{
{
"coding": []map[string]interface{}{
{"system": "http://terminology.hl7.org/CodeSystem/v3-ParticipationType", "code": "ATND", "display": "attender"},
},
},
},
},
"individual": map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
},
})
"individual": ind,
})
}
payload.Set("participant", participants)
}
if req.LocationID != "" {
payload.Append("location", map[string]interface{}{
"location": map[string]interface{}{
"reference": "Location/" + req.LocationID,
"display": req.LocationName,
},
})
if len(req.Location) > 0 {
var locations []map[string]interface{}
for _, l := range req.Location {
if l.Location != nil {
locRef := map[string]interface{}{"reference": l.Location.Reference}
if l.Location.Display != "" {
locRef["display"] = l.Location.Display
}
locEntry := map[string]interface{}{"location": locRef}
period := map[string]interface{}{}
if l.PeriodStart != nil && !l.PeriodStart.IsZero() {
period["start"] = l.PeriodStart.Format(time.RFC3339)
}
if l.PeriodEnd != nil && !l.PeriodEnd.IsZero() {
period["end"] = l.PeriodEnd.Format(time.RFC3339)
}
if len(period) > 0 {
locEntry["period"] = period
}
locations = append(locations, locEntry)
}
}
payload.Set("location", locations)
}
period := map[string]interface{}{
"start": req.PeriodStart.Format(time.RFC3339),
period := map[string]interface{}{}
if req.PeriodStart != nil && !req.PeriodStart.IsZero() {
period["start"] = req.PeriodStart.Format(time.RFC3339)
}
if req.PeriodEnd != nil {
if req.PeriodEnd != nil && !req.PeriodEnd.IsZero() {
period["end"] = req.PeriodEnd.Format(time.RFC3339)
}
payload.Set("period", period)
if len(period) > 0 {
payload.Set("period", period)
}
payload.Append("statusHistory", map[string]interface{}{
"status": req.Status,
"period": period,
})
if len(req.Diagnosis) > 0 {
var diagnoses []map[string]interface{}
for _, d := range req.Diagnosis {
diag := map[string]interface{}{}
if d.Condition != nil {
cond := map[string]interface{}{"reference": d.Condition.Reference}
if d.Condition.Display != "" {
cond["display"] = d.Condition.Display
}
diag["condition"] = cond
}
if d.Use != nil {
sys := d.Use.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/diagnosis-role"
}
useCoding := map[string]interface{}{"system": sys, "code": d.Use.Code}
if d.Use.Display != "" {
useCoding["display"] = d.Use.Display
}
diag["use"] = map[string]interface{}{"coding": []map[string]interface{}{useCoding}}
}
if d.Rank > 0 {
diag["rank"] = d.Rank
}
diagnoses = append(diagnoses, diag)
}
payload.Set("diagnosis", diagnoses)
}
if len(req.StatusHistory) > 0 {
var statusHistories []map[string]interface{}
for _, sh := range req.StatusHistory {
shEntry := map[string]interface{}{"status": sh.Status}
shp := map[string]interface{}{}
if sh.PeriodStart != nil && !sh.PeriodStart.IsZero() {
shp["start"] = sh.PeriodStart.Format(time.RFC3339)
}
if sh.PeriodEnd != nil && !sh.PeriodEnd.IsZero() {
shp["end"] = sh.PeriodEnd.Format(time.RFC3339)
}
if len(shp) > 0 {
shEntry["period"] = shp
}
statusHistories = append(statusHistories, shEntry)
}
payload.Set("statusHistory", statusHistories)
} else if req.Status != "" && req.PeriodStart != nil && !req.PeriodStart.IsZero() {
payload.Set("statusHistory", []map[string]interface{}{
{
"status": req.Status,
"period": map[string]interface{}{
"start": req.PeriodStart.Format(time.RFC3339),
},
},
})
}
if orgID != "" {
payload.Set("serviceProvider", map[string]interface{}{
"reference": "Organization/" + orgID,
})
}
return payload
}
// MapPendaftaranToRequest mengubah data database t_pendaftaran menjadi EncounterRequest
func MapPendaftaranToRequest(dbData PendaftaranDB, organizationID string, patientIHS string, practitionerIHS string) EncounterRequest {
// Penentuan status encounter
status := "arrived"
if dbData.Batal.Valid && dbData.Batal.String == "Y" {
status = "cancelled"
} else if dbData.KeluarPoly.Valid {
status = "finished"
} else if dbData.MasukPoly.Valid {
status = "in-progress"
}
// Waktu mulai
periodStart := time.Now()
if dbData.MasukPoly.Valid {
periodStart = dbData.MasukPoly.Time
} else if dbData.JamReg.Valid {
periodStart = dbData.JamReg.Time
}
var periodEnd *time.Time
if dbData.KeluarPoly.Valid {
periodEnd = &dbData.KeluarPoly.Time
}
return EncounterRequest{
EncounterID: fmt.Sprintf("%d", dbData.IdxDaftar),
OrganizationID: organizationID,
PatientID: patientIHS, // Hasil pemetaan/pencarian IHS
PatientName: "Pasien " + dbData.NoMR.String, // Fallback Name
PractitionerID: practitionerIHS, // Hasil pemetaan/pencarian IHS
PractitionerName: dbData.DokterNameHFIS.String,
LocationID: fmt.Sprintf("%d", dbData.KdPoly.Int64), // Catatan: Anda harus me-mapping ini ke IHS Location ID
LocationName: dbData.PoliNameHFIS.String,
Status: status,
Class: "AMB", // Default: Rawat Jalan (Ambulatory)
PeriodStart: periodStart,
PeriodEnd: periodEnd,
}
}
func getClassDisplay(code string) string {
switch code {
case "AMB":
return "ambulatory"
case "IMP":
return "inpatient encounter"
case "EMER":
return "emergency"
default:
return "ambulatory"
}
}
@@ -2,12 +2,12 @@ package encounter
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/url"
"service/internal/infrastructure/database"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -16,7 +16,6 @@ type Repository interface {
Patch(ctx context.Context, id string, payload EncounterPatchRequest) (*satusehat.FHIRResponse, error)
GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error)
Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error)
GetPendaftaranByID(ctx context.Context, idxdaftar int64) (*PendaftaranDB, error)
SearchPatientByNIK(ctx context.Context, nik string) (*satusehat.FHIRResponse, error)
SearchPractitionerByNIK(ctx context.Context, nik string) (*satusehat.FHIRResponse, error)
}
@@ -30,10 +29,14 @@ func NewRepository(client satusehat.SatuSehatClient, db database.Service) Reposi
return &repository{client: client, db: db}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, payload interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, payload)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, payload)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
return r.parseAndProcessResponse(resp)
}
@@ -41,23 +44,16 @@ func (r *repository) executeRequest(ctx context.Context, method, endpoint string
func (r *repository) parseAndProcessResponse(data []byte) (*satusehat.FHIRResponse, error) {
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(data)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
// Ekstrak ID dari resource jika ada
var resourceID string
if id, ok := result["id"].(string); ok {
resourceID = id
}
// Buat response terstruktur
fhirResponse := &satusehat.FHIRResponse{
ID: resourceID,
FullResponse: result,
RawResponse: data, // Simpan data mentah untuk logging atau audit
}
return fhirResponse, nil
return &satusehat.FHIRResponse{ID: resourceID, FullResponse: result, RawResponse: data}, nil
}
func (r *repository) Create(ctx context.Context, payload interface{}) (*satusehat.FHIRResponse, error) {
@@ -65,66 +61,25 @@ func (r *repository) Create(ctx context.Context, payload interface{}) (*satuseha
}
func (r *repository) Update(ctx context.Context, id string, payload interface{}) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Encounter/%s", id)
return r.executeRequest(ctx, "PUT", endpoint, payload)
return r.executeRequest(ctx, "PUT", fmt.Sprintf("/Encounter/%s", id), payload)
}
func (r *repository) Patch(ctx context.Context, id string, payload EncounterPatchRequest) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Encounter/%s", id)
return r.executeRequest(ctx, "PATCH", endpoint, payload)
return r.executeRequest(ctx, "PATCH", fmt.Sprintf("/Encounter/%s", id), payload)
}
func (r *repository) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Encounter/%s", id)
return r.executeRequest(ctx, "GET", endpoint, nil)
return r.executeRequest(ctx, "GET", fmt.Sprintf("/Encounter/%s", id), nil)
}
func (r *repository) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Encounter?%s", queryParams.Encode())
return r.executeRequest(ctx, "GET", endpoint, nil)
return r.executeRequest(ctx, "GET", fmt.Sprintf("/Encounter?%s", queryParams.Encode()), nil)
}
func (r *repository) SearchPatientByNIK(ctx context.Context, nik string) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Patient?identifier=https://fhir.kemkes.go.id/id/nik|%s", nik)
return r.executeRequest(ctx, "GET", endpoint, nil)
return r.executeRequest(ctx, "GET", fmt.Sprintf("/Patient?identifier=https://fhir.kemkes.go.id/id/nik|%s", nik), nil)
}
func (r *repository) SearchPractitionerByNIK(ctx context.Context, nik string) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Practitioner?identifier=https://fhir.kemkes.go.id/id/nik|%s", nik)
return r.executeRequest(ctx, "GET", endpoint, nil)
}
func (r *repository) GetPendaftaranByID(ctx context.Context, idxdaftar int64) (*PendaftaranDB, error) {
// Ambil koneksi database internal
db, err := r.db.GetReadDB("default")
if err != nil {
return nil, err
}
var p PendaftaranDB
query := `
SELECT p.idxdaftar, p.nomr, p.jamreg, p.masukpoly, p.keluarpoly, p.batal,
p.kdpoly, p.poli_name_hfis, p.dokter_id_hfis, p.dokter_name_hfis,
COALESCE(NULLIF(mp.noktp_baru, ''), mp.noktp) AS pasien_nik,
dp.nik AS dokter_nik,
dpas."Nomor_satusehat" AS patient_ihs,
dp."Kode_satusehat" AS practitioner_ihs
FROM public.t_pendaftaran p
LEFT JOIN public.m_pasien mp ON p.nomr = mp.nomr
LEFT JOIN public.data_pasien dpas ON p.nomr = dpas."Nomor_rekamedik"
LEFT JOIN public.data_pegawai dp ON p.kddokter = dp."Kode_DPJP"
WHERE p.idxdaftar = $1`
err = db.QueryRowContext(ctx, query, idxdaftar).Scan(
&p.IdxDaftar, &p.NoMR, &p.JamReg, &p.MasukPoly, &p.KeluarPoly, &p.Batal,
&p.KdPoly, &p.PoliNameHFIS, &p.DokterIDHFIS, &p.DokterNameHFIS,
&p.PasienNIK, &p.DokterNIK, &p.PatientIHS, &p.PractitionerIHS,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("pendaftaran dengan idxdaftar %d tidak ditemukan", idxdaftar)
}
return nil, err
}
return &p, nil
return r.executeRequest(ctx, "GET", fmt.Sprintf("/Practitioner?identifier=https://fhir.kemkes.go.id/id/nik|%s", nik), nil)
}
@@ -2,10 +2,7 @@ package encounter
import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
@@ -16,31 +13,25 @@ type Service interface {
Patch(ctx context.Context, id string, req EncounterPatchRequest) (*satusehat.FHIRResponse, error)
GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error)
Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error)
SyncFromSIMRS(ctx context.Context, idxdaftar int64) (*satusehat.FHIRResponse, error)
}
type service struct {
repo Repository
}
type service struct{ repo Repository }
func NewService(repo Repository) Service {
return &service{repo: repo}
}
func (s *service) Create(ctx context.Context, req EncounterRequest) (*satusehat.FHIRResponse, error) {
fhirPayload := MapRequestToFHIR(req)
return s.repo.Create(ctx, fhirPayload)
return s.repo.Create(ctx, MapRequestToFHIR(req))
}
func (s *service) Update(ctx context.Context, id string, req EncounterRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Encounter ID is required").Build()
}
fhirPayload := MapRequestToFHIR(req)
fhirPayload.Set("id", id) // Untuk Update (PUT), parameter ID di payload diwajibkan oleh standar API Kemenkes
return s.repo.Update(ctx, id, fhirPayload)
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req EncounterPatchRequest) (*satusehat.FHIRResponse, error) {
@@ -61,62 +52,5 @@ func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRRespon
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
// Bisa ditambahkan validasi query params wajib di sini jika perlu
return s.repo.Search(ctx, queryParams)
}
func (s *service) SyncFromSIMRS(ctx context.Context, idxdaftar int64) (*satusehat.FHIRResponse, error) {
// 1. Ambil data dari database SIMRS
pendaftaran, err := s.repo.GetPendaftaranByID(ctx, idxdaftar)
if err != nil {
return nil, fmt.Errorf("gagal mengambil data pendaftaran: %w", err)
}
// 2. Ambil Organization ID RS Anda (Bisa dari environment variable atau parameter config)
orgID := os.Getenv("SATUSEHAT_ORGANIZATION_ID") // Sesuaikan bila ada package config terpisah
// 3. Persiapkan Patient ID (IHS) dengan fallback pencarian ke NIK
patientIHS := pendaftaran.PatientIHS.String
if patientIHS == "" && pendaftaran.PasienNIK.Valid && pendaftaran.PasienNIK.String != "" {
resp, err := s.repo.SearchPatientByNIK(ctx, pendaftaran.PasienNIK.String)
if err == nil && resp != nil {
patientIHS = extractIDFromBundle(resp.RawResponse)
}
}
if patientIHS == "" { // Fallback terakhir (jaga-jaga agar mapping tak panic)
patientIHS = pendaftaran.NoMR.String
}
// 4. Persiapkan Practitioner ID (IHS) dengan fallback pencarian ke NIK
practitionerIHS := pendaftaran.PractitionerIHS.String
if practitionerIHS == "" && pendaftaran.DokterNIK.Valid && pendaftaran.DokterNIK.String != "" {
resp, err := s.repo.SearchPractitionerByNIK(ctx, pendaftaran.DokterNIK.String)
if err == nil && resp != nil {
practitionerIHS = extractIDFromBundle(resp.RawResponse)
}
}
if practitionerIHS == "" { // Fallback terakhir
practitionerIHS = pendaftaran.DokterIDHFIS.String
}
// 5. Mapping data database ke struktur Request Encounter API
req := MapPendaftaranToRequest(*pendaftaran, orgID, patientIHS, practitionerIHS)
// 6. Proses Create/Kirim ke Satu Sehat menggunakan fungsi Create yang sudah ada
return s.Create(ctx, req)
}
// extractIDFromBundle mem-parsing raw response FHIR Bundle untuk mengambil ID resource pertama
func extractIDFromBundle(data []byte) string {
var bundle struct {
Entry []struct {
Resource struct {
ID string `json:"id"`
} `json:"resource"`
} `json:"entry"`
}
if err := json.Unmarshal(data, &bundle); err == nil && len(bundle.Entry) > 0 {
return bundle.Entry[0].Resource.ID
}
return ""
}
@@ -1,14 +1,36 @@
package episodeofcare
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type EpisodeOfCareRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
OrganizationID string `json:"organization_id" binding:"required"`
Status string `json:"status" binding:"required,oneof=planned waitlist active onhold finished cancelled entered-in-error"` // planned, waitlist, active, onhold, finished, cancelled, entered-in-error
PeriodStart time.Time `json:"period_start" binding:"required"`
PeriodEnd *time.Time `json:"period_end,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
EpisodeOfCareID string `json:"episode_of_care_id,omitempty"` // cth: "EOC12345"
Status string `json:"status" binding:"required,oneof=planned waitlist active onhold finished cancelled entered-in-error"`
StatusHistory []StatusHistoryDTO `json:"status_history,omitempty"`
Type []*common.CodeableConceptDTO `json:"type,omitempty"`
Diagnosis []DiagnosisDTO `json:"diagnosis,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name,omitempty"`
ManagingOrganization string `json:"managing_organization,omitempty"`
PeriodStart *time.Time `json:"period_start,omitempty"`
PeriodEnd *time.Time `json:"period_end,omitempty"`
CareManager *common.ReferenceDTO `json:"care_manager,omitempty"`
}
type StatusHistoryDTO struct {
Status string `json:"status,omitempty"`
PeriodStart *time.Time `json:"period_start,omitempty"`
PeriodEnd *time.Time `json:"period_end,omitempty"`
}
type DiagnosisDTO struct {
Condition *common.ReferenceDTO `json:"condition,omitempty"`
Role *common.CodeableConceptDTO `json:"role,omitempty"`
Rank int `json:"rank,omitempty"`
}
type EpisodeOfCarePatchRequest []map[string]interface{}
@@ -1,30 +1,141 @@
package episodeofcare
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req EpisodeOfCareRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("EpisodeOfCare").
Set("status", req.Status).
Set("patient", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
})
payload := satusehat.NewFHIRPayload("EpisodeOfCare")
if req.OrganizationID != "" {
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.EpisodeOfCareID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/episode-of-care/" + orgID,
"value": req.EpisodeOfCareID,
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if len(req.StatusHistory) > 0 {
var statusHistory []map[string]interface{}
for _, sh := range req.StatusHistory {
historyItem := map[string]interface{}{"status": sh.Status}
period := map[string]interface{}{}
if sh.PeriodStart != nil && !sh.PeriodStart.IsZero() {
period["start"] = sh.PeriodStart.Format(time.RFC3339)
}
if sh.PeriodEnd != nil && !sh.PeriodEnd.IsZero() {
period["end"] = sh.PeriodEnd.Format(time.RFC3339)
}
if len(period) > 0 {
historyItem["period"] = period
}
statusHistory = append(statusHistory, historyItem)
}
payload.Set("statusHistory", statusHistory)
}
if len(req.Type) > 0 {
var types []map[string]interface{}
for _, t := range req.Type {
if t != nil {
sys := t.System
if sys == "" {
sys = "http://terminology.kemkes.go.id/CodeSystem/episodeofcare-type"
}
coding := map[string]interface{}{"system": sys, "code": t.Code}
if t.Display != "" {
coding["display"] = t.Display
}
typeConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if t.Text != "" {
typeConcept["text"] = t.Text
}
types = append(types, typeConcept)
}
}
payload.Set("type", types)
}
if len(req.Diagnosis) > 0 {
var diagnoses []map[string]interface{}
for _, d := range req.Diagnosis {
diagnosisItem := map[string]interface{}{}
if d.Condition != nil {
condition := map[string]interface{}{"reference": d.Condition.Reference}
if d.Condition.Display != "" {
condition["display"] = d.Condition.Display
}
diagnosisItem["condition"] = condition
}
if d.Role != nil {
sys := d.Role.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/diagnosis-role"
}
coding := map[string]interface{}{"system": sys, "code": d.Role.Code}
if d.Role.Display != "" {
coding["display"] = d.Role.Display
}
diagnosisItem["role"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if d.Rank > 0 {
diagnosisItem["rank"] = d.Rank
}
diagnoses = append(diagnoses, diagnosisItem)
}
payload.Set("diagnosis", diagnoses)
}
if req.PatientID != "" {
subject := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientName != "" {
subject["display"] = req.PatientName
}
payload.Set("patient", subject)
}
manageOrg := orgID
if req.ManagingOrganization != "" {
manageOrg = req.ManagingOrganization
}
if manageOrg != "" {
payload.Set("managingOrganization", map[string]interface{}{
"reference": "Organization/" + req.OrganizationID,
"reference": "Organization/" + manageOrg,
})
}
period := map[string]interface{}{"start": req.PeriodStart.Format(time.RFC3339)}
if req.PeriodEnd != nil {
period["end"] = req.PeriodEnd.Format(time.RFC3339)
if req.PeriodStart != nil && !req.PeriodStart.IsZero() {
period := map[string]interface{}{"start": req.PeriodStart.Format(time.RFC3339)}
if req.PeriodEnd != nil && !req.PeriodEnd.IsZero() {
period["end"] = req.PeriodEnd.Format(time.RFC3339)
}
payload.Set("period", period)
}
if req.CareManager != nil {
cm := map[string]interface{}{"reference": req.CareManager.Reference}
if req.CareManager.Display != "" {
cm["display"] = req.CareManager.Display
}
payload.Set("careManager", cm)
}
payload.Set("period", period)
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
// Repository mendefinisikan kontrak untuk operasi penyimpanan data EpisodeOfCare.
@@ -26,15 +27,22 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
@@ -42,11 +50,7 @@ func (r *repository) executeRequest(ctx context.Context, method, endpoint string
resourceID = id
}
return &satusehat.FHIRResponse{
ID: resourceID,
FullResponse: result,
RawResponse: resp,
}, nil
return &satusehat.FHIRResponse{ID: resourceID, FullResponse: result, RawResponse: resp}, nil
}
func (r *repository) Create(ctx context.Context, payload interface{}) (*satusehat.FHIRResponse, error) {
@@ -26,19 +26,16 @@ func NewService(repo Repository) Service {
}
func (s *service) Create(ctx context.Context, req EpisodeOfCareRequest) (*satusehat.FHIRResponse, error) {
fhirPayload := MapRequestToFHIR(req)
return s.repo.Create(ctx, fhirPayload)
return s.repo.Create(ctx, MapRequestToFHIR(req))
}
func (s *service) Update(ctx context.Context, id string, req EpisodeOfCareRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("EpisodeOfCare ID is required").Build()
}
fhirPayload := MapRequestToFHIR(req)
fhirPayload.Set("id", id)
return s.repo.Update(ctx, id, fhirPayload)
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req EpisodeOfCarePatchRequest) (*satusehat.FHIRResponse, error) {
+15 -12
View File
@@ -3,18 +3,21 @@ package imagingstudy
import "time"
type ImagingStudyRequest struct {
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
EncounterID string `json:"encounter_id"`
PractitionerID string `json:"practitioner_id"`
PractitionerName string `json:"practitioner_name"`
Status string `json:"status"` // registered, available, cancelled, entered-in-error, unknown
Started time.Time `json:"started"`
NumberOfSeries int `json:"number_of_series"`
NumberOfInstances int `json:"number_of_instances"`
ProcedureCode string `json:"procedure_code"` // SNOMED CT
ProcedureDisplay string `json:"procedure_display"`
Description string `json:"description"`
OrganizationID string `json:"organization_id,omitempty"`
AccessionNumber string `json:"accession_number,omitempty"`
ServiceRequestID string `json:"service_request_id,omitempty"`
PatientID string `json:"patient_id,omitempty"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id,omitempty"`
PractitionerID string `json:"practitioner_id,omitempty"`
PractitionerName string `json:"practitioner_name,omitempty"`
Status string `json:"status,omitempty"` // registered, available, cancelled, entered-in-error, unknown
Started time.Time `json:"started,omitempty"`
NumberOfSeries *int `json:"number_of_series,omitempty"`
NumberOfInstances *int `json:"number_of_instances,omitempty"`
ProcedureCode string `json:"procedure_code,omitempty"` // SNOMED CT
ProcedureDisplay string `json:"procedure_display,omitempty"`
Description string `json:"description,omitempty"`
}
type ImagingStudyPatchRequest []map[string]interface{}
@@ -1,39 +1,103 @@
package imagingstudy
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req ImagingStudyRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("ImagingStudy").
Set("status", req.Status).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("started", req.Started.Format(time.RFC3339)).
Set("numberOfSeries", req.NumberOfSeries).
Set("numberOfInstances", req.NumberOfInstances).
Set("description", req.Description)
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if req.ProcedureCode != "" {
payload.Set("procedureCode", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("ImagingStudy")
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.PatientID != "" {
subject := map[string]interface{}{
"reference": "Patient/" + req.PatientID,
}
if req.PatientName != "" {
subject["display"] = req.PatientName
}
payload.Set("subject", subject)
}
if !req.Started.IsZero() {
payload.Set("started", req.Started.Format(time.RFC3339))
}
if orgID != "" && req.AccessionNumber != "" {
payload.Set("identifier", []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://snomed.info/sct",
"code": req.ProcedureCode,
"display": req.ProcedureDisplay,
"use": "official",
"type": map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
"code": "ACSN",
},
},
},
"system": "http://sys-ids.kemkes.go.id/acsn/" + orgID,
"value": req.AccessionNumber,
},
})
}
if req.ServiceRequestID != "" {
payload.Set("basedOn", []map[string]interface{}{
{
"reference": "ServiceRequest/" + req.ServiceRequestID,
},
})
}
if req.ProcedureCode != "" {
modality := map[string]interface{}{
"system": "http://dicom.nema.org/resources/ontology/DCM",
"code": req.ProcedureCode,
}
if req.ProcedureDisplay != "" {
modality["display"] = req.ProcedureDisplay
}
payload.Set("modality", []map[string]interface{}{modality})
}
// Menambahkan Practitioner ke kolom `interpreter` jika disediakan
if req.PractitionerID != "" {
interpreter := map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
}
if req.PractitionerName != "" {
interpreter["display"] = req.PractitionerName
}
payload.Set("interpreter", []map[string]interface{}{interpreter})
}
if req.EncounterID != "" {
payload.Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
})
}
if req.NumberOfSeries != nil {
payload.Set("numberOfSeries", *req.NumberOfSeries)
}
if req.NumberOfInstances != nil {
payload.Set("numberOfInstances", *req.NumberOfInstances)
}
if req.Description != "" {
payload.Set("description", req.Description)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
// Repository mendefinisikan kontrak untuk operasi penyimpanan data ImagingStudy.
@@ -26,15 +27,33 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
// hiddenCtx membungkus context untuk mencegah *HTTP client*
// melakukan type-assertion ke *gin.Context dan melakukan auto-print error.
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().
Message("Failed to execute request to SatuSehat").
Cause(err).
Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().
Message("Failed to parse SatuSehat response").
Cause(err).
Metadata("raw_response", string(resp)).
Build()
}
// Tangkap respons OperationOutcome dan serahkan ke Parser terpusat
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
+20 -12
View File
@@ -1,19 +1,27 @@
package immunization
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type ImmunizationRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
Status string `json:"status" binding:"required,oneof=completed entered-in-error not-done"`
VaccineCode string `json:"vaccine_code" binding:"required"` // KFA Code for Vaccine
VaccineDisplay string `json:"vaccine_display" binding:"required"`
OccurrenceDateTime time.Time `json:"occurrence_date_time" binding:"required"`
PrimarySource bool `json:"primary_source"`
LotNumber string `json:"lot_number,omitempty"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
ImmunizationID string `json:"immunization_id,omitempty"`
Status string `json:"status" binding:"required,oneof=completed entered-in-error not-done"`
VaccineCode *common.CodeableConceptDTO `json:"vaccine_code" binding:"required"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
EncounterDisplay string `json:"encounter_display,omitempty"`
OccurrenceDateTime *time.Time `json:"occurrence_date_time,omitempty"`
PrimarySource *bool `json:"primary_source,omitempty"`
LotNumber string `json:"lot_number,omitempty"`
Performer []common.ReferenceDTO `json:"performer,omitempty"`
Location *common.ReferenceDTO `json:"location,omitempty"`
DoseQuantity *common.QuantityDTO `json:"dose_quantity,omitempty"`
Route *common.CodeableConceptDTO `json:"route,omitempty"`
}
type ImmunizationPatchRequest []map[string]interface{}
+110 -17
View File
@@ -1,33 +1,126 @@
package immunization
import (
"service/internal/interfaces/satusehat"
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req ImmunizationRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Immunization").
Set("status", req.Status).
Set("vaccineCode", map[string]interface{}{
"coding": []map[string]interface{}{
{"system": "http://sys-ids.kemkes.go.id/kfa", "code": req.VaccineCode, "display": req.VaccineDisplay},
},
}).
Set("patient", map[string]interface{}{"reference": "Patient/" + req.PatientID, "display": req.PatientName}).
Set("encounter", map[string]interface{}{"reference": "Encounter/" + req.EncounterID}).
Set("occurrenceDateTime", req.OccurrenceDateTime.Format(time.RFC3339)).
Set("primarySource", req.PrimarySource).
Set("performer", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("Immunization")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.ImmunizationID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"actor": map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
},
"system": "http://sys-ids.kemkes.go.id/immunization/" + orgID,
"use": "official",
"value": req.ImmunizationID,
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.VaccineCode != nil {
sys := req.VaccineCode.System
if sys == "" {
sys = "http://sys-ids.kemkes.go.id/kfa"
}
coding := map[string]interface{}{"system": sys, "code": req.VaccineCode.Code}
if req.VaccineCode.Display != "" {
coding["display"] = req.VaccineCode.Display
}
vaccineConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.VaccineCode.Text != "" {
vaccineConcept["text"] = req.VaccineCode.Text
}
payload.Set("vaccineCode", vaccineConcept)
}
if req.PatientID != "" {
subj := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientName != "" {
subj["display"] = req.PatientName
}
payload.Set("patient", subj)
}
if req.EncounterID != "" {
enc := map[string]interface{}{"reference": "Encounter/" + req.EncounterID}
if req.EncounterDisplay != "" {
enc["display"] = req.EncounterDisplay
}
payload.Set("encounter", enc)
}
if req.OccurrenceDateTime != nil && !req.OccurrenceDateTime.IsZero() {
payload.Set("occurrenceDateTime", req.OccurrenceDateTime.Format(time.RFC3339))
}
if req.PrimarySource != nil {
payload.Set("primarySource", *req.PrimarySource)
}
if req.Location != nil {
loc := map[string]interface{}{"reference": req.Location.Reference}
if req.Location.Display != "" {
loc["display"] = req.Location.Display
}
payload.Set("location", loc)
}
if req.LotNumber != "" {
payload.Set("lotNumber", req.LotNumber)
}
if len(req.Performer) > 0 {
var performers []map[string]interface{}
for _, p := range req.Performer {
actor := map[string]interface{}{"reference": p.Reference}
if p.Display != "" {
actor["display"] = p.Display
}
performers = append(performers, map[string]interface{}{"actor": actor})
}
payload.Set("performer", performers)
}
if req.DoseQuantity != nil {
sys := req.DoseQuantity.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm"
}
payload.Set("doseQuantity", map[string]interface{}{
"value": req.DoseQuantity.Value,
"unit": req.DoseQuantity.Unit,
"system": sys,
"code": req.DoseQuantity.Code,
})
}
if req.Route != nil {
sys := req.Route.System
if sys == "" {
sys = "http://www.whocc.no/atc"
}
coding := map[string]interface{}{"system": sys, "code": req.Route.Code}
if req.Route.Display != "" {
coding["display"] = req.Route.Display
}
routeConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Route.Text != "" {
routeConcept["text"] = req.Route.Text
}
payload.Set("route", routeConcept)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -19,14 +20,21 @@ type repository struct{ client satusehat.SatuSehatClient }
func NewRepository(client satusehat.SatuSehatClient) Repository { return &repository{client: client} }
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -24,12 +24,23 @@ func (s *service) Update(ctx context.Context, id string, req ImmunizationRequest
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.Update(ctx, id, MapRequestToFHIR(req).Set("id", id))
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req ImmunizationPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
+3 -1
View File
@@ -3,9 +3,11 @@ package medication
import "time"
type MedicationRequest struct {
MedicationID string `json:"medication_id,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
StatusCode string `json:"status_code" binding:"required,oneof=active inactive entered-in-error"`
KfaCode string `json:"kfa_code" binding:"required"`
KfaDisplay string `json:"kfa_display" binding:"required"`
KfaDisplay string `json:"kfa_display,omitempty"`
FormCode string `json:"form_code,omitempty"`
FormDisplay string `json:"form_display,omitempty"`
ManufacturerID string `json:"manufacturer_id,omitempty"` // Reference ke ID Organization (Pabrik/Distributor)
+69 -20
View File
@@ -1,33 +1,78 @@
package medication
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req MedicationRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Medication").
Set("status", req.StatusCode).
Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/kfa",
"code": req.KfaCode,
"display": req.KfaDisplay,
payload := satusehat.NewFHIRPayload("Medication")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
// Set Meta Profile secara otomatis
payload.Set("meta", map[string]interface{}{
"profile": []string{"https://fhir.kemkes.go.id/r4/StructureDefinition/Medication"},
})
// Set Extension MedicationType (Non-compound) secara otomatis
payload.Set("extension", []map[string]interface{}{
{
"url": "https://fhir.kemkes.go.id/r4/StructureDefinition/MedicationType",
"valueCodeableConcept": map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-type",
"code": "NC",
"display": "Non-compound",
},
},
},
},
})
if orgID != "" && req.MedicationID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/medication/" + orgID,
"use": "official",
"value": req.MedicationID,
},
})
}
if req.StatusCode != "" {
payload.Set("status", req.StatusCode)
}
if req.KfaCode != "" {
coding := map[string]interface{}{
"system": "http://sys-ids.kemkes.go.id/kfa",
"code": req.KfaCode,
}
if req.KfaDisplay != "" {
coding["display"] = req.KfaDisplay
}
payload.Set("code", map[string]interface{}{
"coding": []map[string]interface{}{coding},
})
}
if req.FormCode != "" {
coding := map[string]interface{}{
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-form",
"code": req.FormCode,
}
if req.FormDisplay != "" {
coding["display"] = req.FormDisplay
}
payload.Set("form", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.kemkes.go.id/CodeSystem/medication-form",
"code": req.FormCode,
"display": req.FormDisplay,
},
},
"coding": []map[string]interface{}{coding},
})
}
@@ -37,11 +82,15 @@ func MapRequestToFHIR(req MedicationRequest) satusehat.FHIRPayload {
})
}
if req.BatchNumber != "" && req.ExpirationDate != nil {
payload.Set("batch", map[string]interface{}{
"lotNumber": req.BatchNumber,
"expirationDate": req.ExpirationDate.Format(time.RFC3339),
})
if req.BatchNumber != "" || req.ExpirationDate != nil {
batch := map[string]interface{}{}
if req.BatchNumber != "" {
batch["lotNumber"] = req.BatchNumber
}
if req.ExpirationDate != nil {
batch["expirationDate"] = req.ExpirationDate.Format(time.RFC3339)
}
payload.Set("batch", batch)
}
return payload
@@ -7,6 +7,7 @@ import (
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -25,15 +26,22 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
@@ -49,6 +49,9 @@ func (s *service) Patch(ctx context.Context, id string, req MedicationPatchReque
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Medication ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
@@ -3,22 +3,32 @@ package medicationdispense
import "time"
type MedicationDispenseRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
MedicationID string `json:"medication_id" binding:"required"`
MedicationDisplay string `json:"medication_display" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
LocationID string `json:"location_id" binding:"required"`
LocationName string `json:"location_name" binding:"required"`
PrescriptionID string `json:"prescription_id" binding:"required"` // Reference to MedicationRequest
Status string `json:"status" binding:"required,oneof=preparation in-progress cancelled on-hold completed entered-in-error stopped declined unknown"`
PreparedDate time.Time `json:"prepared_date" binding:"required"`
HandedOverDate time.Time `json:"handed_over_date" binding:"required"`
DispenseValue float64 `json:"dispense_value" binding:"required"`
DispenseUnit string `json:"dispense_unit" binding:"required"`
DosagePatientInstr string `json:"dosage_patient_instruction"`
OrganizationID string `json:"organization_id,omitempty"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
MedicationID string `json:"medication_id" binding:"required"`
MedicationDisplay string `json:"medication_display,omitempty"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name,omitempty"`
LocationID string `json:"location_id" binding:"required"`
LocationName string `json:"location_name,omitempty"`
PrescriptionID string `json:"prescription_id" binding:"required"` // Digunakan untuk Prescription Identifier
PrescriptionItemID string `json:"prescription_item_id,omitempty"` // Digunakan untuk Identifier item
Status string `json:"status" binding:"required,oneof=preparation in-progress cancelled on-hold completed entered-in-error stopped declined unknown"`
Category string `json:"category,omitempty" binding:"omitempty,oneof=outpatient inpatient community discharge"`
PreparedDate time.Time `json:"prepared_date" binding:"required"`
HandedOverDate time.Time `json:"handed_over_date" binding:"required"`
QuantityValue float64 `json:"quantity_value" binding:"required"`
QuantityUnit string `json:"quantity_unit" binding:"required"` // e.g. TAB
DaysSupplyValue int `json:"days_supply_value,omitempty"`
DosageText string `json:"dosage_text,omitempty"`
TimingFrequency int `json:"timing_frequency,omitempty"`
TimingPeriod int `json:"timing_period,omitempty"`
TimingPeriodUnit string `json:"timing_period_unit,omitempty"` // e.g. d, h
DoseQuantityValue float64 `json:"dose_quantity_value,omitempty"`
DoseQuantityUnit string `json:"dose_quantity_unit,omitempty"` // e.g. TAB
MedicationRequestID string `json:"medication_request_id" binding:"required"` //Digunakan untuk authorizingPrescriptionID
}
type MedicationDispensePatchRequest []map[string]interface{}
@@ -1,51 +1,171 @@
package medicationdispense
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req MedicationDispenseRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("MedicationDispense").
Set("status", req.Status).
Set("category", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/fhir/CodeSystem/medicationdispense-category",
"code": "outpatient",
"display": "Outpatient",
},
},
}).
Set("medicationReference", map[string]interface{}{
"reference": "Medication/" + req.MedicationID,
"display": req.MedicationDisplay,
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("context", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("performer", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("MedicationDispense")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.PrescriptionID != "" {
identifiers := []map[string]interface{}{
{
"actor": map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
},
"system": "http://sys-ids.kemkes.go.id/prescription/" + orgID,
"use": "official",
"value": req.PrescriptionID,
},
}).
Set("location", map[string]interface{}{
"reference": "Location/" + req.LocationID,
"display": req.LocationName,
}).
Set("authorizingPrescription", []map[string]interface{}{
{"reference": "MedicationRequest/" + req.PrescriptionID},
}).
Set("whenPrepared", req.PreparedDate.Format(time.RFC3339)).
Set("whenHandedOver", req.HandedOverDate.Format(time.RFC3339))
}
if req.PrescriptionItemID != "" {
identifiers = append(identifiers, map[string]interface{}{
"system": "http://sys-ids.kemkes.go.id/prescription-item/" + orgID,
"use": "official",
"value": req.PrescriptionItemID,
})
}
payload.Set("identifier", identifiers)
}
if req.Status != "" {
payload.Set("status", req.Status)
}
categoryCode := "outpatient"
categoryDisplay := "Outpatient"
if req.Category != "" {
categoryCode = req.Category
switch req.Category {
case "inpatient":
categoryDisplay = "Inpatient"
case "community":
categoryDisplay = "Community"
case "discharge":
categoryDisplay = "Discharge"
}
}
payload.Set("category", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/fhir/CodeSystem/medicationdispense-category",
"code": categoryCode,
"display": categoryDisplay,
},
},
})
if req.MedicationID != "" {
medRef := map[string]interface{}{"reference": "Medication/" + req.MedicationID}
if req.MedicationDisplay != "" {
medRef["display"] = req.MedicationDisplay
}
payload.Set("medicationReference", medRef)
}
if req.PatientID != "" {
subj := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientName != "" {
subj["display"] = req.PatientName
}
payload.Set("subject", subj)
}
if req.EncounterID != "" {
payload.Set("context", map[string]interface{}{"reference": "Encounter/" + req.EncounterID})
}
if req.PractitionerID != "" {
actor := map[string]interface{}{"reference": "Practitioner/" + req.PractitionerID}
if req.PractitionerName != "" {
actor["display"] = req.PractitionerName
}
payload.Set("performer", []map[string]interface{}{{"actor": actor}})
}
if req.LocationID != "" {
loc := map[string]interface{}{"reference": "Location/" + req.LocationID}
if req.LocationName != "" {
loc["display"] = req.LocationName
}
payload.Set("location", loc)
}
if req.MedicationRequestID != "" {
payload.Set("authorizingPrescription", []map[string]interface{}{{"reference": "MedicationRequest/" + req.MedicationRequestID}})
}
if req.QuantityValue != 0 || req.QuantityUnit != "" {
qty := map[string]interface{}{
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
}
if req.QuantityValue != 0 {
qty["value"] = req.QuantityValue
}
if req.QuantityUnit != "" {
qty["code"] = req.QuantityUnit
}
payload.Set("quantity", qty)
}
if req.DaysSupplyValue != 0 {
payload.Set("daysSupply", map[string]interface{}{
"value": req.DaysSupplyValue,
"unit": "Day",
"system": "http://unitsofmeasure.org",
"code": "d",
})
}
if !req.PreparedDate.IsZero() {
payload.Set("whenPrepared", req.PreparedDate.Format(time.RFC3339))
}
if !req.HandedOverDate.IsZero() {
payload.Set("whenHandedOver", req.HandedOverDate.Format(time.RFC3339))
}
if req.DosageText != "" || req.TimingFrequency != 0 || req.DoseQuantityValue != 0 {
dose := map[string]interface{}{"sequence": 1}
if req.DosageText != "" {
dose["text"] = req.DosageText
}
if req.TimingFrequency != 0 && req.TimingPeriod != 0 && req.TimingPeriodUnit != "" {
dose["timing"] = map[string]interface{}{
"repeat": map[string]interface{}{
"frequency": req.TimingFrequency,
"period": req.TimingPeriod,
"periodUnit": req.TimingPeriodUnit,
},
}
}
if req.DoseQuantityValue != 0 && req.DoseQuantityUnit != "" {
dose["doseAndRate"] = []map[string]interface{}{
{
"type": map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/dose-rate-type",
"code": "ordered",
"display": "Ordered",
},
},
},
"doseQuantity": map[string]interface{}{
"value": req.DoseQuantityValue,
"unit": req.DoseQuantityUnit,
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
"code": req.DoseQuantityUnit,
},
},
}
}
payload.Set("dosageInstruction", []map[string]interface{}{dose})
}
return payload
}
@@ -7,6 +7,7 @@ import (
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -25,14 +26,21 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -36,9 +36,18 @@ func (s *service) Update(ctx context.Context, id string, req MedicationDispenseR
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req MedicationDispensePatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("MedicationDispense ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("MedicationDispense ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
@@ -3,21 +3,41 @@ package medicationrequest
import "time"
type MedicationRequestRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
MedicationID string `json:"medication_id" binding:"required"` // Reference to Medication resource
MedicationDisplay string `json:"medication_display" binding:"required"`
Status string `json:"status" binding:"required,oneof=active on-hold cancelled completed entered-in-error stopped draft unknown"`
Intent string `json:"intent" binding:"required,oneof=proposal plan order original-order reflex-order filler-order instance-order option"`
AuthoredOn time.Time `json:"authored_on" binding:"required"`
DosageText string `json:"dosage_text" binding:"required"`
PatientInstr string `json:"patient_instruction"`
DispenseValue float64 `json:"dispense_value" binding:"required"`
DispenseUnit string `json:"dispense_unit" binding:"required"`
SupplyDuration int `json:"supply_duration" binding:"required"` // in days
MedicationRequestID string `json:"medicationrequest_id,omitempty"` // Contoh: No Resep / ID Resep
PrescriptionItemID string `json:"prescription_item_id,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
Category string `json:"category,omitempty" binding:"omitempty,oneof=outpatient inpatient community discharge"`
Priority string `json:"priority,omitempty" binding:"omitempty,oneof=routine urgent asap stat"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id" binding:"required"`
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name,omitempty"`
MedicationID string `json:"medication_id" binding:"required"` // Reference to Medication resource
MedicationDisplay string `json:"medication_display,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
ReasonDisplay string `json:"reason_display,omitempty"`
CourseOfTherapyCode string `json:"course_of_therapy_code,omitempty"`
CourseOfTherapyDisplay string `json:"course_of_therapy_display,omitempty"`
Status string `json:"status" binding:"required,oneof=active on-hold cancelled completed entered-in-error stopped draft unknown"`
Intent string `json:"intent" binding:"required,oneof=proposal plan order original-order reflex-order filler-order instance-order option"`
AuthoredOn time.Time `json:"authored_on" binding:"required"`
DosageText string `json:"dosage_text,omitempty"`
AdditionalInstruction string `json:"additional_instruction,omitempty"`
PatientInstr string `json:"patient_instruction"`
TimingFrequency int `json:"timing_frequency,omitempty"`
TimingPeriod int `json:"timing_period,omitempty"`
TimingPeriodUnit string `json:"timing_period_unit,omitempty"` // d (days), h (hours), etc
RouteCode string `json:"route_code,omitempty"` // e.g. O (Oral)
RouteDisplay string `json:"route_display,omitempty"`
DoseQuantityValue float64 `json:"dose_quantity_value,omitempty"`
DoseQuantityUnit string `json:"dose_quantity_unit,omitempty"` // TAB, CAP, etc
DispenseInterval int `json:"dispense_interval,omitempty"`
DispenseValue float64 `json:"dispense_value,omitempty"`
DispenseUnit string `json:"dispense_unit,omitempty"`
SupplyDuration int `json:"supply_duration,omitempty"` // in days
ValidityPeriodStart *time.Time `json:"validity_period_start,omitempty"`
ValidityPeriodEnd *time.Time `json:"validity_period_end,omitempty"`
}
type MedicationRequestPatchRequest []map[string]interface{}
@@ -1,60 +1,245 @@
package medicationrequest
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req MedicationRequestRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("MedicationRequest").
Set("status", req.Status).
Set("intent", req.Intent).
Set("category", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("MedicationRequest")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
// Set Meta Profile
payload.Set("meta", map[string]interface{}{
"profile": []string{"https://fhir.kemkes.go.id/r4/StructureDefinition/MedicationRequest"},
})
// Set Identifier (ID Resep)
if orgID != "" && req.MedicationRequestID != "" {
identifiers := []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/medicationrequest-category",
"code": "outpatient",
"display": "Outpatient",
},
"system": "http://sys-ids.kemkes.go.id/prescription/" + orgID,
"use": "official",
"value": req.MedicationRequestID,
},
}
if req.PrescriptionItemID != "" {
identifiers = append(identifiers, map[string]interface{}{
"system": "http://sys-ids.kemkes.go.id/prescription-item/" + orgID,
"use": "official",
"value": req.PrescriptionItemID,
})
}
payload.Set("identifier", identifiers)
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Intent != "" {
payload.Set("intent", req.Intent)
}
if req.Priority != "" {
payload.Set("priority", req.Priority)
}
// Penanganan Category dinamis dengan default 'outpatient'
categoryCode := "outpatient"
categoryDisplay := "Outpatient"
if req.Category != "" {
categoryCode = req.Category
switch req.Category {
case "inpatient":
categoryDisplay = "Inpatient"
case "community":
categoryDisplay = "Community"
case "discharge":
categoryDisplay = "Discharge"
}
}
payload.Set("category", []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/medicationrequest-category",
"code": categoryCode,
"display": categoryDisplay,
},
},
}).
Set("medicationReference", map[string]interface{}{
"reference": "Medication/" + req.MedicationID,
"display": req.MedicationDisplay,
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("authoredOn", req.AuthoredOn.Format(time.RFC3339)).
Set("requester", map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
}).
Set("dosageInstruction", []map[string]interface{}{
{
"sequence": 1,
"text": req.DosageText,
"patientInstruction": req.PatientInstr,
},
}).
Set("dispenseRequest", map[string]interface{}{
"quantity": map[string]interface{}{
"value": req.DispenseValue,
"code": req.DispenseUnit,
},
"expectedSupplyDuration": map[string]interface{}{
},
})
if req.MedicationID != "" {
medRef := map[string]interface{}{"reference": "Medication/" + req.MedicationID}
if req.MedicationDisplay != "" {
medRef["display"] = req.MedicationDisplay
}
payload.Set("medicationReference", medRef)
}
if req.ReasonCode != "" {
reasonCoding := map[string]interface{}{
"system": "http://hl7.org/fhir/sid/icd-10",
"code": req.ReasonCode,
}
if req.ReasonDisplay != "" {
reasonCoding["display"] = req.ReasonDisplay
}
payload.Set("reasonCode", []map[string]interface{}{
{"coding": []map[string]interface{}{reasonCoding}},
})
}
if req.CourseOfTherapyCode != "" {
courseCoding := map[string]interface{}{
"system": "http://terminology.hl7.org/CodeSystem/medicationrequest-course-of-therapy",
"code": req.CourseOfTherapyCode,
}
if req.CourseOfTherapyDisplay != "" {
courseCoding["display"] = req.CourseOfTherapyDisplay
}
payload.Set("courseOfTherapyType", map[string]interface{}{
"coding": []map[string]interface{}{courseCoding},
})
}
if req.PatientID != "" {
subj := map[string]interface{}{"reference": "Patient/" + req.PatientID}
if req.PatientName != "" {
subj["display"] = req.PatientName
}
payload.Set("subject", subj)
}
if req.EncounterID != "" {
payload.Set("encounter", map[string]interface{}{"reference": "Encounter/" + req.EncounterID})
}
if !req.AuthoredOn.IsZero() {
payload.Set("authoredOn", req.AuthoredOn.Format(time.RFC3339))
}
if req.PractitionerID != "" {
reqRef := map[string]interface{}{"reference": "Practitioner/" + req.PractitionerID}
if req.PractitionerName != "" {
reqRef["display"] = req.PractitionerName
}
payload.Set("requester", reqRef)
}
if req.DosageText != "" || req.PatientInstr != "" || req.DoseQuantityValue != 0 || req.TimingFrequency != 0 {
dose := map[string]interface{}{"sequence": 1}
if req.DosageText != "" {
dose["text"] = req.DosageText
}
if req.AdditionalInstruction != "" {
dose["additionalInstruction"] = []map[string]interface{}{
{"text": req.AdditionalInstruction},
}
}
if req.PatientInstr != "" {
dose["patientInstruction"] = req.PatientInstr
}
if req.TimingFrequency != 0 && req.TimingPeriod != 0 && req.TimingPeriodUnit != "" {
dose["timing"] = map[string]interface{}{
"repeat": map[string]interface{}{
"frequency": req.TimingFrequency,
"period": req.TimingPeriod,
"periodUnit": req.TimingPeriodUnit,
},
}
}
if req.RouteCode != "" {
routeCoding := map[string]interface{}{
"system": "http://www.whocc.no/atc",
"code": req.RouteCode,
}
if req.RouteDisplay != "" {
routeCoding["display"] = req.RouteDisplay
}
dose["route"] = map[string]interface{}{
"coding": []map[string]interface{}{routeCoding},
}
}
if req.DoseQuantityValue != 0 && req.DoseQuantityUnit != "" {
dose["doseAndRate"] = []map[string]interface{}{
{
"type": map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/dose-rate-type",
"code": "ordered",
"display": "Ordered",
},
},
},
"doseQuantity": map[string]interface{}{
"value": req.DoseQuantityValue,
"unit": req.DoseQuantityUnit,
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
"code": req.DoseQuantityUnit,
},
},
}
}
payload.Set("dosageInstruction", []map[string]interface{}{dose})
}
if req.DispenseValue != 0 || req.SupplyDuration != 0 || req.DispenseInterval != 0 {
dispenseReq := map[string]interface{}{}
if req.DispenseInterval != 0 {
dispenseReq["dispenseInterval"] = map[string]interface{}{
"value": req.DispenseInterval,
"unit": "days",
"system": "http://unitsofmeasure.org",
"code": "d",
}
}
if req.ValidityPeriodStart != nil || req.ValidityPeriodEnd != nil {
validity := map[string]interface{}{}
if req.ValidityPeriodStart != nil {
validity["start"] = req.ValidityPeriodStart.Format("2006-01-02")
}
if req.ValidityPeriodEnd != nil {
validity["end"] = req.ValidityPeriodEnd.Format("2006-01-02")
}
dispenseReq["validityPeriod"] = validity
}
dispenseReq["numberOfRepeatsAllowed"] = 0
if req.DispenseValue != 0 {
qty := map[string]interface{}{
"value": req.DispenseValue,
"system": "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm",
}
if req.DispenseUnit != "" {
qty["unit"] = req.DispenseUnit
qty["code"] = req.DispenseUnit
}
dispenseReq["quantity"] = qty
}
if req.SupplyDuration != 0 {
dispenseReq["expectedSupplyDuration"] = map[string]interface{}{
"value": req.SupplyDuration,
"unit": "days",
"system": "http://unitsofmeasure.org",
"code": "d",
},
})
}
}
if orgID != "" {
dispenseReq["performer"] = map[string]interface{}{
"reference": "Organization/" + orgID,
}
}
payload.Set("dispenseRequest", dispenseReq)
}
return payload
}
@@ -7,6 +7,7 @@ import (
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -25,14 +26,21 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -36,9 +36,18 @@ func (s *service) Update(ctx context.Context, id string, req MedicationRequestRe
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req MedicationRequestPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("MedicationRequest ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("MedicationRequest ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
@@ -1,18 +1,35 @@
package medicationstatement
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type MedicationStatementRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
Status string `json:"status" binding:"required,oneof=active completed entered-in-error intended stopped on-hold unknown not-taken"`
CategoryCode string `json:"category_code" binding:"required,oneof=inpatient outpatient community"`
MedicationCode string `json:"medication_code" binding:"required"` // KFA Code
MedicationDisplay string `json:"medication_display" binding:"required"` // KFA Name
EffectiveDateTime time.Time `json:"effective_date_time" binding:"required"`
DateAsserted time.Time `json:"date_asserted" binding:"required"`
DosageText string `json:"dosage_text" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
StatementID string `json:"statement_id,omitempty"`
Status string `json:"status" binding:"required,oneof=active completed entered-in-error intended stopped on-hold unknown not-taken"`
Category *common.CodeableConceptDTO `json:"category,omitempty"`
Medication *common.CodeableConceptDTO `json:"medication" binding:"required"`
Subject *common.ReferenceDTO `json:"subject" binding:"required"`
Context *common.ReferenceDTO `json:"context,omitempty"` // Encounter
EffectiveDateTime *time.Time `json:"effective_date_time,omitempty"`
DateAsserted *time.Time `json:"date_asserted,omitempty"`
InformationSource *common.ReferenceDTO `json:"information_source,omitempty"`
Dosage []DosageDTO `json:"dosage,omitempty"`
}
type DosageDTO struct {
Sequence int `json:"sequence,omitempty"`
Text string `json:"text,omitempty"`
PatientInstruction string `json:"patient_instruction,omitempty"`
Route *common.CodeableConceptDTO `json:"route,omitempty"`
DoseAndRate []DoseAndRateDTO `json:"dose_and_rate,omitempty"`
}
type DoseAndRateDTO struct {
DoseQuantity *common.QuantityDTO `json:"dose_quantity,omitempty"`
}
type MedicationStatementPatchRequest []map[string]interface{}
@@ -1,50 +1,141 @@
package medicationstatement
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req MedicationStatementRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("MedicationStatement").
Set("status", req.Status).
Set("category", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/medication-statement-category",
"code": req.CategoryCode,
"display": req.CategoryCode,
},
},
}).
Set("medicationCodeableConcept", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/kfa",
"code": req.MedicationCode,
"display": req.MedicationDisplay,
},
},
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("context", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("informationSource", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("effectiveDateTime", req.EffectiveDateTime.Format(time.RFC3339)).
Set("dateAsserted", req.DateAsserted.Format(time.RFC3339)).
Set("dosage", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("MedicationStatement")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.StatementID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"text": req.DosageText,
"system": "http://sys-ids.kemkes.go.id/medicationstatement/" + orgID,
"use": "official",
"value": req.StatementID,
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Category != nil {
sys := req.Category.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/medication-statement-category"
}
coding := map[string]interface{}{"system": sys, "code": req.Category.Code}
if req.Category.Display != "" {
coding["display"] = req.Category.Display
}
payload.Set("category", map[string]interface{}{
"coding": []map[string]interface{}{coding},
})
}
if req.Medication != nil {
sys := req.Medication.System
if sys == "" {
sys = "http://sys-ids.kemkes.go.id/kfa"
}
coding := map[string]interface{}{"system": sys, "code": req.Medication.Code}
if req.Medication.Display != "" {
coding["display"] = req.Medication.Display
}
medConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Medication.Text != "" {
medConcept["text"] = req.Medication.Text
}
payload.Set("medicationCodeableConcept", medConcept)
}
if req.Subject != nil {
subject := map[string]interface{}{"reference": req.Subject.Reference}
if req.Subject.Display != "" {
subject["display"] = req.Subject.Display
}
payload.Set("subject", subject)
}
if req.Context != nil {
context := map[string]interface{}{"reference": req.Context.Reference}
if req.Context.Display != "" {
context["display"] = req.Context.Display
}
payload.Set("context", context)
}
if req.EffectiveDateTime != nil && !req.EffectiveDateTime.IsZero() {
payload.Set("effectiveDateTime", req.EffectiveDateTime.Format(time.RFC3339))
}
if req.DateAsserted != nil && !req.DateAsserted.IsZero() {
payload.Set("dateAsserted", req.DateAsserted.Format(time.RFC3339))
}
if req.InformationSource != nil {
infoSource := map[string]interface{}{"reference": req.InformationSource.Reference}
if req.InformationSource.Display != "" {
infoSource["display"] = req.InformationSource.Display
}
payload.Set("informationSource", infoSource)
}
if len(req.Dosage) > 0 {
var dosages []map[string]interface{}
for _, d := range req.Dosage {
dosageObj := map[string]interface{}{}
if d.Sequence > 0 {
dosageObj["sequence"] = d.Sequence
}
if d.Text != "" {
dosageObj["text"] = d.Text
}
if d.PatientInstruction != "" {
dosageObj["patientInstruction"] = d.PatientInstruction
}
if d.Route != nil {
sys := d.Route.System
if sys == "" {
sys = "http://www.whocc.no/atc"
}
coding := map[string]interface{}{"system": sys, "code": d.Route.Code}
if d.Route.Display != "" {
coding["display"] = d.Route.Display
}
dosageObj["route"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if len(d.DoseAndRate) > 0 {
var doseAndRates []map[string]interface{}
for _, dar := range d.DoseAndRate {
if dar.DoseQuantity != nil {
dq := dar.DoseQuantity
sys := dq.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/v3-orderableDrugForm"
}
doseQuantity := map[string]interface{}{"value": dq.Value, "unit": dq.Unit, "system": sys, "code": dq.Code}
doseAndRates = append(doseAndRates, map[string]interface{}{"doseQuantity": doseQuantity})
}
}
if len(doseAndRates) > 0 {
dosageObj["doseAndRate"] = doseAndRates
}
}
dosages = append(dosages, dosageObj)
}
payload.Set("dosage", dosages)
}
return payload
}
@@ -7,6 +7,7 @@ import (
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -25,14 +26,21 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -36,9 +36,18 @@ func (s *service) Update(ctx context.Context, id string, req MedicationStatement
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req MedicationStatementPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("MedicationStatement ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("MedicationStatement ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
+41 -13
View File
@@ -1,20 +1,48 @@
package observation
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type ObservationRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
Status string `json:"status" binding:"required,oneof=registered preliminary final amended"` // registered, preliminary, final, amended
CategoryCode string `json:"category_code" binding:"required"` // vital-signs, laboratory
CategoryDisplay string `json:"category_display" binding:"required"`
Code string `json:"code" binding:"required"` // LOINC code (e.g., 8867-4 for Heart rate)
Display string `json:"display" binding:"required"` // Heart rate
Value float64 `json:"value" binding:"required"`
Unit string `json:"unit" binding:"required"`
UnitCode string `json:"unit_code" binding:"required"` // UCUM code (e.g., /min)
EffectiveDateTime time.Time `json:"effective_date_time" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
ObservationID string `json:"observation_id,omitempty"`
Status string `json:"status" binding:"required,oneof=registered preliminary final amended corrected cancelled entered-in-error unknown"`
Category []*common.CodeableConceptDTO `json:"category" binding:"required,min=1"`
Code *common.CodeableConceptDTO `json:"code" binding:"required"`
Subject *common.ReferenceDTO `json:"subject" binding:"required"` // Patient
Encounter *common.ReferenceDTO `json:"encounter,omitempty"`
EffectiveDateTime *time.Time `json:"effective_datetime,omitempty"`
Issued *time.Time `json:"issued,omitempty"`
Performer []common.ReferenceDTO `json:"performer,omitempty"`
BasedOn []common.ReferenceDTO `json:"based_on,omitempty"`
PartOf []common.ReferenceDTO `json:"part_of,omitempty"`
DerivedFrom []common.ReferenceDTO `json:"derived_from,omitempty"`
Specimen *common.ReferenceDTO `json:"specimen,omitempty"`
BodySite *common.CodeableConceptDTO `json:"body_site,omitempty"`
ValueQuantity *common.QuantityDTO `json:"value_quantity,omitempty"`
ValueCodeableConcept *common.CodeableConceptDTO `json:"value_codeable_concept,omitempty"`
ValueString string `json:"value_string,omitempty"`
ValueBoolean *bool `json:"value_boolean,omitempty"`
Interpretation []*common.CodeableConceptDTO `json:"interpretation,omitempty"`
ReferenceRange []ReferenceRangeDTO `json:"reference_range,omitempty"`
Components []ComponentDTO `json:"components,omitempty"`
}
type ReferenceRangeDTO struct {
Low *common.QuantityDTO `json:"low,omitempty"`
High *common.QuantityDTO `json:"high,omitempty"`
Text string `json:"text,omitempty"`
}
type ComponentDTO struct {
Code *common.CodeableConceptDTO `json:"code,omitempty"`
ValueQuantity *common.QuantityDTO `json:"value_quantity,omitempty"`
ValueCodeableConcept *common.CodeableConceptDTO `json:"value_codeable_concept,omitempty"`
ValueString string `json:"value_string,omitempty"`
ValueBoolean *bool `json:"value_boolean,omitempty"`
}
type ObservationPatchRequest []map[string]interface{}
+243 -33
View File
@@ -1,48 +1,258 @@
package observation
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req ObservationRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Observation").
Set("status", req.Status).
Set("category", []map[string]interface{}{
payload := satusehat.NewFHIRPayload("Observation")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if req.ObservationID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"coding": []map[string]interface{}{
{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": req.CategoryCode,
"display": req.CategoryDisplay,
},
},
"system": "http://sys-ids.kemkes.go.id/observation/" + orgID,
"value": req.ObservationID,
},
}).
Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://loinc.org",
"code": req.Code,
"display": req.Display,
},
},
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("effectiveDateTime", req.EffectiveDateTime.Format(time.RFC3339)).
Set("valueQuantity", map[string]interface{}{
"value": req.Value,
"unit": req.Unit,
"system": "http://unitsofmeasure.org",
"code": req.UnitCode,
})
}
status := req.Status
if status == "" {
status = "final"
}
payload.Set("status", status)
if len(req.Category) > 0 {
var categories []map[string]interface{}
for _, c := range req.Category {
if c != nil {
sys := c.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/observation-category"
}
coding := map[string]interface{}{"system": sys, "code": c.Code}
if c.Display != "" {
coding["display"] = c.Display
}
categories = append(categories, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("category", categories)
}
if req.Code != nil {
sys := req.Code.System
if sys == "" {
sys = "http://loinc.org"
}
coding := map[string]interface{}{"system": sys, "code": req.Code.Code}
if req.Code.Display != "" {
coding["display"] = req.Code.Display
}
codeConcept := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Code.Text != "" {
codeConcept["text"] = req.Code.Text
}
payload.Set("code", codeConcept)
}
if req.Subject != nil {
subj := map[string]interface{}{"reference": req.Subject.Reference}
if req.Subject.Display != "" {
subj["display"] = req.Subject.Display
}
payload.Set("subject", subj)
}
if req.Encounter != nil {
enc := map[string]interface{}{"reference": req.Encounter.Reference}
if req.Encounter.Display != "" {
enc["display"] = req.Encounter.Display
}
payload.Set("encounter", enc)
}
if req.EffectiveDateTime != nil && !req.EffectiveDateTime.IsZero() {
payload.Set("effectiveDateTime", req.EffectiveDateTime.Format(time.RFC3339))
}
if req.Issued != nil && !req.Issued.IsZero() {
payload.Set("issued", req.Issued.Format(time.RFC3339))
}
if len(req.Performer) > 0 {
var performers []map[string]interface{}
for _, p := range req.Performer {
perf := map[string]interface{}{"reference": p.Reference}
if p.Display != "" {
perf["display"] = p.Display
}
performers = append(performers, perf)
}
payload.Set("performer", performers)
}
if len(req.BasedOn) > 0 {
var refs []map[string]interface{}
for _, r := range req.BasedOn {
refs = append(refs, map[string]interface{}{"reference": r.Reference})
}
payload.Set("basedOn", refs)
}
if len(req.PartOf) > 0 {
var refs []map[string]interface{}
for _, r := range req.PartOf {
refs = append(refs, map[string]interface{}{"reference": r.Reference})
}
payload.Set("partOf", refs)
}
if len(req.DerivedFrom) > 0 {
var refs []map[string]interface{}
for _, r := range req.DerivedFrom {
refs = append(refs, map[string]interface{}{"reference": r.Reference})
}
payload.Set("derivedFrom", refs)
}
if req.Specimen != nil {
spec := map[string]interface{}{"reference": req.Specimen.Reference}
if req.Specimen.Display != "" {
spec["display"] = req.Specimen.Display
}
payload.Set("specimen", spec)
}
if req.BodySite != nil {
sys := req.BodySite.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.BodySite.Code}
if req.BodySite.Display != "" {
coding["display"] = req.BodySite.Display
}
payload.Set("bodySite", map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
if req.ValueQuantity != nil {
sys := req.ValueQuantity.System
if sys == "" {
sys = "http://unitsofmeasure.org"
}
payload.Set("valueQuantity", map[string]interface{}{
"value": req.ValueQuantity.Value,
"unit": req.ValueQuantity.Unit,
"system": sys,
"code": req.ValueQuantity.Code,
})
} else if req.ValueCodeableConcept != nil {
sys := req.ValueCodeableConcept.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.ValueCodeableConcept.Code}
if req.ValueCodeableConcept.Display != "" {
coding["display"] = req.ValueCodeableConcept.Display
}
payload.Set("valueCodeableConcept", map[string]interface{}{"coding": []map[string]interface{}{coding}})
} else if req.ValueString != "" {
payload.Set("valueString", req.ValueString)
} else if req.ValueBoolean != nil {
payload.Set("valueBoolean", *req.ValueBoolean)
}
if len(req.Interpretation) > 0 {
var interps []map[string]interface{}
for _, interp := range req.Interpretation {
if interp != nil {
sys := interp.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation"
}
coding := map[string]interface{}{"system": sys, "code": interp.Code}
if interp.Display != "" {
coding["display"] = interp.Display
}
interps = append(interps, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("interpretation", interps)
}
if len(req.ReferenceRange) > 0 {
var ranges []map[string]interface{}
for _, r := range req.ReferenceRange {
rng := map[string]interface{}{}
if r.Low != nil {
sys := r.Low.System
if sys == "" {
sys = "http://unitsofmeasure.org"
}
rng["low"] = map[string]interface{}{"value": r.Low.Value, "unit": r.Low.Unit, "system": sys, "code": r.Low.Code}
}
if r.High != nil {
sys := r.High.System
if sys == "" {
sys = "http://unitsofmeasure.org"
}
rng["high"] = map[string]interface{}{"value": r.High.Value, "unit": r.High.Unit, "system": sys, "code": r.High.Code}
}
if r.Text != "" {
rng["text"] = r.Text
}
ranges = append(ranges, rng)
}
payload.Set("referenceRange", ranges)
}
if len(req.Components) > 0 {
var components []map[string]interface{}
for _, comp := range req.Components {
c := make(map[string]interface{})
if comp.Code != nil {
sys := comp.Code.System
if sys == "" {
sys = "http://loinc.org"
}
coding := map[string]interface{}{"system": sys, "code": comp.Code.Code}
if comp.Code.Display != "" {
coding["display"] = comp.Code.Display
}
c["code"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if comp.ValueQuantity != nil {
sysVQ := comp.ValueQuantity.System
if sysVQ == "" {
sysVQ = "http://unitsofmeasure.org"
}
c["valueQuantity"] = map[string]interface{}{"value": comp.ValueQuantity.Value, "unit": comp.ValueQuantity.Unit, "system": sysVQ, "code": comp.ValueQuantity.Code}
} else if comp.ValueCodeableConcept != nil {
sysVCC := comp.ValueCodeableConcept.System
if sysVCC == "" {
sysVCC = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sysVCC, "code": comp.ValueCodeableConcept.Code}
if comp.ValueCodeableConcept.Display != "" {
coding["display"] = comp.ValueCodeableConcept.Display
}
c["valueCodeableConcept"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
} else if comp.ValueString != "" {
c["valueString"] = comp.ValueString
} else if comp.ValueBoolean != nil {
c["valueBoolean"] = *comp.ValueBoolean
}
components = append(components, c)
}
payload.Set("component", components)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -24,15 +25,23 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
@@ -40,11 +49,7 @@ func (r *repository) executeRequest(ctx context.Context, method, endpoint string
resourceID = id
}
return &satusehat.FHIRResponse{
ID: resourceID,
FullResponse: result,
RawResponse: resp,
}, nil
return &satusehat.FHIRResponse{ID: resourceID, FullResponse: result, RawResponse: resp}, nil
}
func (r *repository) Create(ctx context.Context, payload interface{}) (*satusehat.FHIRResponse, error) {
@@ -24,19 +24,16 @@ func NewService(repo Repository) Service {
}
func (s *service) Create(ctx context.Context, req ObservationRequest) (*satusehat.FHIRResponse, error) {
fhirPayload := MapRequestToFHIR(req)
return s.repo.Create(ctx, fhirPayload)
return s.repo.Create(ctx, MapRequestToFHIR(req))
}
func (s *service) Update(ctx context.Context, id string, req ObservationRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Observation ID is required").Build()
}
fhirPayload := MapRequestToFHIR(req)
fhirPayload.Set("id", id)
return s.repo.Update(ctx, id, fhirPayload)
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req ObservationPatchRequest) (*satusehat.FHIRResponse, error) {
@@ -57,6 +54,5 @@ func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRRespon
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
// Tambahkan validasi untuk query params jika diperlukan
return s.repo.Search(ctx, queryParams)
}
+19 -12
View File
@@ -1,19 +1,26 @@
package procedure
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type ProcedureRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
Status string `json:"status" binding:"required,oneof=preparation in-progress not-done on-hold stopped completed entered-in-error unknown"` // preparation, in-progress, not-done, on-hold, stopped, completed, entered-in-error, unknown
CategoryCode string `json:"category_code" binding:"required"`
CategoryDisplay string `json:"category_display" binding:"required"`
Code string `json:"code" binding:"required"` // SNOMED-CT or ICD9CM code
Display string `json:"display" binding:"required"` // Procedure name
PractitionerID string `json:"practitioner_id" binding:"required"`
PractitionerName string `json:"practitioner_name" binding:"required"`
PerformedDateTime time.Time `json:"performed_date_time" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
ProcedureID string `json:"procedure_id,omitempty"`
Status string `json:"status" binding:"required,oneof=preparation in-progress not-done on-hold stopped completed entered-in-error unknown"`
Category []*common.CodeableConceptDTO `json:"category,omitempty"`
Code *common.CodeableConceptDTO `json:"code" binding:"required"`
Subject *common.ReferenceDTO `json:"subject" binding:"required"`
Encounter *common.ReferenceDTO `json:"encounter,omitempty"`
PerformedDateTime *time.Time `json:"performed_date_time,omitempty"`
PerformedStart *time.Time `json:"performed_start,omitempty"`
PerformedEnd *time.Time `json:"performed_end,omitempty"`
Performer []common.ReferenceDTO `json:"performer,omitempty"`
ReasonCode []*common.CodeableConceptDTO `json:"reason_code,omitempty"`
BodySite []*common.CodeableConceptDTO `json:"body_site,omitempty"`
Note []string `json:"note,omitempty"`
}
type ProcedurePatchRequest []map[string]interface{}
+130 -33
View File
@@ -1,49 +1,146 @@
package procedure
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req ProcedureRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("Procedure").
Set("status", req.Status).
Set("category", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://snomed.info/sct",
"code": req.CategoryCode,
"display": req.CategoryDisplay,
},
},
}).
Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://snomed.info/sct",
"code": req.Code,
"display": req.Display,
},
},
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("performedDateTime", req.PerformedDateTime.Format(time.RFC3339))
payload := satusehat.NewFHIRPayload("Procedure")
if req.PractitionerID != "" {
payload.Append("performer", map[string]interface{}{
"actor": map[string]interface{}{
"reference": "Practitioner/" + req.PractitionerID,
"display": req.PractitionerName,
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.ProcedureID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/procedure/" + orgID,
"value": req.ProcedureID,
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if len(req.Category) > 0 && req.Category[0] != nil {
sys := req.Category[0].System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.Category[0].Code}
if req.Category[0].Display != "" {
coding["display"] = req.Category[0].Display
}
catObj := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Category[0].Text != "" {
catObj["text"] = req.Category[0].Text
}
payload.Set("category", catObj)
}
if req.Code != nil {
sys := req.Code.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.Code.Code}
if req.Code.Display != "" {
coding["display"] = req.Code.Display
}
codeObj := map[string]interface{}{"coding": []map[string]interface{}{coding}}
if req.Code.Text != "" {
codeObj["text"] = req.Code.Text
}
payload.Set("code", codeObj)
}
if req.Subject != nil {
subj := map[string]interface{}{"reference": req.Subject.Reference}
if req.Subject.Display != "" {
subj["display"] = req.Subject.Display
}
payload.Set("subject", subj)
}
if req.Encounter != nil {
enc := map[string]interface{}{"reference": req.Encounter.Reference}
if req.Encounter.Display != "" {
enc["display"] = req.Encounter.Display
}
payload.Set("encounter", enc)
}
if req.PerformedStart != nil && !req.PerformedStart.IsZero() {
period := map[string]interface{}{"start": req.PerformedStart.Format(time.RFC3339)}
if req.PerformedEnd != nil && !req.PerformedEnd.IsZero() {
period["end"] = req.PerformedEnd.Format(time.RFC3339)
}
payload.Set("performedPeriod", period)
} else if req.PerformedDateTime != nil && !req.PerformedDateTime.IsZero() {
payload.Set("performedDateTime", req.PerformedDateTime.Format(time.RFC3339))
}
if len(req.Performer) > 0 {
var performers []map[string]interface{}
for _, p := range req.Performer {
actor := map[string]interface{}{"reference": p.Reference}
if p.Display != "" {
actor["display"] = p.Display
}
performers = append(performers, map[string]interface{}{"actor": actor})
}
payload.Set("performer", performers)
}
if len(req.ReasonCode) > 0 {
var reasons []map[string]interface{}
for _, r := range req.ReasonCode {
if r != nil {
sys := r.System
if sys == "" {
sys = "http://hl7.org/fhir/sid/icd-10"
}
coding := map[string]interface{}{"system": sys, "code": r.Code}
if r.Display != "" {
coding["display"] = r.Display
}
reasons = append(reasons, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("reasonCode", reasons)
}
if len(req.BodySite) > 0 {
var bodySites []map[string]interface{}
for _, b := range req.BodySite {
if b != nil {
sys := b.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": b.Code}
if b.Display != "" {
coding["display"] = b.Display
}
bodySites = append(bodySites, map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
}
payload.Set("bodySite", bodySites)
}
if len(req.Note) > 0 {
var notes []map[string]interface{}
for _, text := range req.Note {
notes = append(notes, map[string]interface{}{"text": text})
}
payload.Set("note", notes)
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -24,15 +25,23 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
@@ -40,11 +49,7 @@ func (r *repository) executeRequest(ctx context.Context, method, endpoint string
resourceID = id
}
return &satusehat.FHIRResponse{
ID: resourceID,
FullResponse: result,
RawResponse: resp,
}, nil
return &satusehat.FHIRResponse{ID: resourceID, FullResponse: result, RawResponse: resp}, nil
}
func (r *repository) Create(ctx context.Context, payload interface{}) (*satusehat.FHIRResponse, error) {
@@ -52,21 +57,17 @@ func (r *repository) Create(ctx context.Context, payload interface{}) (*satuseha
}
func (r *repository) Update(ctx context.Context, id string, payload interface{}) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Procedure/%s", id)
return r.executeRequest(ctx, "PUT", endpoint, payload)
return r.executeRequest(ctx, "PUT", fmt.Sprintf("/Procedure/%s", id), payload)
}
func (r *repository) Patch(ctx context.Context, id string, payload ProcedurePatchRequest) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Procedure/%s", id)
return r.executeRequest(ctx, "PATCH", endpoint, payload)
return r.executeRequest(ctx, "PATCH", fmt.Sprintf("/Procedure/%s", id), payload)
}
func (r *repository) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Procedure/%s", id)
return r.executeRequest(ctx, "GET", endpoint, nil)
return r.executeRequest(ctx, "GET", fmt.Sprintf("/Procedure/%s", id), nil)
}
func (r *repository) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
endpoint := fmt.Sprintf("/Procedure?%s", queryParams.Encode())
return r.executeRequest(ctx, "GET", endpoint, nil)
return r.executeRequest(ctx, "GET", fmt.Sprintf("/Procedure?%s", queryParams.Encode()), nil)
}
@@ -24,19 +24,16 @@ func NewService(repo Repository) Service {
}
func (s *service) Create(ctx context.Context, req ProcedureRequest) (*satusehat.FHIRResponse, error) {
fhirPayload := MapRequestToFHIR(req)
return s.repo.Create(ctx, fhirPayload)
return s.repo.Create(ctx, MapRequestToFHIR(req))
}
func (s *service) Update(ctx context.Context, id string, req ProcedureRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Procedure ID is required").Build()
}
fhirPayload := MapRequestToFHIR(req)
fhirPayload.Set("id", id)
return s.repo.Update(ctx, id, fhirPayload)
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req ProcedurePatchRequest) (*satusehat.FHIRResponse, error) {
@@ -1,17 +1,22 @@
package questionnaireresponse
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type QuestionnaireResponseRequest struct {
QuestionnaireURL string `json:"questionnaire_url" binding:"required"` // e.g. "https://fhir.kemkes.go.id/Questionnaire/Q0007"
Status string `json:"status" binding:"required,oneof=in-progress completed amended entered-in-error stopped"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
AuthoredDate time.Time `json:"authored_date" binding:"required"`
AuthorID string `json:"author_id" binding:"required"`
AuthorName string `json:"author_name" binding:"required"`
Items []map[string]interface{} `json:"items" binding:"required"` // Raw FHIR formatted items to handle high nesting flexibility
OrganizationID string `json:"organization_id,omitempty"`
QuestionnaireResponseID string `json:"questionnaire_response_id,omitempty"`
QuestionnaireURL string `json:"questionnaire_url" binding:"required"` // e.g., "https://fhir.kemkes.go.id/Questionnaire/Q0007"
Status string `json:"status" binding:"required,oneof=in-progress completed amended entered-in-error stopped"`
Subject *common.ReferenceDTO `json:"subject" binding:"required"` // Patient
Encounter *common.ReferenceDTO `json:"encounter,omitempty"`
Authored *time.Time `json:"authored" binding:"required"`
Author *common.ReferenceDTO `json:"author,omitempty"`
Source *common.ReferenceDTO `json:"source,omitempty"`
Items []map[string]interface{} `json:"item" binding:"required,min=1"` // Raw FHIR formatted items
}
type QuestionnaireResponsePatchRequest []map[string]interface{}
@@ -1,28 +1,74 @@
package questionnaireresponse
import (
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req QuestionnaireResponseRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("QuestionnaireResponse").
Set("questionnaire", req.QuestionnaireURL).
Set("status", req.Status).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("authored", req.AuthoredDate.Format(time.RFC3339)).
Set("author", map[string]interface{}{
"reference": "Practitioner/" + req.AuthorID,
"display": req.AuthorName,
}).
Set("item", req.Items)
payload := satusehat.NewFHIRPayload("QuestionnaireResponse")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.QuestionnaireResponseID != "" {
// Catatan: Identifier QuestionnaireResponse adalah single object, bukan array
payload.Set("identifier", map[string]interface{}{
"system": "http://sys-ids.kemkes.go.id/QuestionnaireResponse/" + orgID,
"value": req.QuestionnaireResponseID,
})
}
if req.QuestionnaireURL != "" {
payload.Set("questionnaire", req.QuestionnaireURL)
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Subject != nil {
subject := map[string]interface{}{"reference": req.Subject.Reference}
if req.Subject.Display != "" {
subject["display"] = req.Subject.Display
}
payload.Set("subject", subject)
}
if req.Encounter != nil {
encounter := map[string]interface{}{"reference": req.Encounter.Reference}
if req.Encounter.Display != "" {
encounter["display"] = req.Encounter.Display
}
payload.Set("encounter", encounter)
}
if req.Authored != nil && !req.Authored.IsZero() {
payload.Set("authored", req.Authored.Format(time.RFC3339))
}
if req.Author != nil {
author := map[string]interface{}{"reference": req.Author.Reference}
if req.Author.Display != "" {
author["display"] = req.Author.Display
}
payload.Set("author", author)
}
if req.Source != nil {
source := map[string]interface{}{"reference": req.Source.Reference}
if req.Source.Display != "" {
source["display"] = req.Source.Display
}
payload.Set("source", source)
}
if len(req.Items) > 0 {
payload.Set("item", req.Items)
}
return payload
}
@@ -7,6 +7,7 @@ import (
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -25,14 +26,21 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
@@ -36,9 +36,18 @@ func (s *service) Update(ctx context.Context, id string, req QuestionnaireRespon
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req QuestionnaireResponsePatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("QuestionnaireResponse ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("QuestionnaireResponse ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
@@ -3,16 +3,18 @@ package servicerequest
import "time"
type ServiceRequestRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
EncounterID string `json:"encounter_id" binding:"required"`
RequesterID string `json:"requester_id" binding:"required"`
RequesterName string `json:"requester_name" binding:"required"`
Status string `json:"status" binding:"required,oneof=draft active on-hold revoked completed entered-in-error unknown"` // draft, active, on-hold, revoked, completed, entered-in-error, unknown
Intent string `json:"intent" binding:"required,oneof=proposal plan directive order original-order reflex-order filler-order instance-order option"` // proposal, plan, directive, order, original-order, reflex-order, filler-order, instance-order, option
Code string `json:"code" binding:"required"`
Display string `json:"display" binding:"required"`
AuthoredOn time.Time `json:"authored_on" binding:"required"`
PatientID string `json:"patient_id,omitempty"`
PatientName string `json:"patient_name,omitempty"`
EncounterID string `json:"encounter_id,omitempty"`
RequesterID string `json:"requester_id,omitempty"`
PerformerID string `json:"performer_id,omitempty"`
RequesterName string `json:"requester_name,omitempty"`
PerformerName string `json:"performer_name,omitempty"`
Status string `json:"status,omitempty" binding:"omitempty,oneof=draft active on-hold revoked completed entered-in-error unknown"`
Intent string `json:"intent,omitempty" binding:"omitempty,oneof=proposal plan directive order original-order reflex-order filler-order instance-order option"`
Code string `json:"code,omitempty"`
Display string `json:"display,omitempty"`
AuthoredOn time.Time `json:"authored_on,omitempty"`
}
type ServiceRequestPatchRequest []map[string]interface{}
@@ -7,32 +7,66 @@ import (
)
func MapRequestToFHIR(req ServiceRequestRequest) satusehat.FHIRPayload {
payload := satusehat.NewFHIRPayload("ServiceRequest").
Set("status", req.Status).
Set("intent", req.Intent).
Set("code", map[string]interface{}{
"coding": []map[string]interface{}{
{
"system": "http://snomed.info/sct",
"code": req.Code,
"display": req.Display,
},
},
}).
Set("subject", map[string]interface{}{
payload := satusehat.NewFHIRPayload("ServiceRequest")
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Intent != "" {
payload.Set("intent", req.Intent)
}
if req.Code != "" {
coding := map[string]interface{}{
"system": "http://snomed.info/sct",
"code": req.Code,
}
if req.Display != "" {
coding["display"] = req.Display
}
payload.Set("code", map[string]interface{}{
"coding": []map[string]interface{}{coding},
})
}
if req.PatientID != "" {
subject := map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("encounter", map[string]interface{}{
}
if req.PatientName != "" {
subject["display"] = req.PatientName
}
payload.Set("subject", subject)
}
if req.EncounterID != "" {
payload.Set("encounter", map[string]interface{}{
"reference": "Encounter/" + req.EncounterID,
}).
Set("authoredOn", req.AuthoredOn.Format(time.RFC3339))
})
}
if !req.AuthoredOn.IsZero() {
payload.Set("authoredOn", req.AuthoredOn.Format(time.RFC3339))
}
if req.PerformerID != "" {
performer := map[string]interface{}{
"reference": "Practitioner/" + req.PerformerID,
}
if req.PerformerName != "" {
performer["display"] = req.PerformerName
}
payload.Set("performer", []map[string]interface{}{performer})
}
if req.RequesterID != "" {
payload.Set("requester", map[string]interface{}{
requester := map[string]interface{}{
"reference": "Practitioner/" + req.RequesterID,
"display": req.RequesterName,
})
}
if req.RequesterName != "" {
requester["display"] = req.RequesterName
}
payload.Set("requester", requester)
}
return payload
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
// Repository mendefinisikan kontrak untuk operasi penyimpanan data ServiceRequest.
@@ -26,15 +27,22 @@ func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
+29 -9
View File
@@ -1,16 +1,36 @@
package specimen
import "time"
import (
"time"
"service/internal/satusehat/common"
)
type SpecimenRequest struct {
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name" binding:"required"`
Status string `json:"status" binding:"required,oneof=available unavailable unsatisfactory entered-in-error"`
TypeCode string `json:"type_code" binding:"required"` // SNOMED CT Code (e.g., 119297000 for Blood)
TypeDisplay string `json:"type_display" binding:"required"`
CollectedDateTime time.Time `json:"collected_date_time" binding:"required"`
CollectorID string `json:"collector_id" binding:"required"`
CollectorName string `json:"collector_name" binding:"required"`
OrganizationID string `json:"organization_id,omitempty"`
SpecimenID string `json:"specimen_id,omitempty"`
Status string `json:"status" binding:"required,oneof=available,unavailable,unsatisfactory,entered-in-error"`
Type *common.CodeableConceptDTO `json:"type" binding:"required"`
Subject *common.ReferenceDTO `json:"subject" binding:"required"`
Collection *CollectionDTO `json:"collection,omitempty"`
ReceivedDateTime *time.Time `json:"received_date_time,omitempty"`
Processing []ProcessingDTO `json:"processing,omitempty"`
Conditions []string `json:"conditions,omitempty"`
Request []common.ReferenceDTO `json:"request,omitempty"`
}
type CollectionDTO struct {
CollectedDateTime *time.Time `json:"collected_date_time,omitempty"`
Collector *common.ReferenceDTO `json:"collector,omitempty"`
Quantity *common.QuantityDTO `json:"quantity,omitempty"`
Method *common.CodeableConceptDTO `json:"method,omitempty"`
BodySite *common.CodeableConceptDTO `json:"body_site,omitempty"`
FastingStatus *common.CodeableConceptDTO `json:"fasting_status,omitempty"`
}
type ProcessingDTO struct {
Procedure *common.CodeableConceptDTO `json:"procedure,omitempty"`
TimeDateTime *time.Time `json:"time_datetime,omitempty"`
}
type SpecimenPatchRequest []map[string]interface{}
+156 -17
View File
@@ -1,27 +1,166 @@
package specimen
import (
"service/internal/interfaces/satusehat"
"os"
"time"
"service/internal/interfaces/satusehat"
)
func MapRequestToFHIR(req SpecimenRequest) satusehat.FHIRPayload {
return satusehat.NewFHIRPayload("Specimen").
Set("status", req.Status).
Set("type", map[string]interface{}{
"coding": []map[string]interface{}{
{"system": "http://snomed.info/sct", "code": req.TypeCode, "display": req.TypeDisplay},
},
}).
Set("subject", map[string]interface{}{
"reference": "Patient/" + req.PatientID,
"display": req.PatientName,
}).
Set("collection", map[string]interface{}{
"collectedDateTime": req.CollectedDateTime.Format(time.RFC3339),
"collector": map[string]interface{}{
"reference": "Practitioner/" + req.CollectorID,
"display": req.CollectorName,
payload := satusehat.NewFHIRPayload("Specimen")
orgID := os.Getenv("SATUSEHAT_ORG_ID")
if orgID == "" {
orgID = req.OrganizationID
}
if orgID != "" && req.SpecimenID != "" {
payload.Set("identifier", []map[string]interface{}{
{
"system": "http://sys-ids.kemkes.go.id/specimen/" + orgID,
"value": req.SpecimenID,
"assigner": map[string]interface{}{
"reference": "Organization/" + orgID,
},
},
})
}
if req.Status != "" {
payload.Set("status", req.Status)
}
if req.Type != nil {
sys := req.Type.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.Type.Code}
if req.Type.Display != "" {
coding["display"] = req.Type.Display
}
payload.Set("type", map[string]interface{}{"coding": []map[string]interface{}{coding}})
}
if req.Collection != nil {
coll := map[string]interface{}{}
if req.Collection.Collector != nil {
collector := map[string]interface{}{"reference": req.Collection.Collector.Reference}
if req.Collection.Collector.Display != "" {
collector["display"] = req.Collection.Collector.Display
}
coll["collector"] = collector
}
if req.Collection.CollectedDateTime != nil && !req.Collection.CollectedDateTime.IsZero() {
coll["collectedDateTime"] = req.Collection.CollectedDateTime.Format(time.RFC3339)
}
if req.Collection.Quantity != nil {
sys := req.Collection.Quantity.System
if sys == "" {
sys = "http://unitsofmeasure.org"
}
coll["quantity"] = map[string]interface{}{
"value": req.Collection.Quantity.Value,
"unit": req.Collection.Quantity.Unit,
"system": sys,
"code": req.Collection.Quantity.Code,
}
}
if req.Collection.Method != nil {
sys := req.Collection.Method.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.Collection.Method.Code}
if req.Collection.Method.Display != "" {
coding["display"] = req.Collection.Method.Display
}
coll["method"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if req.Collection.BodySite != nil {
sys := req.Collection.BodySite.System
if sys == "" {
sys = "http://snomed.info/sct"
}
coding := map[string]interface{}{"system": sys, "code": req.Collection.BodySite.Code}
if req.Collection.BodySite.Display != "" {
coding["display"] = req.Collection.BodySite.Display
}
coll["bodySite"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if req.Collection.FastingStatus != nil {
sys := req.Collection.FastingStatus.System
if sys == "" {
sys = "http://terminology.hl7.org/CodeSystem/v2-0916"
}
coding := map[string]interface{}{"system": sys, "code": req.Collection.FastingStatus.Code}
if req.Collection.FastingStatus.Display != "" {
coding["display"] = req.Collection.FastingStatus.Display
}
coll["fastingStatusCodeableConcept"] = map[string]interface{}{"coding": []map[string]interface{}{coding}}
}
if len(coll) > 0 {
payload.Set("collection", coll)
}
}
if len(req.Processing) > 0 {
var processings []map[string]interface{}
for _, proc := range req.Processing {
p := map[string]interface{}{}
if proc.Procedure != nil {
sys := proc.Procedure.System
if sys == "" {
sys = "http://snomed.info/sct"
}
p["procedure"] = map[string]interface{}{
"coding": []map[string]interface{}{
{"system": sys, "code": proc.Procedure.Code, "display": proc.Procedure.Display},
},
}
}
if proc.TimeDateTime != nil && !proc.TimeDateTime.IsZero() {
p["timeDateTime"] = proc.TimeDateTime.Format(time.RFC3339)
}
processings = append(processings, p)
}
payload.Set("processing", processings)
}
if len(req.Conditions) > 0 {
var conditions []map[string]interface{}
for _, c := range req.Conditions {
conditions = append(conditions, map[string]interface{}{"text": c})
}
payload.Set("condition", conditions)
}
if req.Subject != nil {
subject := map[string]interface{}{"reference": req.Subject.Reference}
if req.Subject.Display != "" {
subject["display"] = req.Subject.Display
}
payload.Set("subject", subject)
}
if len(req.Request) > 0 {
var requests []map[string]interface{}
for _, r := range req.Request {
requests = append(requests, map[string]interface{}{"reference": r.Reference})
}
payload.Set("request", requests)
}
if req.ReceivedDateTime != nil && !req.ReceivedDateTime.IsZero() {
payload.Set("receivedTime", req.ReceivedDateTime.Format(time.RFC3339))
}
return payload
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
@@ -19,14 +20,21 @@ type repository struct{ client satusehat.SatuSehatClient }
func NewRepository(client satusehat.SatuSehatClient) Repository { return &repository{client: client} }
type hiddenCtx struct {
context.Context
}
func (r *repository) executeRequest(ctx context.Context, method, endpoint string, req interface{}) (*satusehat.FHIRResponse, error) {
resp, err := r.client.DoRequest(ctx, method, endpoint, req)
resp, err := r.client.DoRequest(hiddenCtx{ctx}, method, endpoint, req)
if err != nil {
return nil, err
return nil, errors.InternalError().Message("Failed to execute request to SatuSehat").Cause(err).Build()
}
var result map[string]interface{}
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
return nil, errors.InternalError().Message("Failed to parse SatuSehat response").Cause(err).Metadata("raw_response", string(resp)).Build()
}
if resourceType, ok := result["resourceType"].(string); ok && resourceType == "OperationOutcome" {
return nil, errors.ParseSatuSehatError(result)
}
var resourceID string
if id, ok := result["id"].(string); ok {
+13 -2
View File
@@ -22,14 +22,25 @@ func (s *service) Create(ctx context.Context, req SpecimenRequest) (*satusehat.F
}
func (s *service) Update(ctx context.Context, id string, req SpecimenRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("ID is required").Build()
return nil, errors.NewValidationError().Message("Specimen ID is required").Build()
}
return s.repo.Update(ctx, id, MapRequestToFHIR(req).Set("id", id))
payload := MapRequestToFHIR(req)
payload.Set("id", id)
return s.repo.Update(ctx, id, payload)
}
func (s *service) Patch(ctx context.Context, id string, req SpecimenPatchRequest) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Specimen ID is required").Build()
}
if len(req) == 0 {
return nil, errors.NewValidationError().Message("Patch payload cannot be empty").Build()
}
return s.repo.Patch(ctx, id, req)
}
func (s *service) GetByID(ctx context.Context, id string) (*satusehat.FHIRResponse, error) {
if id == "" {
return nil, errors.NewValidationError().Message("Specimen ID is required").Build()
}
return s.repo.GetByID(ctx, id)
}
func (s *service) Search(ctx context.Context, queryParams url.Values) (*satusehat.FHIRResponse, error) {
@@ -0,0 +1,38 @@
package studies
import (
"context"
"encoding/json"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
)
type Repository interface {
UploadDICOM(ctx context.Context, dicomBytes []byte) (*satusehat.FHIRResponse, error)
}
type repository struct {
client satusehat.SatuSehatClient
}
func NewRepository(client satusehat.SatuSehatClient) Repository {
return &repository{client: client}
}
func (r *repository) UploadDICOM(ctx context.Context, dicomBytes []byte) (*satusehat.FHIRResponse, error) {
respData, err := r.client.UploadDICOM(ctx, dicomBytes)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(respData, &result); err != nil {
return nil, errors.InternalError().Message("Gagal memparsing response DICOM dari SatuSehat").Cause(err).Build()
}
return &satusehat.FHIRResponse{
FullResponse: result,
RawResponse: respData,
}, nil
}
@@ -0,0 +1,36 @@
package studies
import (
"context"
"service/internal/interfaces/satusehat"
"service/pkg/errors"
"service/pkg/logger"
)
type Service interface {
UploadDICOM(ctx context.Context, dicomBytes []byte) (*satusehat.FHIRResponse, error)
}
type service struct {
repo Repository
}
func NewService(repo Repository) Service {
return &service{repo: repo}
}
func (s *service) UploadDICOM(ctx context.Context, dicomBytes []byte) (*satusehat.FHIRResponse, error) {
if len(dicomBytes) == 0 {
return nil, errors.NewValidationError().Message("File DICOM kosong").Build()
}
logger.Default().Info("Mempersiapkan upload DICOM", logger.Int("file_size_bytes", len(dicomBytes)))
resp, err := s.repo.UploadDICOM(ctx, dicomBytes)
if err != nil {
return nil, errors.InternalError().Message("Gagal meneruskan file DICOM ke SatuSehat").Cause(err).Build()
}
return resp, nil
}