From 102823c2b263ef2fa6476591bfb245b3ba3760e7 Mon Sep 17 00:00:00 2001 From: Tubagus Fajri Date: Thu, 1 Oct 2026 14:01:32 +0700 Subject: [PATCH] Add CPONE lab and radiology result forwarding --- .env.example | 3 + README.md | 21 +++++ cmd/server/main.go | 6 ++ internal/config/config.go | 2 + internal/http/update_result_forward.go | 73 ++++++++++++++++ internal/http/update_result_forward_test.go | 96 +++++++++++++++++++++ 6 files changed, 201 insertions(+) create mode 100644 internal/http/update_result_forward.go create mode 100644 internal/http/update_result_forward_test.go diff --git a/.env.example b/.env.example index d59fe22..89920a4 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ DB_MAX_IDLE_CONNS=25 DB_CONN_MAX_LIFETIME_MINUTES=5 CPONE_BEARER_TOKEN=replace-with-a-secure-token + +# URL dasar penerusan hasil lab dan radiologi ke CPONE. Default di kode: devcpone. +CPONE_RESULT_BASE_URL=https://devcpone.aplikasi.web.id/one-api/api_his diff --git a/README.md b/README.md index 612df89..7f872cb 100644 --- a/README.md +++ b/README.md @@ -368,3 +368,24 @@ go run ./cmd/server File `sqlc.yaml`, `sqlc/schema.sql`, dan `sqlc/query.sql` sudah disiapkan. Catatan penting: sqlc tidak bisa menerima nama tabel dinamis seperti `harga_layanan_{periodeTarifID}`. Implementasi runtime saat ini tetap memakai `database/sql` untuk menyusun nama tabel setelah `periodeTarifID` divalidasi angka, sama seperti PHP memakai `HargaLayanan::makeTableName()`. Kalau semua periode ingin digenerate oleh sqlc, buat query konkret per tabel periode, misalnya `harga_layanan_10`, `harga_layanan_11`, dan seterusnya. + +## Meneruskan hasil lab ke CPONE + +`POST /api/cpone/his/update-result` menerima body JSON yang sama dengan +`/one-api/api_his/update_result` di CPONE dan meneruskannya tanpa mengubah isi. +Gunakan `Authorization: Bearer ` dan `Content-Type: application/json`. +Field `kode_rs` pada payload diteruskan apa adanya. Header Authorization dan +Cookie dari pemanggil tidak diteruskan ke CPONE. + +URL dasar default adalah `https://devcpone.aplikasi.web.id/one-api/api_his`. +Ubah melalui `CPONE_RESULT_BASE_URL` bila diperlukan. Middleware menambahkan +`/update_result` untuk hasil lab. Status HTTP dan body +respons CPONE diteruskan ke pemanggil. Respons `422` dapat berarti hasil sudah +tersimpan di staging tetapi belum sepenuhnya cocok dengan order; periksa pesan +dan `hisOrderID` di respons untuk memastikan kondisinya. + +`POST /api/cpone/his/update-result-radiologi` meneruskan payload radiologi ke +`/one-api/api_his/update_result_radiologi` dengan aturan autentikasi dan +respons yang sama. Payload dapat memuat `dokter_id` dan `results[].expertise` +seperti kontrak devcpone. Middleware menambahkan `/update_result_radiologi` +pada `CPONE_RESULT_BASE_URL` untuk hasil radiologi. diff --git a/cmd/server/main.go b/cmd/server/main.go index 5e3427e..c1466cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -62,6 +63,11 @@ func main() { mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/services", protected(http.HandlerFunc(handler.AddLabServices))) mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage))) mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult))) + resultBaseURL := strings.TrimRight(cfg.ResultBaseURL, "/") + mux.Handle("POST /api/cpone/his/update-result", apphttp.BearerAuth(cfg.BearerToken, + apphttp.NewUpdateResultForwarder(resultBaseURL+"/update_result", nil))) + mux.Handle("POST /api/cpone/his/update-result-radiologi", apphttp.BearerAuth(cfg.BearerToken, + apphttp.NewUpdateResultForwarder(resultBaseURL+"/update_result_radiologi", nil))) // MCU report parity with docs/lap_mcu.php. The additional aliases keep the // report reachable for clients that use the Indonesian legacy route name. mux.Handle("GET /api/cpone/reports/mcu", protected(http.HandlerFunc(handler.ListLaporanMCU))) diff --git a/internal/config/config.go b/internal/config/config.go index 1430342..42d6af2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { RSCode string DepartmentID string BearerToken string + ResultBaseURL string DBMaxOpenConns int DBMaxIdleConns int DBConnMaxLifetime time.Duration @@ -36,6 +37,7 @@ func Load(path string) (Config, error) { RSCode: env("CPONE_RS_CODE", ""), DepartmentID: env("CPONE_DEPARTEMEN_ID", ""), BearerToken: env("CPONE_BEARER_TOKEN", ""), + ResultBaseURL: env("CPONE_RESULT_BASE_URL", "https://devcpone.aplikasi.web.id/one-api/api_his"), DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, diff --git a/internal/http/update_result_forward.go b/internal/http/update_result_forward.go new file mode 100644 index 0000000..df19fde --- /dev/null +++ b/internal/http/update_result_forward.go @@ -0,0 +1,73 @@ +package http + +import ( + "bytes" + "encoding/json" + "io" + "mime" + "net/http" + "time" +) + +const maxUpdateResultBody = 8 << 20 + +type resultHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +type updateResultForwarder struct { + targetURL string + client resultHTTPClient +} + +// NewUpdateResultForwarder relays the original JSON body to CPONE without +// forwarding the caller's Authorization header or cookies. +func NewUpdateResultForwarder(targetURL string, client resultHTTPClient) http.Handler { + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + return updateResultForwarder{ + targetURL: targetURL, + client: client, + } +} + +func (h updateResultForwarder) ServeHTTP(w http.ResponseWriter, r *http.Request) { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + WriteJSON(w, http.StatusUnsupportedMediaType, Response{Success: false, Message: "Content-Type harus application/json"}) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxUpdateResultBody+1)) + if err != nil { + WriteJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Gagal membaca payload hasil pemeriksaan"}) + return + } + if len(body) > maxUpdateResultBody { + WriteJSON(w, http.StatusRequestEntityTooLarge, Response{Success: false, Message: "Payload hasil pemeriksaan terlalu besar"}) + return + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil || payload == nil { + WriteJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Payload harus berupa object JSON"}) + return + } + upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, h.targetURL, bytes.NewReader(body)) + if err != nil { + WriteJSON(w, http.StatusBadGateway, Response{Success: false, Message: "URL tujuan CPONE tidak valid"}) + return + } + upstreamRequest.Header.Set("Content-Type", "application/json") + upstreamRequest.Header.Set("Accept", "application/json") + upstreamResponse, err := h.client.Do(upstreamRequest) + if err != nil { + WriteJSON(w, http.StatusBadGateway, Response{Success: false, Message: "Gagal menghubungi API hasil pemeriksaan CPONE"}) + return + } + defer upstreamResponse.Body.Close() + if contentType := upstreamResponse.Header.Get("Content-Type"); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.WriteHeader(upstreamResponse.StatusCode) + _, _ = io.Copy(w, upstreamResponse.Body) +} diff --git a/internal/http/update_result_forward_test.go b/internal/http/update_result_forward_test.go new file mode 100644 index 0000000..a47f08a --- /dev/null +++ b/internal/http/update_result_forward_test.go @@ -0,0 +1,96 @@ +package http + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type fakeResultClient func(*http.Request) (*http.Response, error) + +func (f fakeResultClient) Do(r *http.Request) (*http.Response, error) { return f(r) } + +func TestUpdateResultForwarderPreservesPayloadAndResponse(t *testing.T) { + const body = `{"kode_rs":"GM","no_regpas":"MCU260900031","results":[{"layanan_id":"1102","result":"5","unit":"mm\/jam"}]}` + called := false + client := fakeResultClient(func(r *http.Request) (*http.Response, error) { + called = true + if r.Method != http.MethodPost || r.URL.Host != "devcpone.aplikasi.web.id" || r.URL.Path != "/one-api/api_his/update_result" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + gotBody, _ := io.ReadAll(r.Body) + if string(gotBody) != body { + t.Errorf("payload changed: %s", gotBody) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Error("caller credentials were forwarded") + } + return &http.Response{ + StatusCode: http.StatusUnprocessableEntity, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"Status":{"OK":false,"Messages":"ORDER_STORED_PENDING_MATCH"}}`)), + }, nil + }) + + handler := NewUpdateResultForwarder("https://devcpone.aplikasi.web.id/one-api/api_his/update_result", client) + r := httptest.NewRequest(http.MethodPost, "/api/cpone/his/update-result", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer middleware-secret") + r.Header.Set("Cookie", "session=secret") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if !called || w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), "ORDER_STORED_PENDING_MATCH") { + t.Fatalf("called=%t status=%d body=%s", called, w.Code, w.Body.String()) + } +} + +func TestUpdateResultForwarderRejectsInvalidJSON(t *testing.T) { + called := false + client := fakeResultClient(func(r *http.Request) (*http.Response, error) { + called = true + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil + }) + handler := NewUpdateResultForwarder("https://devcpone.aplikasi.web.id/one-api/api_his/update_result", client) + r := httptest.NewRequest(http.MethodPost, "/api/cpone/his/update-result", strings.NewReader(`{"kode_rs":`)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest || called { + t.Fatalf("status=%d upstream_called=%t", w.Code, called) + } +} + +func TestRadiologyResultForwarderPreservesPayloadAndResponse(t *testing.T) { + const body = `{"kode_rs":"GM","no_regpas":"MCU260900014","medrec":"00640052","dokter_id":"DR00069","results":[{"layanan_id":"RAD-01-01-001","testName":"A103 - Thorax PA","modality":"CR","expertise":"Hasil ekspertisi"}]}` + called := false + client := fakeResultClient(func(r *http.Request) (*http.Response, error) { + called = true + if r.Method != http.MethodPost || r.URL.Host != "devcpone.aplikasi.web.id" || r.URL.Path != "/one-api/api_his/update_result_radiologi" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + } + gotBody, _ := io.ReadAll(r.Body) + if string(gotBody) != body { + t.Errorf("radiology payload changed: %s", gotBody) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Error("caller credentials were forwarded") + } + return &http.Response{ + StatusCode: http.StatusUnprocessableEntity, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"Status":{"OK":false,"Messages":"dokter_id HIS belum memiliki mapping dokter CPONE [Radiology004]"}}`)), + }, nil + }) + handler := NewUpdateResultForwarder("https://devcpone.aplikasi.web.id/one-api/api_his/update_result_radiologi", client) + r := httptest.NewRequest(http.MethodPost, "/api/cpone/his/update-result-radiologi", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer middleware-secret") + r.Header.Set("Cookie", "session=secret") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if !called || w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), "Radiology004") { + t.Fatalf("called=%t status=%d body=%s", called, w.Code, w.Body.String()) + } +}