refactor: use static HIS database per instance

This commit is contained in:
2026-09-04 08:56:29 +07:00
parent 10fa7a6e38
commit 9afd5b792c
15 changed files with 174 additions and 783 deletions

View File

@@ -22,29 +22,22 @@ func main() {
} }
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second) bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 20*time.Second)
registry, err := databaseconfig.NewRegistry(bootstrapCtx, databaseconfig.Setting{ resolver, err := databaseconfig.NewStaticResolver(bootstrapCtx, cfg.RSCode, databaseconfig.Setting{
Host: cfg.ManagementDBHost, Port: cfg.ManagementDBPort, Database: cfg.ManagementDBDatabase, Host: cfg.HISDBHost, Port: cfg.HISDBPort, Database: cfg.HISDBDatabase,
Username: cfg.ManagementDBUsername, Password: cfg.ManagementDBPassword, Username: cfg.HISDBUsername, Password: cfg.HISDBPassword,
}, databaseconfig.PoolConfig{ }, databaseconfig.PoolConfig{
MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns, MaxOpenConns: cfg.DBMaxOpenConns, MaxIdleConns: cfg.DBMaxIdleConns,
ConnMaxLifetime: cfg.DBConnMaxLifetime, ConnMaxLifetime: cfg.DBConnMaxLifetime,
}, cfg.DBCredentialSecret) })
bootstrapCancel() bootstrapCancel()
if err != nil { if err != nil {
log.Fatalf("load database registry: %v", err) log.Fatalf("load database HIS: %v", err)
} }
defer registry.Close() defer resolver.Close()
handler := apphttp.NewDynamicHandler() handler := apphttp.NewDynamicHandler(cfg.RSCode)
settingsHandler := apphttp.NewDatabaseSettingsHandler(registry)
protected := func(handler http.Handler) http.Handler { protected := func(handler http.Handler) http.Handler {
return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(registry, handler)) return apphttp.BearerAuth(cfg.BearerToken, apphttp.SelectDatabase(resolver, handler))
}
adminProtected := func(handler http.Handler) http.Handler {
return apphttp.BearerAuth(cfg.DBSettingsToken, handler)
}
catalogProtected := func(handler http.Handler) http.Handler {
return apphttp.BearerAuth(cfg.BearerToken, handler)
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -64,10 +57,6 @@ 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}/services", protected(http.HandlerFunc(handler.AddLabServices)))
mux.Handle("POST /api/cpone/patients/lab-registration/{regId}/packages", protected(http.HandlerFunc(handler.AddLabPackage))) 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))) mux.Handle("GET /api/cpone/patients/lab-results/{labNumber}", protected(http.HandlerFunc(handler.GetLabResult)))
mux.Handle("GET /api/cpone/database-settings", adminProtected(http.HandlerFunc(settingsHandler.List)))
mux.Handle("GET /api/cpone/database-settings/{kodeRs}", adminProtected(http.HandlerFunc(settingsHandler.Get)))
mux.Handle("POST /api/cpone/database-settings", adminProtected(http.HandlerFunc(settingsHandler.Store)))
mux.Handle("GET /api/cpone/hospitals", catalogProtected(http.HandlerFunc(settingsHandler.ListHospitals)))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -10,19 +10,18 @@ import (
) )
type Config struct { type Config struct {
AppHost string AppHost string
AppPort string AppPort string
BearerToken string RSCode string
DBSettingsToken string BearerToken string
DBMaxOpenConns int DBMaxOpenConns int
DBMaxIdleConns int DBMaxIdleConns int
DBConnMaxLifetime time.Duration DBConnMaxLifetime time.Duration
ManagementDBHost string HISDBHost string
ManagementDBPort string HISDBPort string
ManagementDBDatabase string HISDBDatabase string
ManagementDBUsername string HISDBUsername string
ManagementDBPassword string HISDBPassword string
DBCredentialSecret string
} }
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
@@ -31,26 +30,28 @@ func Load(path string) (Config, error) {
} }
cfg := Config{ cfg := Config{
AppHost: env("APP_HOST", "0.0.0.0"), AppHost: env("APP_HOST", "0.0.0.0"),
AppPort: env("APP_PORT", "8080"), AppPort: env("APP_PORT", "8080"),
BearerToken: env("CPONE_BEARER_TOKEN", ""), RSCode: env("CPONE_RS_CODE", ""),
DBSettingsToken: env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", "")), BearerToken: env("CPONE_BEARER_TOKEN", ""),
DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25), DBMaxOpenConns: envInt("DB_MAX_OPEN_CONNS", 25),
DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25), DBMaxIdleConns: envInt("DB_MAX_IDLE_CONNS", 25),
DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute, DBConnMaxLifetime: time.Duration(envInt("DB_CONN_MAX_LIFETIME_MINUTES", 5)) * time.Minute,
ManagementDBHost: env("CPONE_DB_HOST", "127.0.0.1"), HISDBHost: env("CPONE_DB_HOST", "127.0.0.1"),
ManagementDBPort: env("CPONE_DB_PORT", "3306"), HISDBPort: env("CPONE_DB_PORT", "3306"),
ManagementDBDatabase: env("CPONE_DB_DATABASE", "cpone_middleware"), HISDBDatabase: env("CPONE_DB_DATABASE", ""),
ManagementDBUsername: env("CPONE_DB_USERNAME", "root"), HISDBUsername: env("CPONE_DB_USERNAME", ""),
ManagementDBPassword: env("CPONE_DB_PASSWORD", ""), HISDBPassword: env("CPONE_DB_PASSWORD", ""),
DBCredentialSecret: env("CPONE_DATABASE_CREDENTIAL_KEY", env("CPONE_DATABASE_SETTINGS_TOKEN", env("CPONE_BEARER_TOKEN", ""))),
} }
if cfg.RSCode == "" {
return Config{}, fmt.Errorf("CPONE_RS_CODE wajib diisi")
}
if cfg.BearerToken == "" { if cfg.BearerToken == "" {
return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi") return Config{}, fmt.Errorf("CPONE_BEARER_TOKEN wajib diisi")
} }
if cfg.ManagementDBDatabase == "" { if cfg.HISDBDatabase == "" || cfg.HISDBUsername == "" {
return Config{}, fmt.Errorf("CPONE_DB_DATABASE wajib diisi") return Config{}, fmt.Errorf("CPONE_DB_DATABASE dan CPONE_DB_USERNAME wajib diisi")
} }
return cfg, nil return cfg, nil

