penambahan All case Satu sehat
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+60
@@ -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)
|
||||
}
|
||||
+15
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user