View File

@@ -1,443 +0,0 @@
package databaseconfig
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
mysql "github.com/go-sql-driver/mysql"
"primaya-api/cpone-middleware/internal/repository"
)
var ErrSettingNotFound = errors.New("database setting not found")
var (
validRSCode = regexp.MustCompile(`^[A-Z0-9_-]+$`)
validDatabaseName = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
)
type Setting struct {
RSCode string `json:"kode_rs"`
Name string `json:"nama"`
Host string `json:"host"`
Port string `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
}
type PublicSetting struct {
RSCode string `json:"kode_rs"`
Name string `json:"nama"`
Host string `json:"host"`
Port string `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
HasPassword bool `json:"has_password"`
Active bool `json:"active"`
}
type PoolConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
type Registry struct {
mu sync.RWMutex
managementDB *sql.DB
poolConfig PoolConfig
credentialKey [32]byte
settings map[string]Setting
pools map[string]*sql.DB
retiredPools []*sql.DB
}
// NewRegistry creates the management database and tables, then loads all
// active hospital database settings.
func NewRegistry(ctx context.Context, managementSetting Setting, poolConfig PoolConfig, credentialSecret string) (*Registry, error) {
if !validDatabaseName.MatchString(managementSetting.Database) {
return nil, errors.New("nama database manajemen hanya boleh berisi huruf, angka, dan underscore")
}
if strings.TrimSpace(credentialSecret) == "" {
return nil, errors.New("credential secret database wajib diisi")
}
managementDB, err := openManagementDatabase(ctx, managementSetting, poolConfig)
if err != nil {
return nil, err
}
r := &Registry{
managementDB: managementDB,
poolConfig: poolConfig,
credentialKey: sha256.Sum256([]byte(credentialSecret)),
settings: make(map[string]Setting),
pools: make(map[string]*sql.DB),
}
if err := r.migrate(ctx); err != nil {
_ = managementDB.Close()
return nil, err
}
if err := r.load(ctx); err != nil {
_ = managementDB.Close()
return nil, err
}
return r, nil
}
func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) }
func (r *Registry) Resolve(ctx context.Context, code string) (repository.MySQLLayananRepository, error) {
code = NormalizeRSCode(code)
r.mu.RLock()
setting, exists := r.settings[code]
db := r.pools[code]
r.mu.RUnlock()
if !exists {
return repository.MySQLLayananRepository{}, ErrSettingNotFound
}
if db != nil {
return repository.NewMySQLLayananRepository(db), nil
}
newDB, err := r.openAndPing(ctx, setting)
if err != nil {
return repository.MySQLLayananRepository{}, err
}
r.mu.Lock()
if existing := r.pools[code]; existing != nil {
r.mu.Unlock()
_ = newDB.Close()
return repository.NewMySQLLayananRepository(existing), nil
}
r.pools[code] = newDB
r.mu.Unlock()
return repository.NewMySQLLayananRepository(newDB), nil
}
func (r *Registry) Upsert(ctx context.Context, setting Setting) (PublicSetting, bool, error) {
setting = normalizeSetting(setting)
if err := Validate(setting); err != nil {
return PublicSetting{}, false, err
}
newDB, err := r.openAndPing(ctx, setting)
if err != nil {
return PublicSetting{}, false, fmt.Errorf("koneksi database RS gagal: %w", err)
}
encryptedPassword, err := r.encrypt(setting.Password)
if err != nil {
_ = newDB.Close()
return PublicSetting{}, false, err
}
tx, err := r.managementDB.BeginTx(ctx, nil)
if err != nil {
_ = newDB.Close()
return PublicSetting{}, false, err
}
defer tx.Rollback()
var hospitalID int64
err = tx.QueryRowContext(ctx, `SELECT id FROM hospitals WHERE code = ? FOR UPDATE`, setting.RSCode).Scan(&hospitalID)
created := errors.Is(err, sql.ErrNoRows)
if err != nil && !created {
_ = newDB.Close()
return PublicSetting{}, false, err
}
if created {
result, execErr := tx.ExecContext(ctx, `INSERT INTO hospitals (code, name, active) VALUES (?, ?, 1)`, setting.RSCode, setting.Name)
if execErr != nil {
_ = newDB.Close()
return PublicSetting{}, false, execErr
}
hospitalID, err = result.LastInsertId()
if err != nil {
_ = newDB.Close()
return PublicSetting{}, false, err
}
} else if _, err = tx.ExecContext(ctx, `UPDATE hospitals SET name = ?, active = 1 WHERE id = ?`, setting.Name, hospitalID); err != nil {
_ = newDB.Close()
return PublicSetting{}, false, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO hospital_databases (hospital_id, host, port, database_name, username, encrypted_password)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE host = VALUES(host), port = VALUES(port), database_name = VALUES(database_name),
username = VALUES(username), encrypted_password = VALUES(encrypted_password)`,
hospitalID, setting.Host, setting.Port, setting.Database, setting.Username, encryptedPassword)
if err != nil {
_ = newDB.Close()
return PublicSetting{}, false, err
}
if err = tx.Commit(); err != nil {
_ = newDB.Close()
return PublicSetting{}, false, err
}
r.mu.Lock()
oldPool := r.pools[setting.RSCode]
r.settings[setting.RSCode] = setting
r.pools[setting.RSCode] = newDB
if oldPool != nil {
r.retiredPools = append(r.retiredPools, oldPool)
}
r.mu.Unlock()
return publicSetting(setting), created, nil
}
func (r *Registry) List() []PublicSetting {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]PublicSetting, 0, len(r.settings))
for _, setting := range r.settings {
result = append(result, publicSetting(setting))
}
sort.Slice(result, func(i, j int) bool { return result[i].RSCode < result[j].RSCode })
return result
}
func (r *Registry) Get(code string) (PublicSetting, bool) {
code = NormalizeRSCode(code)
r.mu.RLock()
defer r.mu.RUnlock()
setting, exists := r.settings[code]
return publicSetting(setting), exists
}
func (r *Registry) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
var firstErr error
for code, db := range r.pools {
if err := db.Close(); err != nil && firstErr == nil {
firstErr = err
}
delete(r.pools, code)
}
for _, db := range r.retiredPools {
if err := db.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if err := r.managementDB.Close(); err != nil && firstErr == nil {
firstErr = err
}
r.retiredPools = nil
return firstErr
}
func openManagementDatabase(ctx context.Context, setting Setting, pool PoolConfig) (*sql.DB, error) {
serverSetting := setting
serverSetting.Database = ""
bootstrap, err := sql.Open("mysql", mysqlDSN(serverSetting))
if err != nil {
return nil, err
}
if err := bootstrap.PingContext(ctx); err != nil {
_ = bootstrap.Close()
return nil, fmt.Errorf("koneksi server database manajemen gagal: %w", err)
}
_, err = bootstrap.ExecContext(ctx, "CREATE DATABASE IF NOT EXISTS `"+setting.Database+"` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
_ = bootstrap.Close()
if err != nil {
return nil, fmt.Errorf("buat database manajemen %s: %w", setting.Database, err)
}
db, err := sql.Open("mysql", mysqlDSN(setting))
if err != nil {
return nil, err
}
applyPoolConfig(db, pool)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("koneksi database manajemen gagal: %w", err)
}
return db, nil
}
func (r *Registry) migrate(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS hospitals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), UNIQUE KEY uq_hospitals_code (code), KEY idx_hospitals_active (active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS hospital_databases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, hospital_id BIGINT UNSIGNED NOT NULL,
host VARCHAR(255) NOT NULL, port SMALLINT UNSIGNED NOT NULL DEFAULT 3306,
database_name VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL, encrypted_password TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), UNIQUE KEY uq_hospital_databases_hospital (hospital_id),
CONSTRAINT fk_hospital_databases_hospital FOREIGN KEY (hospital_id) REFERENCES hospitals(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.managementDB.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("migrasi database manajemen: %w", err)
}
}
var defaultColumnExists int
if err := r.managementDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'hospitals' AND column_name = 'is_default'`).Scan(&defaultColumnExists); err != nil {
return fmt.Errorf("periksa schema database manajemen: %w", err)
}
if defaultColumnExists > 0 {
if _, err := r.managementDB.ExecContext(ctx, `ALTER TABLE hospitals DROP COLUMN is_default`); err != nil {
return fmt.Errorf("hapus kolom default rumah sakit: %w", err)
}
}
return nil
}
func (r *Registry) load(ctx context.Context) error {
rows, err := r.managementDB.QueryContext(ctx, `SELECT h.code, h.name, d.host, d.port, d.database_name, d.username, d.encrypted_password
FROM hospitals h INNER JOIN hospital_databases d ON d.hospital_id = h.id WHERE h.active = 1 ORDER BY h.code`)
if err != nil {
return err
}
defer rows.Close()
settings := make(map[string]Setting)
for rows.Next() {
var setting Setting
var encryptedPassword string
if err := rows.Scan(&setting.RSCode, &setting.Name, &setting.Host, &setting.Port, &setting.Database, &setting.Username, &encryptedPassword); err != nil {
return err
}
setting.Password, err = r.decrypt(encryptedPassword)
if err != nil {
return fmt.Errorf("decrypt password %s: %w", setting.RSCode, err)
}
settings[setting.RSCode] = setting
}
if err := rows.Err(); err != nil {
return err
}
r.settings = settings
return nil
}
func (r *Registry) encrypt(plainText string) (string, error) {
block, err := aes.NewCipher(r.credentialKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(plainText), nil)), nil
}
func (r *Registry) decrypt(encoded string) (string, error) {
sealed, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(r.credentialKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(sealed) < gcm.NonceSize() {
return "", errors.New("encrypted password tidak valid")
}
plainText, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], nil)
if err != nil {
return "", err
}
return string(plainText), nil
}
func (r *Registry) openAndPing(ctx context.Context, setting Setting) (*sql.DB, error) {
db, err := sql.Open("mysql", mysqlDSN(setting))
if err != nil {
return nil, err
}
applyPoolConfig(db, r.poolConfig)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func applyPoolConfig(db *sql.DB, pool PoolConfig) {
db.SetMaxOpenConns(pool.MaxOpenConns)
db.SetMaxIdleConns(pool.MaxIdleConns)
db.SetConnMaxLifetime(pool.ConnMaxLifetime)
}
func Validate(setting Setting) error {
setting = normalizeSetting(setting)
if setting.RSCode == "" {
return errors.New("kode_rs wajib diisi")
}
if len(setting.RSCode) > 50 {
return errors.New("kode_rs maksimal 50 karakter")
}
if !validRSCode.MatchString(setting.RSCode) {
return errors.New("kode_rs hanya boleh berisi huruf, angka, tanda hubung, dan underscore")
}
if setting.Name == "" {
return errors.New("nama rumah sakit wajib diisi")
}
if setting.Host == "" || setting.Port == "" || setting.Database == "" || setting.Username == "" {
return errors.New("host, port, database, dan username wajib diisi")
}
port, err := strconv.Atoi(setting.Port)
if err != nil || port < 1 || port > 65535 {
return errors.New("port harus berupa angka antara 1 sampai 65535")
}
return nil
}
func normalizeSetting(setting Setting) Setting {
setting.RSCode = NormalizeRSCode(setting.RSCode)
setting.Name = strings.TrimSpace(setting.Name)
setting.Host = strings.TrimSpace(setting.Host)
setting.Port = strings.TrimSpace(setting.Port)
setting.Database = strings.TrimSpace(setting.Database)
setting.Username = strings.TrimSpace(setting.Username)
return setting
}
func publicSetting(setting Setting) PublicSetting {
return PublicSetting{RSCode: setting.RSCode, Name: setting.Name, Host: setting.Host, Port: setting.Port,
Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "",
Active: true}
}
func mysqlDSN(setting Setting) string {
cfg := mysql.NewConfig()
cfg.User = setting.Username
cfg.Passwd = setting.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(setting.Host, setting.Port)
cfg.DBName = setting.Database
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{"charset": "utf8mb4"}
return cfg.FormatDSN()
}

View File

@@ -1,63 +0,0 @@
package databaseconfig
import (
"crypto/sha256"
"strings"
"testing"
)
func TestCredentialEncryptionRoundTrip(t *testing.T) {
registry := &Registry{credentialKey: sha256.Sum256([]byte("test-secret"))}
encrypted, err := registry.encrypt("database-password")
if err != nil {
t.Fatal(err)
}
if encrypted == "database-password" || strings.Contains(encrypted, "database-password") {
t.Fatalf("password was not encrypted: %q", encrypted)
}
decrypted, err := registry.decrypt(encrypted)
if err != nil {
t.Fatal(err)
}
if decrypted != "database-password" {
t.Fatalf("decrypted = %q", decrypted)
}
}
func TestCredentialCannotBeDecryptedWithDifferentKey(t *testing.T) {
first := &Registry{credentialKey: sha256.Sum256([]byte("first-secret"))}
second := &Registry{credentialKey: sha256.Sum256([]byte("second-secret"))}
encrypted, err := first.encrypt("database-password")
if err != nil {
t.Fatal(err)
}
if _, err := second.decrypt(encrypted); err == nil {
t.Fatal("expected decryption failure with a different key")
}
}
func TestValidateDatabaseSetting(t *testing.T) {
valid := Setting{RSCode: "rs_bekasi", Name: "RS Bekasi", Host: "host", Port: "3306", Database: "db", Username: "user"}
if err := Validate(valid); err != nil {
t.Fatalf("valid setting rejected: %v", err)
}
tests := []struct {
name string
mutate func(*Setting)
}{
{name: "blank code", mutate: func(s *Setting) { s.RSCode = " " }},
{name: "invalid code", mutate: func(s *Setting) { s.RSCode = "RS BEKASI" }},
{name: "blank hospital name", mutate: func(s *Setting) { s.Name = "" }},
{name: "invalid port", mutate: func(s *Setting) { s.Port = "70000" }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
setting := valid
test.mutate(&setting)
if err := Validate(setting); err == nil {
t.Fatal("expected validation error")
}
})
}
}

View File

@@ -0,0 +1,105 @@
package databaseconfig
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
"primaya-api/cpone-middleware/internal/repository"
)
// ErrRSCodeMismatch indicates that a request was sent to the wrong hospital
// instance. The code is a guard for this installation, not a database key.
var ErrRSCodeMismatch = errors.New("kode RS tidak sesuai dengan instance CPONE")
type Setting struct {
RSCode string `json:"kode_rs"`
Name string `json:"nama"`
Host string `json:"host"`
Port string `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
}
type PoolConfig struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
func NormalizeRSCode(code string) string { return strings.ToUpper(strings.TrimSpace(code)) }
// StaticResolver owns one HIS connection for one CPONE installation. Resolve
// only validates the request code and always returns the same repository.
type StaticResolver struct {
code string
db *sql.DB
repo repository.MySQLLayananRepository
}
func NewStaticResolver(ctx context.Context, code string, setting Setting, pool PoolConfig) (*StaticResolver, error) {
code = NormalizeRSCode(code)
if code == "" {
return nil, errors.New("CPONE_RS_CODE wajib diisi")
}
if setting.Host == "" || setting.Port == "" || setting.Database == "" || setting.Username == "" {
return nil, errors.New("host, port, database, dan username HIS wajib diisi")
}
port, err := strconv.Atoi(setting.Port)
if err != nil || port < 1 || port > 65535 {
return nil, errors.New("port database HIS harus berupa angka antara 1 sampai 65535")
}
db, err := sql.Open("mysql", mysqlDSN(setting))
if err != nil {
return nil, err
}
applyPoolConfig(db, pool)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("koneksi database HIS gagal: %w", err)
}
return &StaticResolver{code: code, db: db, repo: repository.NewMySQLLayananRepository(db)}, nil
}
func (r *StaticResolver) Resolve(_ context.Context, code string) (repository.MySQLLayananRepository, error) {
if NormalizeRSCode(code) != r.code {
return repository.MySQLLayananRepository{}, ErrRSCodeMismatch
}
return r.repo, nil
}
func (r *StaticResolver) Close() error {
if r == nil || r.db == nil {
return nil
}
return r.db.Close()
}
func (r *StaticResolver) Code() string { return strings.TrimSpace(r.code) }
func applyPoolConfig(db *sql.DB, pool PoolConfig) {
db.SetMaxOpenConns(pool.MaxOpenConns)
db.SetMaxIdleConns(pool.MaxIdleConns)
db.SetConnMaxLifetime(pool.ConnMaxLifetime)
}
func mysqlDSN(setting Setting) string {
cfg := mysql.NewConfig()
cfg.User = setting.Username
cfg.Passwd = setting.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(setting.Host, setting.Port)
cfg.DBName = setting.Database
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{"charset": "utf8mb4"}
return cfg.FormatDSN()
}

View File

@@ -0,0 +1,17 @@
package databaseconfig
import (
"context"
"errors"
"testing"
)
func TestStaticResolverValidatesCodeWithoutSelectingDatabase(t *testing.T) {
resolver := &StaticResolver{code: "RS_BEKASI"}
if _, err := resolver.Resolve(context.Background(), " rs_bekasi "); err != nil {
t.Fatalf("matching code returned error: %v", err)
}
if _, err := resolver.Resolve(context.Background(), "RS_JAKARTA"); !errors.Is(err, ErrRSCodeMismatch) {
t.Fatalf("wrong code error = %v, want ErrRSCodeMismatch", err)
}
}

View File

@@ -1,72 +0,0 @@
package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"primaya-api/cpone-middleware/internal/databaseconfig"
)
type DatabaseSettingsStore interface {
List() []databaseconfig.PublicSetting
Get(code string) (databaseconfig.PublicSetting, bool)
Upsert(ctx context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error)
}
type DatabaseSettingsHandler struct{ store DatabaseSettingsStore }
func NewDatabaseSettingsHandler(store DatabaseSettingsStore) DatabaseSettingsHandler {
return DatabaseSettingsHandler{store: store}
}
func (h DatabaseSettingsHandler) List(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: h.store.List()})
}
func (h DatabaseSettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
setting, found := h.store.Get(r.PathValue("kodeRs"))
if !found {
WriteJSON(w, http.StatusNotFound, Response{Success: false, Message: "Setting database tidak ditemukan", Data: nil})
return
}
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: setting})
}
func (h DatabaseSettingsHandler) Store(w http.ResponseWriter, r *http.Request) {
var payload databaseconfig.Setting
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: "Payload JSON tidak valid: " + err.Error(), Data: nil})
return
}
if err := databaseconfig.Validate(payload); err != nil {
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: err.Error(), Data: nil})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
setting, created, err := h.store.Upsert(ctx, payload)
if err != nil {
status := http.StatusInternalServerError
message := "Gagal menyimpan setting database"
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "koneksi database") {
status = http.StatusBadGateway
message = err.Error()
}
WriteJSON(w, status, Response{Success: false, Message: message, Data: nil})
return
}
status := http.StatusOK
message := "Setting database berhasil diperbarui"
if created {
status = http.StatusCreated
message = "Setting database berhasil ditambahkan"
}
WriteJSON(w, status, Response{Success: true, Message: message, Data: setting})
}

View File

@@ -1,59 +0,0 @@
package http
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/databaseconfig"
)
type fakeDatabaseSettingsStore struct {
setting databaseconfig.Setting
}
func (f *fakeDatabaseSettingsStore) List() []databaseconfig.PublicSetting { return nil }
func (f *fakeDatabaseSettingsStore) Get(string) (databaseconfig.PublicSetting, bool) {
return databaseconfig.PublicSetting{}, false
}
func (f *fakeDatabaseSettingsStore) Upsert(_ context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error) {
f.setting = setting
return databaseconfig.PublicSetting{
RSCode: setting.RSCode, Host: setting.Host, Port: setting.Port,
Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "",
}, true, nil
}
func TestStoreDatabaseSettingDoesNotExposePassword(t *testing.T) {
store := &fakeDatabaseSettingsStore{}
handler := NewDatabaseSettingsHandler(store)
body := `{"kode_rs":"RS_BEKASI","nama":"RS Bekasi","host":"10.0.0.2","port":"3306","database":"his","username":"cpone","password":"top-secret"}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(body))
recorder := httptest.NewRecorder()
handler.Store(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if store.setting.Password != "top-secret" {
t.Fatal("password was not passed to store")
}
if strings.Contains(recorder.Body.String(), "top-secret") || strings.Contains(recorder.Body.String(), `"password"`) {
t.Fatalf("response exposes password: %s", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"has_password":true`) {
t.Fatalf("response missing has_password: %s", recorder.Body.String())
}
}
func TestStoreDatabaseSettingValidation(t *testing.T) {
handler := NewDatabaseSettingsHandler(&fakeDatabaseSettingsStore{})
request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(`{"kode_rs":"invalid code"}`))
recorder := httptest.NewRecorder()
handler.Store(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
}

View File

@@ -65,6 +65,7 @@ type Handler struct {
periodeTarifRepo PeriodeTarifRepository periodeTarifRepo PeriodeTarifRepository
periodeTarifDetailRepo PeriodeTarifDetailRepository periodeTarifDetailRepo PeriodeTarifDetailRepository
perusahaanRepo PerusahaanRepository perusahaanRepo PerusahaanRepository
hospitalCode string
} }
type CPONERepository interface { type CPONERepository interface {
@@ -87,7 +88,9 @@ func NewHandler(repo CPONERepository) Handler {
} }
} }
func NewDynamicHandler() Handler { return Handler{} } func NewDynamicHandler(hospitalCode string) Handler {
return Handler{hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode))}
}
func (h Handler) layananRepository(ctx context.Context) LayananRepository { func (h Handler) layananRepository(ctx context.Context) LayananRepository {
if repo, ok := selectedRepository(ctx); ok { if repo, ok := selectedRepository(ctx); ok {

View File

@@ -1,29 +0,0 @@
package http
import (
"net/http"
"primaya-api/cpone-middleware/internal/databaseconfig"
)
type HospitalSummary struct {
RSCode string `json:"kode_rs"`
Name string `json:"nama"`
Status bool `json:"status"`
}
func (h DatabaseSettingsHandler) ListHospitals(w http.ResponseWriter, _ *http.Request) {
settings := h.store.List()
hospitals := make([]HospitalSummary, 0, len(settings))
for _, setting := range settings {
if !setting.Active {
continue
}
hospitals = append(hospitals, hospitalSummary(setting))
}
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: hospitals})
}
func hospitalSummary(setting databaseconfig.PublicSetting) HospitalSummary {
return HospitalSummary{RSCode: setting.RSCode, Name: setting.Name, Status: setting.Active}
}

View File

@@ -1,50 +0,0 @@
package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/databaseconfig"
)
type fakeHospitalStore struct {
fakeDatabaseSettingsStore
settings []databaseconfig.PublicSetting
}
func (f *fakeHospitalStore) List() []databaseconfig.PublicSetting { return f.settings }
func TestListHospitalsOnlyReturnsActiveWithoutDatabaseCredentials(t *testing.T) {
store := &fakeHospitalStore{settings: []databaseconfig.PublicSetting{
{RSCode: "AWALBROS_DEV", Name: "RS Dev Awalbros", Host: "secret-host", Username: "secret-user", Active: true},
{RSCode: "INACTIVE_RS", Name: "RS Tidak Aktif", Host: "inactive-host", Active: false},
}}
handler := NewDatabaseSettingsHandler(store)
recorder := httptest.NewRecorder()
handler.ListHospitals(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/hospitals", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
for _, expected := range []string{`"kode_rs":"AWALBROS_DEV"`, `"nama":"RS Dev Awalbros"`, `"status":true`} {
if !strings.Contains(recorder.Body.String(), expected) {
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
}
}
for _, forbidden := range []string{"INACTIVE_RS", "RS Tidak Aktif", "secret-host", "secret-user", `"host"`, `"username"`, `"password"`} {
if strings.Contains(recorder.Body.String(), forbidden) {
t.Fatalf("response contains %q: %s", forbidden, recorder.Body.String())
}
}
}
func TestListHospitalsReturnsEmptyArray(t *testing.T) {
handler := NewDatabaseSettingsHandler(&fakeHospitalStore{})
recorder := httptest.NewRecorder()
handler.ListHospitals(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/hospitals", nil))
if recorder.Body.String() != `{"success":true,"message":"ok","data":[]}`+"\n" {
t.Fatalf("body=%s", recorder.Body.String())
}
}

View File

@@ -32,6 +32,7 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil) writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
return return
} }
params.HospitalCode = h.hospitalCode
outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params) outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params)
if err != nil { if err != nil {
@@ -48,10 +49,6 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
} }
func validateMedrec(payload map[string]any) (repository.MedrecParams, string) { func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
rs, message := requiredString(payload, "rs", 20, "Kode RS wajib diisi")
if message != "" {
return repository.MedrecParams{}, message
}
name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi") name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi")
if message != "" { if message != "" {
return repository.MedrecParams{}, message return repository.MedrecParams{}, message
@@ -191,7 +188,6 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
} }
return repository.MedrecParams{ return repository.MedrecParams{
HospitalCode: normalizeText(rs),
Name: normalizeName(name), Name: normalizeName(name),
Gender: normalizeText(gender), Gender: normalizeText(gender),
DateOfBirth: birthDate, DateOfBirth: birthDate,

View File

@@ -48,9 +48,8 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
IsNewPatient: true, IsNewPatient: true,
}, },
}} }}
handler := Handler{patientRepo: repo} handler := Handler{patientRepo: repo, hospitalCode: "PRIMAYA"}
body := `{ body := `{
"rs":" primaya ",
"nama":" Budi Santos ", "nama":" Budi Santos ",
"jenis_kelamin":"l", "jenis_kelamin":"l",
"tgl_lahir":"1990-01-15", "tgl_lahir":"1990-01-15",
@@ -107,7 +106,6 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
func TestStoreMedrecValidationParity(t *testing.T) { func TestStoreMedrecValidationParity(t *testing.T) {
baseline := map[string]any{ baseline := map[string]any{
"rs": "PRIMAYA",
"nama": "BUDI SANTOS", "nama": "BUDI SANTOS",
"jenis_kelamin": "L", "jenis_kelamin": "L",
"tgl_lahir": "1990-01-15", "tgl_lahir": "1990-01-15",
@@ -122,7 +120,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
mutate func(map[string]any) mutate func(map[string]any)
message string message string
}{ }{
{name: "missing rs", mutate: func(p map[string]any) { delete(p, "rs") }, message: "Kode RS wajib diisi"},
{name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"}, {name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"},
{name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"}, {name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"},
{name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"}, {name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"},
@@ -131,7 +128,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
{name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"}, {name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"},
{name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"}, {name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"},
{name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."}, {name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."},
{name: "long hospital code", mutate: func(p map[string]any) { p["rs"] = strings.Repeat("X", 21) }, message: "The rs must not be greater than 20 characters."},
{name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."}, {name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."},
} }
@@ -172,7 +168,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
Data: data, Data: data,
}} }}
handler := Handler{patientRepo: repo} handler := Handler{patientRepo: repo}
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}` body := `{"nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body)) request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler.StoreMedrec(recorder, request) handler.StoreMedrec(recorder, request)
@@ -184,7 +180,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
func TestStoreMedrecRepositoryErrorParity(t *testing.T) { func TestStoreMedrecRepositoryErrorParity(t *testing.T) {
handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}} handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}}
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}` body := `{"nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body)) request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler.StoreMedrec(recorder, request) handler.StoreMedrec(recorder, request)

View File

@@ -46,13 +46,13 @@ func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler {
repo, err := resolver.Resolve(r.Context(), code) repo, err := resolver.Resolve(r.Context(), code)
if err != nil { if err != nil {
if errors.Is(err, databaseconfig.ErrSettingNotFound) { if errors.Is(err, databaseconfig.ErrRSCodeMismatch) {
WriteJSON(w, http.StatusNotFound, Response{Success: false, WriteJSON(w, http.StatusForbidden, Response{Success: false,
Message: "Setting database untuk kode RS " + code + " tidak ditemukan", Data: nil}) Message: "X-RS-Code tidak sesuai dengan instance CPONE", Data: nil})
return return
} }
WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false, WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false,
Message: "Database untuk kode RS " + code + " tidak dapat diakses", Data: nil}) Message: "Database HIS tidak dapat diakses", Data: nil})
return return
} }
w.Header().Set("X-RS-Code", code) w.Header().Set("X-RS-Code", code)

View File

@@ -66,14 +66,14 @@ func TestSelectDatabaseRequiresRSCode(t *testing.T) {
} }
} }
func TestSelectDatabaseUnknownCode(t *testing.T) { func TestSelectDatabaseRejectsWrongInstanceCode(t *testing.T) {
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound} resolver := &fakeDatabaseResolver{err: databaseconfig.ErrRSCodeMismatch}
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil) request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("next handler should not be called") t.Fatal("next handler should not be called")
})).ServeHTTP(recorder, request) })).ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound { if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
} }