Files
service_antrean/internal/handlers/patient/ms_patient.go
2026-01-09 09:10:10 +07:00

2444 lines
87 KiB
Go

package handlers
import (
"api-service/internal/config"
"api-service/internal/database"
models "api-service/internal/models"
patientModels "api-service/internal/models/patient"
queryUtils "api-service/internal/utils/query"
"api-service/internal/utils/validation"
"api-service/pkg/logger"
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/jmoiron/sqlx"
)
// =============================================================================
// GLOBAL INITIALIZATION & VALIDATION
// =============================================================================
var (
patientdb database.Service
patientonce sync.Once
patientvalidate *validator.Validate
)
var (
patientFieldToColumn = map[string]string{
"Name": "name",
"PhoneNumber": "phone_number",
"Gender": "gender",
"BirthDate": "birth_date",
"Address": "address",
"Active": "active",
"FKSdProvinsiID": "fk_sd_provinsi_id",
"FKSdKabupatenKotaID": "fk_sd_kabupaten_kota_id",
"FKSdKecamatanID": "fk_sd_kecamatan_id",
"FKSdKelurahanID": "fk_sd_kelurahan_id",
"DsSdProvinsi": "ds_sd_provinsi",
"DsSdKabupatenKota": "ds_sd_kabupaten_kota",
"DsSdKecamatan": "ds_sd_kecamatan",
"DsSdKelurahan": "ds_sd_kelurahan",
}
visitFieldToColumn = map[string]string{
"Barcode": "barcode",
"RegistrationDate": "registration_date",
"ServiceDate": "service_date",
"CheckInDate": "check_in_date",
"CheckIn": "check_in",
"Active": "active",
}
attachmentFieldToColumn = map[string]string{
"Name": "name",
"FileName": "file_name",
"Directory": "directory",
"Active": "active",
"FKMsPatientID": "fk_ms_patient_id",
}
paymentTypeFieldToColumn = map[string]string{
"Name": "name",
"Number": "number",
"Active": "active",
"FKMsPatientID": "fk_ms_patient_id",
"FKRefPaymentType": "fk_ref_payment_type",
}
)
// Initialize the database connection and validator once
func init() {
patientonce.Do(func() {
patientdb = database.New(config.LoadConfig())
patientvalidate = validator.New()
patientvalidate.RegisterValidation("patient_status", validatePatientStatus)
if patientdb == nil {
logger.Fatal("Failed to initialize database connection")
}
})
}
// Custom validation for patient status
func validatePatientStatus(fl validator.FieldLevel) bool {
return models.IsValidStatus(fl.Field().String())
}
// =============================================================================
// CACHE IMPLEMENTATION
// =============================================================================
// CacheEntry represents an entry in the cache
type CacheEntry struct {
Data interface{}
ExpiresAt time.Time
}
// IsExpired checks if the cache entry has expired
func (e *CacheEntry) IsExpired() bool {
return time.Now().After(e.ExpiresAt)
}
// InMemoryCache implements a simple in-memory cache with TTL
type InMemoryCache struct {
items sync.Map
}
// NewInMemoryCache creates a new in-memory cache
func NewInMemoryCache() *InMemoryCache {
return &InMemoryCache{}
}
// Get retrieves an item from the cache
func (c *InMemoryCache) Get(key string) (interface{}, bool) {
val, ok := c.items.Load(key)
if !ok {
return nil, false
}
entry, ok := val.(*CacheEntry)
if !ok || entry.IsExpired() {
c.items.Delete(key)
return nil, false
}
return entry.Data, true
}
// Set stores an item in the cache with a TTL
func (c *InMemoryCache) Set(key string, value interface{}, ttl time.Duration) {
c.items.Store(key, &CacheEntry{
Data: value,
ExpiresAt: time.Now().Add(ttl),
})
}
// Delete removes an item from the cache
func (c *InMemoryCache) Delete(key string) {
c.items.Delete(key)
}
// DeleteByPrefix removes all items with a specific prefix
func (c *InMemoryCache) DeleteByPrefix(prefix string) {
c.items.Range(func(key, value interface{}) bool {
if keyStr, ok := key.(string); ok && strings.HasPrefix(keyStr, prefix) {
c.items.Delete(key)
}
return true
})
}
// =============================================================================
// PATIENT HANDLER STRUCT
// =============================================================================
// PatientHandler handles patient services
type PatientHandler struct {
db database.Service
queryBuilder *queryUtils.QueryBuilder
validator *validation.DynamicValidator
cache *InMemoryCache
}
// NewPatientHandler creates a new PatientHandler with a pre-configured QueryBuilder
func NewPatientHandler() *PatientHandler {
// Initialize QueryBuilder with allowed columns list for security.
queryBuilder := queryUtils.NewQueryBuilder(queryUtils.DBTypePostgreSQL).
SetAllowedColumns([]string{
// Patient fields
"id", "name", "medical_record_number", "phone_number", "gender",
"birth_date", "address", "active", "fk_sd_provinsi_id",
"fk_sd_kabupaten_kota_id", "fk_sd_kecamatan_id", "fk_sd_kelurahan_id",
"ds_sd_provinsi", "ds_sd_kabupaten_kota", "ds_sd_kecamatan", "ds_sd_kelurahan",
// Visit fields
"id", "barcode", "registration_date", "service_date", "check_in_date", "check_in", "active", "fk_ms_patient_id",
// Attachment fields
"id", "name", "file_name", "directory", "active", "fk_ms_patient_id",
// Payment type fields
"id", "name", "number", "active", "fk_ms_patient_id", "fk_ref_payment_type",
})
return &PatientHandler{
db: patientdb,
queryBuilder: queryBuilder,
validator: validation.NewDynamicValidator(queryBuilder),
cache: NewInMemoryCache(),
}
}
// =============================================================================
// HANDLER ENDPOINTS (READ-ONLY)
// =============================================================================
// GetPatient godoc
// @Summary Get Patients List
// @Description Get list of patients with pagination and filters, including their visits.
// @Tags Patient
// @Accept json
// @Produce json
// @Param limit query int false "Limit (max 100)" default(10)
// @Param offset query int false "Offset" default(0)
// @Param active query string false "Filter by active status (true/false)"
// @Param search query string false "Search in medical_record_number, name, or phone_number"
// @Param include_summary query bool false "Include aggregation summary" default(false)
// @Success 200 {object} patientModels.PatientGetResponse "Success response"
// @Failure 400 {object} models.ErrorResponse "Bad request"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /patient [get]
func (h *PatientHandler) GetPatient(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
// Build base query with LEFT JOINs to fetch related data in a single trip
query := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
// Patient fields
{Expression: "mp.id", Alias: "id"},
{Expression: "mp.name", Alias: "name"},
{Expression: "mp.medical_record_number", Alias: "medical_record_number"},
{Expression: "mp.phone_number", Alias: "phone_number"},
{Expression: "mp.gender", Alias: "gender"},
{Expression: "mp.birth_date", Alias: "birth_date"},
{Expression: "mp.address", Alias: "address"},
{Expression: "mp.active", Alias: "active"},
{Expression: "mp.fk_sd_provinsi_id", Alias: "fk_sd_provinsi_id"},
{Expression: "mp.fk_sd_kabupaten_kota_id", Alias: "fk_sd_kabupaten_kota_id"},
{Expression: "mp.fk_sd_kecamatan_id", Alias: "fk_sd_kecamatan_id"},
{Expression: "mp.fk_sd_kelurahan_id", Alias: "fk_sd_kelurahan_id"},
{Expression: "mp.ds_sd_provinsi", Alias: "ds_sd_provinsi"},
{Expression: "mp.ds_sd_kabupaten_kota", Alias: "ds_sd_kabupaten_kota"},
{Expression: "mp.ds_sd_kecamatan", Alias: "ds_sd_kecamatan"},
{Expression: "mp.ds_sd_kelurahan", Alias: "ds_sd_kelurahan"},
// Visit fields
{Expression: "tpv.id", Alias: "visit_id"},
{Expression: "tpv.barcode", Alias: "visit_barcode"},
{Expression: "tpv.registration_date", Alias: "visit_registration_date"},
{Expression: "tpv.service_date", Alias: "visit_service_date"},
{Expression: "tpv.check_in_date", Alias: "visit_check_in_date"},
{Expression: "tpv.check_in", Alias: "visit_check_in"},
{Expression: "tpv.active", Alias: "visit_active"},
{Expression: "tpv.fk_ms_patient_id", Alias: "visit_fk_ms_patient_id"},
},
Sort: []queryUtils.SortField{
{Column: "mp.name", Order: "ASC"},
},
Joins: []queryUtils.Join{
{
Type: "LEFT",
Table: "transaction.tr_patient_visit",
Alias: "tpv",
OnConditions: queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "tpv.fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: "mp.id"},
},
},
},
},
}
// Parse pagination
h.parsePagination(c, &query)
// Get database connection
dbConn, err := h.db.GetSQLXDB("db_antrean")
if err != nil {
h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
return
}
// Parse filters
var filters []queryUtils.DynamicFilter
if active := c.Query("active"); active != "" {
if b, err := strconv.ParseBool(active); err == nil {
filters = append(filters, queryUtils.DynamicFilter{Column: "mp.active", Operator: queryUtils.OpEqual, Value: b})
} else {
h.respondError(c, "Invalid 'active' value; must be true or false", fmt.Errorf("invalid active value: %s", active), http.StatusBadRequest)
return
}
}
// Handle search with caching
search := c.Query("search")
var searchFilters []queryUtils.DynamicFilter
var cacheKey string
var useCache bool
if search != "" {
if len(search) > 50 {
search = search[:50]
}
cacheKey = fmt.Sprintf("patient:search:%s:%d:%d", search, query.Limit, query.Offset)
searchFilters = []queryUtils.DynamicFilter{
{Column: "mp.medical_record_number", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
{Column: "mp.name", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
{Column: "mp.phone_number", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
}
if cachedData, found := h.cache.Get(cacheKey); found {
logger.Info("Cache hit for search", map[string]interface{}{"search": search})
if patients, ok := cachedData.([]patientModels.Patient); ok {
var aggregateData *models.AggregateData
if c.Query("include_summary") == "true" {
fullFilterGroups := []queryUtils.FilterGroup{
{Filters: searchFilters, LogicOp: "OR"},
}
if len(filters) > 0 {
fullFilterGroups = append(fullFilterGroups, queryUtils.FilterGroup{Filters: filters, LogicOp: "AND"})
}
aggregateData, err = h.getAggregateData(ctx, dbConn, fullFilterGroups)
if err != nil {
h.logAndRespondError(c, "Failed to get aggregate data", err, http.StatusInternalServerError)
return
}
}
meta := h.calculateMeta(query.Limit, query.Offset, len(patients))
response := patientModels.PatientGetResponse{
Message: "Data patient berhasil diambil (dari cache)",
Data: patients,
Meta: meta,
}
if aggregateData != nil {
response.Summary = aggregateData
}
c.JSON(http.StatusOK, response)
return
}
}
useCache = true
query.Filters = append(query.Filters, queryUtils.FilterGroup{Filters: searchFilters, LogicOp: "OR"})
}
if len(filters) > 0 {
query.Filters = append(query.Filters, queryUtils.FilterGroup{Filters: filters, LogicOp: "AND"})
}
// Execute query
var results []map[string]interface{}
err = h.queryBuilder.ExecuteQuery(ctx, dbConn, query, &results)
if err != nil {
h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError)
return
}
// Process results into structured format
patients := h.processQueryResults(results)
// Get total count for pagination metadata
total, err := h.getTotalCount(ctx, dbConn, query)
if err != nil {
h.logAndRespondError(c, "Failed to get total count", err, http.StatusInternalServerError)
return
}
// Cache results if this was a search query
if useCache && len(patients) > 0 {
h.cache.Set(cacheKey, patients, 15*time.Minute)
logger.Info("Cached search results", map[string]interface{}{"search": search, "count": len(patients)})
}
// Get aggregate data if requested
var aggregateData *models.AggregateData
if c.Query("include_summary") == "true" {
aggregateData, err = h.getAggregateData(ctx, dbConn, query.Filters)
if err != nil {
h.logAndRespondError(c, "Failed to get aggregate data", err, http.StatusInternalServerError)
return
}
}
// Build and send response
meta := h.calculateMeta(query.Limit, query.Offset, total)
response := patientModels.PatientGetResponse{
Message: "Data patient berhasil diambil",
Data: patients,
Meta: meta,
}
if aggregateData != nil {
response.Summary = aggregateData
}
c.JSON(http.StatusOK, response)
}
// GetPatientByMedicalRecordNumberPost godoc
// @Summary Get Patient by Medical Record Number
// @Description Retrieves a single patient record by its Medical Record Number, including attachments, payment types, and visits.
// @Tags Patient
// @Accept json
// @Produce json
// @Param request body patientModels.PatientPostRequest true "Patient post request"
// @Success 200 {object} patientModels.PatientCreateResponse "Patient details retrieved successfully"
// @Failure 400 {object} models.ErrorResponse "Bad request or validation error"
// @Failure 404 {object} models.ErrorResponse "Patient not found"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /patient/detail [post]
func (h *PatientHandler) GetPatientByMedicalRecordNumberPost(c *gin.Context) {
var req patientModels.PatientPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
return
}
if err := patientvalidate.Struct(&req); err != nil {
h.respondError(c, "Validation failed", err, http.StatusBadRequest)
return
}
dbConn, err := h.db.GetSQLXDB("db_antrean")
if err != nil {
h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
// Check cache first for fast lookups
cacheKey := fmt.Sprintf("patient:medical_record_number:%s", req.MedicalRecordNumber)
if cachedData, found := h.cache.Get(cacheKey); found {
if patient, ok := cachedData.(*patientModels.Patient); ok {
response := patientModels.PatientCreateResponse{
Message: "Patient details retrieved successfully (dari cache)",
Data: patient,
}
c.JSON(http.StatusOK, response)
return
}
}
// Build query with joins to get all related data in one go
query := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
// Patient fields
{Expression: "mp.id", Alias: "id"},
{Expression: "mp.name", Alias: "name"},
{Expression: "mp.medical_record_number", Alias: "medical_record_number"},
{Expression: "mp.phone_number", Alias: "phone_number"},
{Expression: "mp.gender", Alias: "gender"},
{Expression: "mp.birth_date", Alias: "birth_date"},
{Expression: "mp.address", Alias: "address"},
{Expression: "mp.active", Alias: "active"},
{Expression: "mp.fk_sd_provinsi_id", Alias: "fk_sd_provinsi_id"},
{Expression: "mp.fk_sd_kabupaten_kota_id", Alias: "fk_sd_kabupaten_kota_id"},
{Expression: "mp.fk_sd_kecamatan_id", Alias: "fk_sd_kecamatan_id"},
{Expression: "mp.fk_sd_kelurahan_id", Alias: "fk_sd_kelurahan_id"},
{Expression: "mp.ds_sd_provinsi", Alias: "ds_sd_provinsi"},
{Expression: "mp.ds_sd_kabupaten_kota", Alias: "ds_sd_kabupaten_kota"},
{Expression: "mp.ds_sd_kecamatan", Alias: "ds_sd_kecamatan"},
{Expression: "mp.ds_sd_kelurahan", Alias: "ds_sd_kelurahan"},
// Visit fields
{Expression: "tpv.id", Alias: "visit_id"},
{Expression: "tpv.barcode", Alias: "visit_barcode"},
{Expression: "tpv.registration_date", Alias: "visit_registration_date"},
{Expression: "tpv.service_date", Alias: "visit_service_date"},
{Expression: "tpv.check_in_date", Alias: "visit_check_in_date"},
{Expression: "tpv.check_in", Alias: "visit_check_in"},
{Expression: "tpv.active", Alias: "visit_active"},
{Expression: "tpv.fk_ms_patient_id", Alias: "visit_fk_ms_patient_id"},
// Attachment fields
{Expression: "ma.id", Alias: "attachment_id"},
{Expression: "ma.name", Alias: "attachment_name"},
{Expression: "ma.file_name", Alias: "attachment_file_name"},
{Expression: "ma.directory", Alias: "attachment_directory"},
{Expression: "ma.active", Alias: "attachment_active"},
// Payment type fields
{Expression: "mpt.id", Alias: "payment_type_id"},
{Expression: "mpt.name", Alias: "payment_type_name"},
{Expression: "mpt.number", Alias: "payment_type_number"},
{Expression: "mpt.active", Alias: "payment_type_active"},
},
Joins: []queryUtils.Join{
{
Type: "LEFT",
Table: "transaction.tr_patient_visit",
Alias: "tpv",
OnConditions: queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "tpv.fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: "mp.id"},
},
},
},
{
Type: "LEFT",
Table: "master.ms_patient_attachment",
Alias: "ma",
OnConditions: queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "ma.fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: "mp.id"},
},
},
},
{
Type: "LEFT",
Table: "master.ms_patient_payment_type",
Alias: "mpt",
OnConditions: queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "mpt.fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: "mp.id"},
},
},
},
},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.medical_record_number", Operator: queryUtils.OpEqual, Value: req.MedicalRecordNumber},
{Column: "tpv.active", Operator: queryUtils.OpEqual, Value: true},
{Column: "ma.active", Operator: queryUtils.OpEqual, Value: true},
{Column: "mpt.active", Operator: queryUtils.OpEqual, Value: true},
},
LogicOp: "AND",
}},
// NOTE: remove Limit so all joined rows (all active visits, attachments, payment types) are returned
// Optionally sort visits by registration_date so newest/oldest order is deterministic:
Sort: []queryUtils.SortField{
{Column: "tpv.registration_date", Order: "DESC"},
},
}
var results []map[string]interface{}
if err := h.queryBuilder.ExecuteQuery(ctx, dbConn, query, &results); err != nil {
if err == sql.ErrNoRows {
h.respondError(c, "Patient not found", err, http.StatusNotFound)
} else {
h.logAndRespondError(c, "Failed to get patient", err, http.StatusInternalServerError)
}
return
}
patients := h.processQueryResults(results)
if len(patients) == 0 {
h.respondError(c, "Patient not found", fmt.Errorf("no rows"), http.StatusNotFound)
return
}
patient := patients[0]
// Cache the result for future requests
h.cache.Set(cacheKey, &patient, 30*time.Minute)
response := patientModels.PatientCreateResponse{
Message: "Patient details retrieved successfully",
Data: &patient,
}
c.JSON(http.StatusOK, response)
}
// =============================================================================
// CRUD ENDPOINTS
// =============================================================================
// CreatePatient godoc
// @Summary Create Patient
// @Description Create a new patient
// @Tags Patient
// @Accept json
// @Produce json
// @Param request body patientModels.PatientCreateRequest true "Patient creation request"
// @Success 201 {object} patientModels.PatientCreateResponse "Patient created successfully"
// @Failure 400 {object} models.ErrorResponse "Bad request or validation error"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /patient [post]
func (h *PatientHandler) CreatePatient(c *gin.Context) {
// Parse request body (supports both single object and array)
reqs, err := h.parseCreateRequest(c)
if err != nil {
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
return
}
// Validate each request
for i := range reqs {
if err := patientvalidate.Struct(&reqs[i]); err != nil {
h.respondError(c, fmt.Sprintf("Validation failed at index %d", i), err, http.StatusBadRequest)
return
}
}
dbConn, err := h.db.GetSQLXDB("db_antrean")
if err != nil {
h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
// Partition requests: new patients vs existing patients (by medical_record_number)
var toCreate []patientModels.PatientCreateRequest
createdPatients := make([]patientModels.Patient, 0, len(reqs))
// Pre-validate unique medical record numbers
for i, req := range reqs {
if req.MedicalRecordNumber == nil || strings.TrimSpace(*req.MedicalRecordNumber) == "" {
h.respondError(c, fmt.Sprintf("medical_record_number required at index %d", i), fmt.Errorf("missing medical_record_number"), http.StatusBadRequest)
return
}
existing, err := h.getPatientByMedicalRecordNumber(*req.MedicalRecordNumber)
if err != nil {
// treat as not found only if it's sql no rows
if err != nil && err != sql.ErrNoRows && !strings.Contains(err.Error(), "no rows") {
h.logAndRespondError(c, fmt.Sprintf("Failed to check existing patient at index %d", i), err, http.StatusInternalServerError)
return
}
}
if existing.ID != 0 {
// Patient exists: attach provided visits/attachments/payments to existing patient inside a transaction
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
h.logAndRespondError(c, "Failed to begin transaction for existing patient relations", err, http.StatusInternalServerError)
return
}
// If there are patient-level fields provided and you want to update them, you can call executeUpdateTransaction here.
// For now we only append relations.
if req.Visits != nil {
if _, err := h.insertVisits(ctx, tx, req.Visits, existing.ID); err != nil {
_ = tx.Rollback()
h.logAndRespondError(c, fmt.Sprintf("Failed to insert visits for existing patient at index %d", i), err, http.StatusInternalServerError)
return
}
}
if req.Attachments != nil {
if _, err := h.insertAttachments(ctx, tx, req.Attachments, existing.ID); err != nil {
_ = tx.Rollback()
h.logAndRespondError(c, fmt.Sprintf("Failed to insert attachments for existing patient at index %d", i), err, http.StatusInternalServerError)
return
}
}
if req.Payments != nil {
if _, err := h.insertPaymentTypes(ctx, tx, req.Payments, existing.ID); err != nil {
_ = tx.Rollback()
h.logAndRespondError(c, fmt.Sprintf("Failed to insert payment types for existing patient at index %d", i), err, http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
h.logAndRespondError(c, "Failed to commit transaction for existing patient relations", err, http.StatusInternalServerError)
return
}
// fetch fresh patient with relations
updated, err := h.getPatientByMedicalRecordNumber(*req.MedicalRecordNumber)
if err != nil {
h.logAndRespondError(c, "Failed to fetch updated patient after inserting relations", err, http.StatusInternalServerError)
return
}
createdPatients = append(createdPatients, updated)
} else {
// New patient: collect for bulk create
toCreate = append(toCreate, req)
}
}
// Create new patients (if any)
if len(toCreate) > 0 {
newCreated, err := h.executeCreateTransaction(ctx, dbConn, toCreate)
if err != nil {
h.logAndRespondError(c, "Failed to create patients", err, http.StatusInternalServerError)
return
}
createdPatients = append(createdPatients, newCreated...)
}
if len(createdPatients) == 0 {
h.respondError(c, "No patients created or updated", fmt.Errorf("no operations"), http.StatusBadRequest)
return
}
h.invalidateRelatedCache()
c.JSON(http.StatusCreated, gin.H{
"message": "Patients berhasil dibuat / relations ditambahkan ke pasien yang ada",
"data": createdPatients,
})
}
// UpdatePatient godoc
// @Summary Update Patient
// @Description Update an existing patient
// @Tags Patient
// @Accept json
// @Produce json
// @Param medical_record_number path string true "Patient Medical Record Number"
// @Param request body patientModels.PatientUpdateRequest true "Patient update request"
// @Success 200 {object} patientModels.PatientUpdateResponse "Patient updated successfully"
// @Failure 400 {object} models.ErrorResponse "Bad request or validation error"
// @Failure 404 {object} models.ErrorResponse "Patient not found"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /patient/{medical_record_number} [put]
func (h *PatientHandler) UpdatePatient(c *gin.Context) {
medicalRecordNumber := c.Param("medical_record_number")
if medicalRecordNumber == "" {
h.respondError(c, "Medical record number is required", fmt.Errorf("empty medical record number"), http.StatusBadRequest)
return
}
var req patientModels.PatientUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
return
}
req.MedicalRecordNumber = &medicalRecordNumber
if err := patientvalidate.Struct(&req); err != nil {
h.respondError(c, "Validation failed", err, http.StatusBadRequest)
return
}
// Get old data for cache invalidation
oldData, err := h.getPatientByMedicalRecordNumber(medicalRecordNumber)
if err != nil {
logger.Error("Failed to fetch old data for cache invalidation", map[string]interface{}{"error": err.Error(), "medical_record_number": medicalRecordNumber})
}
dbConn, err := h.db.GetSQLXDB("db_antrean")
if err != nil {
h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
// Validate medical record number must be unique, except for record with this id
// if req.MedicalRecordNumber != nil {
// if isDuplicate, err := h.validateUniqueMedicalRecordNumber(ctx, dbConn, *req.MedicalRecordNumber, medicalRecordNumber); err != nil {
// h.logAndRespondError(c, "Failed to validate medical record number", err, http.StatusInternalServerError)
// return
// } else if isDuplicate {
// h.respondError(c, "Medical record number already exists", fmt.Errorf("duplicate medical record number: %s", *req.MedicalRecordNumber), http.StatusConflict)
// return
// }
// }
// Execute update in a transaction
updatedPatient, err := h.executeUpdateTransaction(ctx, dbConn, req)
if err != nil {
h.logAndRespondError(c, "Failed to update patient", err, http.StatusInternalServerError)
return
}
// Invalidate cache
// h.cache.Delete(fmt.Sprintf("patient:id:%s", id))
h.cache.Delete(fmt.Sprintf("patient:medical_record_number:%s", *req.MedicalRecordNumber))
if oldData.ID != 0 {
h.invalidateRelatedCache()
}
response := patientModels.PatientUpdateResponse{Message: "Patient berhasil diperbarui", Data: updatedPatient}
c.JSON(http.StatusOK, response)
}
// DeletePatient godoc
// @Summary Delete Patient
// @Description Delete a patient
// @Tags Patient
// @Accept json
// @Produce json
// @Param medical_record_number path string true "Patient Medical Record Number"
// @Success 200 {object} patientModels.PatientDeleteResponse "Patient deleted successfully"
// @Failure 400 {object} models.ErrorResponse "Invalid Medical Record Number format"
// @Failure 404 {object} models.ErrorResponse "Patient not found"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /patient/{medical_record_number} [delete]
func (h *PatientHandler) DeletePatient(c *gin.Context) {
medicalRecordNumber := c.Param("medical_record_number")
if medicalRecordNumber == "" {
h.respondError(c, "Medical record number is required", fmt.Errorf("empty medical record number"), http.StatusBadRequest)
return
}
// Optional DELETE body: if provided, will limit deletion to these child IDs
var delReq struct {
VisitIDs []int64 `json:"visit_ids"`
AttachmentIDs []int64 `json:"attachment_ids"`
PaymentTypeIDs []int64 `json:"payment_type_ids"`
}
_ = c.ShouldBindJSON(&delReq) // ignore error so empty body is allowed
// Get data for cache invalidation
dataToDelete, err := h.getPatientByMedicalRecordNumber(medicalRecordNumber)
if err != nil {
logger.Error("Failed to fetch data for cache invalidation", map[string]interface{}{"error": err.Error(), "medical_record_number": medicalRecordNumber})
}
dbConn, err := h.db.GetSQLXDB("db_antrean")
if err != nil {
h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
// Execute delete in a transaction
err = h.executeDeleteTransaction(ctx, dbConn, medicalRecordNumber, delReq)
if err != nil {
h.logAndRespondError(c, "Failed to delete patient", err, http.StatusInternalServerError)
return
}
// Invalidate cache
h.cache.Delete(fmt.Sprintf("patient:medical_record_number:%s", medicalRecordNumber))
if dataToDelete.MedicalRecordNumber.String != "" {
h.cache.Delete(fmt.Sprintf("patient:medical_record_number:%s", dataToDelete.MedicalRecordNumber.String))
h.invalidateRelatedCache()
}
response := patientModels.PatientDeleteResponse{Message: "Patient berhasil dihapus", MedicalRecordNumber: medicalRecordNumber}
c.JSON(http.StatusOK, response)
}
// UpdatePatientBulk godoc
// @Summary Bulk update patients
// @Description Update multiple patients in a single request. Each object must include "id". Use /patient/bulk [put].
// @Tags Patient
// @Accept json
// @Produce json
// @Param request body []patientModels.PatientUpdateRequest true "Array of patient update requests"
// @Success 200 {object} []patientModels.PatientUpdateResponse "Updated patients"
// @Failure 400 {object} models.ErrorResponse "Bad request or validation error"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /patient/bulk [put]
// func (h *PatientHandler) UpdatePatientBulk(c *gin.Context) {
// // Parse request body (supports both single object and array)
// reqs, err := h.parseUpdateRequest(c)
// if err != nil {
// h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
// return
// }
// if len(reqs) == 0 {
// h.respondError(c, "No update objects provided", fmt.Errorf("no objects"), http.StatusBadRequest)
// return
// }
// // Normalize id fields: accept either "id" or "ids" in JSON, prefer ID
// for i := range reqs {
// if reqs[i].ID == nil && reqs[i].IDs != nil {
// reqs[i].ID = reqs[i].IDs
// }
// }
// // Ensure every object has numeric ID and validate
// for i := range reqs {
// if reqs[i].ID == nil {
// h.respondError(c, fmt.Sprintf("Missing id for update at index %d", i), fmt.Errorf("id required"), http.StatusBadRequest)
// return
// }
// if _, err := strconv.Atoi(fmt.Sprintf("%d", *reqs[i].ID)); err != nil {
// h.respondError(c, fmt.Sprintf("Invalid id at index %d", i), err, http.StatusBadRequest)
// return
// }
// if err := patientvalidate.Struct(&reqs[i]); err != nil {
// h.respondError(c, fmt.Sprintf("Validation failed at index %d", i), err, http.StatusBadRequest)
// return
// }
// }
// dbConn, err := h.db.GetSQLXDB("db_antrean")
// if err != nil {
// h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
// return
// }
// ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
// defer cancel()
// // Execute bulk update in a transaction
// updatedPatients, err := h.executeBulkUpdateTransaction(ctx, dbConn, reqs)
// if err != nil {
// h.logAndRespondError(c, "Failed to update patients", err, http.StatusInternalServerError)
// return
// }
// // Invalidate cache
// for _, p := range updatedPatients {
// h.cache.Delete(fmt.Sprintf("patient:id:%d", p.ID))
// h.cache.Delete(fmt.Sprintf("patient:medical_record_number:%s", p.MedicalRecordNumber.String))
// }
// h.invalidateRelatedCache()
// // Respond single or multiple
// if len(updatedPatients) == 1 {
// resp := patientModels.PatientUpdateResponse{Message: "Patient berhasil diperbarui", Data: &updatedPatients[0]}
// c.JSON(http.StatusOK, resp)
// return
// }
// c.JSON(http.StatusOK, gin.H{"message": "Multiple patients berhasil diperbarui", "data": updatedPatients})
// }
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
// parsePagination parses pagination parameters from the request
func (h *PatientHandler) parsePagination(c *gin.Context, query *queryUtils.DynamicQuery) {
if limit, err := strconv.Atoi(c.DefaultQuery("limit", "10")); err == nil && limit > 0 && limit <= 100 {
query.Limit = limit
}
if offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")); err == nil && offset >= 0 {
query.Offset = offset
}
}
// parseCreateRequest parses the create request body (supports both single object and array)
func (h *PatientHandler) parseCreateRequest(c *gin.Context) ([]patientModels.PatientCreateRequest, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, err
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil, fmt.Errorf("empty body")
}
var reqs []patientModels.PatientCreateRequest
if trimmed[0] == '[' {
if err := json.Unmarshal(trimmed, &reqs); err != nil {
return nil, fmt.Errorf("invalid request body (array): %w", err)
}
} else {
var single patientModels.PatientCreateRequest
if err := json.Unmarshal(trimmed, &single); err != nil {
return nil, fmt.Errorf("invalid request body (object): %w", err)
}
reqs = append(reqs, single)
}
return reqs, nil
}
// parseUpdateRequest parses the update request body (supports both single object and array)
func (h *PatientHandler) parseUpdateRequest(c *gin.Context) ([]patientModels.PatientUpdateRequest, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, err
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return nil, fmt.Errorf("empty body")
}
var reqs []patientModels.PatientUpdateRequest
if trimmed[0] == '[' {
if err := json.Unmarshal(trimmed, &reqs); err != nil {
return nil, fmt.Errorf("invalid request body (array): %w", err)
}
} else {
var single patientModels.PatientUpdateRequest
if err := json.Unmarshal(trimmed, &single); err != nil {
return nil, fmt.Errorf("invalid request body (object): %w", err)
}
reqs = append(reqs, single)
}
return reqs, nil
}
// validateUniqueMedicalRecordNumber validates that a medical record number is unique (excluding the current record if excludeID is provided)
func (h *PatientHandler) validateUniqueMedicalRecordNumber(ctx context.Context, dbConn *sqlx.DB, medicalRecordNumber string, excludeID string) (bool, error) {
rule := validation.ValidationRule{
TableName: "master.ms_patient",
UniqueColumns: []string{"medical_record_number"},
Conditions: []queryUtils.DynamicFilter{
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
}
if excludeID != "" {
rule.ExcludeIDColumn = "id"
rule.ExcludeIDValue = excludeID
}
dataToValidate := map[string]interface{}{"medical_record_number": medicalRecordNumber}
return h.validator.Validate(ctx, dbConn, rule, dataToValidate)
}
// getPatientByID retrieves a patient by ID for cache invalidation
func (h *PatientHandler) getPatientByMedicalRecordNumber(medicalRecordNumber string) (patientModels.Patient, error) {
var patient patientModels.Patient
dbConn, err := h.db.GetSQLXDB("db_antrean")
if err != nil {
return patient, err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
query := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
// Patient fields
{Expression: "mp.id", Alias: "id"},
{Expression: "mp.name", Alias: "name"},
{Expression: "mp.medical_record_number", Alias: "medical_record_number"},
{Expression: "mp.phone_number", Alias: "phone_number"},
{Expression: "mp.gender", Alias: "gender"},
{Expression: "mp.birth_date", Alias: "birth_date"},
{Expression: "mp.address", Alias: "address"},
{Expression: "mp.active", Alias: "active"},
{Expression: "mp.fk_sd_provinsi_id", Alias: "fk_sd_provinsi_id"},
{Expression: "mp.fk_sd_kabupaten_kota_id", Alias: "fk_sd_kabupaten_kota_id"},
{Expression: "mp.fk_sd_kecamatan_id", Alias: "fk_sd_kecamatan_id"},
{Expression: "mp.fk_sd_kelurahan_id", Alias: "fk_sd_kelurahan_id"},
{Expression: "mp.ds_sd_provinsi", Alias: "ds_sd_provinsi"},
{Expression: "mp.ds_sd_kabupaten_kota", Alias: "ds_sd_kabupaten_kota"},
{Expression: "mp.ds_sd_kecamatan", Alias: "ds_sd_kecamatan"},
{Expression: "mp.ds_sd_kelurahan", Alias: "ds_sd_kelurahan"},
// Visit fields
{Expression: "tpv.id", Alias: "visit_id"},
{Expression: "tpv.barcode", Alias: "visit_barcode"},
{Expression: "tpv.registration_date", Alias: "visit_registration_date"},
{Expression: "tpv.service_date", Alias: "visit_service_date"},
{Expression: "tpv.check_in_date", Alias: "visit_check_in_date"},
{Expression: "tpv.check_in", Alias: "visit_check_in"},
{Expression: "tpv.active", Alias: "visit_active"},
{Expression: "tpv.fk_ms_patient_id", Alias: "visit_fk_ms_patient_id"},
},
Joins: []queryUtils.Join{
{
Type: "LEFT",
Table: "transaction.tr_patient_visit",
Alias: "tpv",
OnConditions: queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "tpv.fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: "mp.id"},
},
},
},
},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.medical_record_number", Operator: queryUtils.OpEqual, Value: medicalRecordNumber},
},
LogicOp: "AND",
}},
Sort: []queryUtils.SortField{
{Column: "tpv.registration_date", Order: "DESC"},
},
Limit: 1,
}
var results []map[string]interface{}
if err := h.queryBuilder.ExecuteQuery(ctx, dbConn, query, &results); err != nil {
return patient, err
}
patients := h.processQueryResults(results)
if len(patients) == 0 {
return patient, sql.ErrNoRows
}
return patients[0], nil
}
// executeCreateTransaction executes the create operation in a transaction
func (h *PatientHandler) executeCreateTransaction(ctx context.Context, dbConn *sqlx.DB, reqs []patientModels.PatientCreateRequest) ([]patientModels.Patient, error) {
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
createdPatients := make([]patientModels.Patient, 0, len(reqs))
for idx, req := range reqs {
// Insert patient
patientInsert := queryUtils.InsertData{
Columns: []string{
"name", "medical_record_number", "phone_number", "gender",
"birth_date", "address", "active", "fk_sd_provinsi_id",
"fk_sd_kabupaten_kota_id", "fk_sd_kecamatan_id", "fk_sd_kelurahan_id",
"ds_sd_provinsi", "ds_sd_kabupaten_kota", "ds_sd_kecamatan", "ds_sd_kelurahan",
},
Values: []interface{}{
req.Name, *req.MedicalRecordNumber, req.PhoneNumber, req.Gender,
req.BirthDate, req.Address, req.Active, req.FKSdProvinsiID,
req.FKSdKabupatenKotaID, req.FKSdKecamatanID, req.FKSdKelurahanID,
req.DsSdProvinsi, req.DsSdKabupatenKota, req.DsSdKecamatan, req.DsSdKelurahan,
},
}
patientReturning := []string{
"id", "name", "medical_record_number", "phone_number", "gender",
"birth_date", "address", "active", "fk_sd_provinsi_id",
"fk_sd_kabupaten_kota_id", "fk_sd_kecamatan_id", "fk_sd_kelurahan_id",
"ds_sd_provinsi", "ds_sd_kabupaten_kota", "ds_sd_kecamatan", "ds_sd_kelurahan",
}
patientSQL, patientArgs, err := h.queryBuilder.BuildInsertQuery("master.ms_patient", patientInsert, patientReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build insert query for patient at index %d: %w", idx, err)
}
var createdPatient patientModels.Patient
if err := tx.GetContext(ctx, &createdPatient, patientSQL, patientArgs...); err != nil {
return nil, fmt.Errorf("failed to insert patient at index %d: %w", idx, err)
}
// -- Sync location name fields (ds_sd_*) from satu_db if they are empty --
if err := h.syncLocationNamesIfEmpty(ctx, tx, createdPatient.ID); err != nil {
return nil, fmt.Errorf("failed to sync location names for patient at index %d: %w", idx, err)
}
// Insert visits
var createdVisits []patientModels.PatientVisit
if req.Visits != nil {
createdVisits, err = h.insertVisits(ctx, tx, req.Visits, createdPatient.ID)
if err != nil {
return nil, fmt.Errorf("failed to insert visits at index %d: %w", idx, err)
}
}
// Insert attachments
var createdAttachments []patientModels.PatientAttachment
if req.Attachments != nil {
createdAttachments, err = h.insertAttachments(ctx, tx, req.Attachments, createdPatient.ID)
if err != nil {
return nil, fmt.Errorf("failed to insert attachments at index %d: %w", idx, err)
}
}
// Insert payment types
var createdPaymentTypes []patientModels.PatientPaymentType
if req.Payments != nil {
createdPaymentTypes, err = h.insertPaymentTypes(ctx, tx, req.Payments, createdPatient.ID)
if err != nil {
return nil, fmt.Errorf("failed to insert payment types at index %d: %w", idx, err)
}
}
createdPatient.Visits = createdVisits
createdPatient.Attachments = createdAttachments
createdPatient.PaymentTypes = createdPaymentTypes
createdPatients = append(createdPatients, createdPatient)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return createdPatients, nil
}
// insertVisits inserts visits for a patient
func (h *PatientHandler) insertVisits(ctx context.Context, tx *sqlx.Tx, visits []patientModels.VisitCreateRequest, patientID int64) ([]patientModels.PatientVisit, error) {
createdVisits := make([]patientModels.PatientVisit, 0, len(visits))
// detect duplicate barcodes inside the incoming batch
seen := make(map[int64]bool)
for _, visitReq := range visits {
// Require frontend to provide a barcode for new visits
if visitReq.Barcode == nil {
return nil, fmt.Errorf("visit barcode must be provided by frontend when creating a visit")
}
// validate barcode value (non-zero)
barcodeVal := int64(*visitReq.Barcode)
if barcodeVal <= 0 {
return nil, fmt.Errorf("invalid visit barcode value")
}
// check duplicates in same request
if seen[barcodeVal] {
return nil, fmt.Errorf("duplicate barcode in request: %d", barcodeVal)
}
// check existing barcode in DB within the same transaction to avoid duplicates
var existingID int64
err := tx.GetContext(ctx, &existingID, "SELECT id FROM transaction.tr_patient_visit WHERE barcode = $1 LIMIT 1", barcodeVal)
if err == nil {
return nil, fmt.Errorf("barcode already exists: %d", barcodeVal)
}
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("failed to check existing barcode: %w", err)
}
seen[barcodeVal] = true
visitInsert := queryUtils.InsertData{
Columns: []string{"barcode", "registration_date", "service_date", "check_in_date", "check_in", "active", "fk_ms_patient_id"},
Values: []interface{}{barcodeVal, visitReq.RegistrationDate, visitReq.ServiceDate, visitReq.CheckInDate, visitReq.CheckIn, visitReq.Active, patientID},
}
visitReturning := []string{"id", "barcode", "registration_date", "service_date", "check_in_date", "check_in", "active", "fk_ms_patient_id"}
visitSQL, visitArgs, err := h.queryBuilder.BuildInsertQuery("transaction.tr_patient_visit", visitInsert, visitReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build insert query for visit: %w", err)
}
var createdVisit patientModels.PatientVisit
if err := tx.GetContext(ctx, &createdVisit, visitSQL, visitArgs...); err != nil {
return nil, fmt.Errorf("failed to insert tr_patient_visit: %w", err)
}
createdVisits = append(createdVisits, createdVisit)
}
return createdVisits, nil
}
// insertAttachments inserts attachments for a patient
func (h *PatientHandler) insertAttachments(ctx context.Context, tx *sqlx.Tx, attachments []patientModels.PatientAttachmentCreateRequest, patientID int64) ([]patientModels.PatientAttachment, error) {
createdAttachments := make([]patientModels.PatientAttachment, 0, len(attachments))
for _, attachReq := range attachments {
attachInsert := queryUtils.InsertData{
Columns: []string{"name", "file_name", "directory", "active", "fk_ms_patient_id"},
Values: []interface{}{attachReq.Name, attachReq.FileName, attachReq.Directory, attachReq.Active, patientID},
}
attachReturning := []string{"id", "name", "file_name", "directory", "active", "fk_ms_patient_id"}
attachSQL, attachArgs, err := h.queryBuilder.BuildInsertQuery("master.ms_patient_attachment", attachInsert, attachReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build insert query for attachment: %w", err)
}
var createdAttach patientModels.PatientAttachment
if err := tx.GetContext(ctx, &createdAttach, attachSQL, attachArgs...); err != nil {
return nil, fmt.Errorf("failed to insert ms_patient_attachment: %w", err)
}
createdAttachments = append(createdAttachments, createdAttach)
}
return createdAttachments, nil
}
// insertPaymentTypes inserts payment types for a patient
func (h *PatientHandler) insertPaymentTypes(ctx context.Context, tx *sqlx.Tx, paymentTypes []patientModels.PatientPaymentTypeCreateRequest, patientID int64) ([]patientModels.PatientPaymentType, error) {
createdPaymentTypes := make([]patientModels.PatientPaymentType, 0, len(paymentTypes))
for _, ptReq := range paymentTypes {
ptInsert := queryUtils.InsertData{
Columns: []string{"name", "number", "active", "fk_ms_patient_id", "fk_ref_payment_type"},
Values: []interface{}{ptReq.Name, ptReq.Number, ptReq.Active, patientID, ptReq.FKRefPaymentType},
}
ptReturning := []string{"id", "name", "number", "active", "fk_ms_patient_id", "fk_ref_payment_type"}
ptSQL, ptArgs, err := h.queryBuilder.BuildInsertQuery("master.ms_patient_payment_type", ptInsert, ptReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build insert query for payment type: %w", err)
}
var createdPt patientModels.PatientPaymentType
if err := tx.GetContext(ctx, &createdPt, ptSQL, ptArgs...); err != nil {
return nil, fmt.Errorf("failed to insert ms_patient_payment_type: %w", err)
}
createdPaymentTypes = append(createdPaymentTypes, createdPt)
}
return createdPaymentTypes, nil
}
// executeUpdateTransaction executes the update operation in a transaction
func (h *PatientHandler) executeUpdateTransaction(ctx context.Context, dbConn *sqlx.DB, req patientModels.PatientUpdateRequest) (*patientModels.Patient, error) {
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
// Build update columns from provided fields
columns, values := h.buildUpdateColumns(req)
var updatedPatient patientModels.Patient
if len(columns) == 0 {
// No patient-level columns to update: select existing row
updatedPatient, err = h.getPatientByMedicalRecordNumber(*req.MedicalRecordNumber)
if err != nil {
return nil, fmt.Errorf("failed to fetch existing patient: %w", err)
}
} else {
// Update patient row and get returning
updateData := queryUtils.UpdateData{Columns: columns, Values: values}
filters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "medical_record_number", Operator: queryUtils.OpEqual, Value: *req.MedicalRecordNumber},
},
LogicOp: "AND",
}}
returningCols := []string{
"id", "name", "medical_record_number", "phone_number", "gender",
"birth_date", "address", "active", "fk_sd_provinsi_id",
"fk_sd_kabupaten_kota_id", "fk_sd_kecamatan_id", "fk_sd_kelurahan_id",
"ds_sd_provinsi", "ds_sd_kabupaten_kota", "ds_sd_kecamatan", "ds_sd_kelurahan",
}
sqlQuery, args, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient", updateData, filters, returningCols...)
if err != nil {
return nil, fmt.Errorf("failed to build update query: %w", err)
}
if err := tx.GetContext(ctx, &updatedPatient, sqlQuery, args...); err != nil {
if err == sql.ErrNoRows || err.Error() == "sql: no rows in result set" {
return nil, fmt.Errorf("patient not found: %w", err)
}
return nil, fmt.Errorf("failed to update patient: %w", err)
}
}
// Sync ds_sd_* from satu_db if empty
if err := h.syncLocationNamesIfEmpty(ctx, tx, updatedPatient.ID); err != nil {
return nil, fmt.Errorf("failed to sync location names: %w", err)
}
// Handle visits (update only if ID provided)
var updatedVisits []patientModels.PatientVisit
if req.Visits != nil {
updatedVisits, err = h.updateVisits(ctx, tx, req.Visits, updatedPatient.ID)
if err != nil {
return nil, fmt.Errorf("failed to update visits: %w", err)
}
}
// Handle attachments (update only if ID provided)
var updatedAttachments []patientModels.PatientAttachment
if req.Attachments != nil {
updatedAttachments, err = h.updateAttachments(ctx, tx, req.Attachments, updatedPatient.ID)
if err != nil {
return nil, fmt.Errorf("failed to update attachments: %w", err)
}
}
// Handle payment types (update only if ID provided)
var updatedPaymentTypes []patientModels.PatientPaymentType
if req.Payments != nil {
updatedPaymentTypes, err = h.updatePaymentTypes(ctx, tx, req.Payments, updatedPatient.ID)
if err != nil {
return nil, fmt.Errorf("failed to update payment types: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
// Attach related rows
updatedPatient.Visits = updatedVisits
updatedPatient.Attachments = updatedAttachments
updatedPatient.PaymentTypes = updatedPaymentTypes
return &updatedPatient, nil
}
// buildUpdateColumns builds update columns and values from the request
func (h *PatientHandler) buildUpdateColumns(req patientModels.PatientUpdateRequest) ([]string, []interface{}) {
return buildUpdateColumnsFromStruct(&req, patientFieldToColumn)
}
// selectPatientByID selects a patient by ID
func (h *PatientHandler) selectPatientByID(ctx context.Context, dbConn *sqlx.DB, id int) (patientModels.Patient, error) {
var updatedPatient patientModels.Patient
dq := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
{Expression: "mp.id", Alias: "id"},
{Expression: "mp.name", Alias: "name"},
{Expression: "mp.medical_record_number", Alias: "medical_record_number"},
{Expression: "mp.phone_number", Alias: "phone_number"},
{Expression: "mp.gender", Alias: "gender"},
{Expression: "mp.birth_date", Alias: "birth_date"},
{Expression: "mp.address", Alias: "address"},
{Expression: "mp.active", Alias: "active"},
{Expression: "mp.fk_sd_provinsi_id", Alias: "fk_sd_provinsi_id"},
{Expression: "mp.fk_sd_kabupaten_kota_id", Alias: "fk_sd_kabupaten_kota_id"},
{Expression: "mp.fk_sd_kecamatan_id", Alias: "fk_sd_kecamatan_id"},
{Expression: "mp.fk_sd_kelurahan_id", Alias: "fk_sd_kelurahan_id"},
{Expression: "mp.ds_sd_provinsi", Alias: "ds_sd_provinsi"},
{Expression: "mp.ds_sd_kabupaten_kota", Alias: "ds_sd_kabupaten_kota"},
{Expression: "mp.ds_sd_kecamatan", Alias: "ds_sd_kecamatan"},
{Expression: "mp.ds_sd_kelurahan", Alias: "ds_sd_kelurahan"},
},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.id", Operator: queryUtils.OpEqual, Value: id},
},
LogicOp: "AND",
}},
Limit: 1,
}
err := h.queryBuilder.ExecuteQueryRow(ctx, dbConn, dq, &updatedPatient)
if err != nil {
return updatedPatient, fmt.Errorf("failed to fetch patient: %w", err)
}
return updatedPatient, nil
}
// updateVisits updates visits for a patient
func (h *PatientHandler) updateVisits(ctx context.Context, tx *sqlx.Tx, visits []patientModels.VisitUpdateRequest, patientID int64) ([]patientModels.PatientVisit, error) {
updatedVisits := make([]patientModels.PatientVisit, 0, len(visits))
for _, visitReq := range visits {
// Use barcode as the identifier for updates
if visitReq.Barcode == nil || *visitReq.Barcode <= 0 {
return nil, fmt.Errorf("visit must include barcode to be updated; insert not allowed in UpdatePatient")
}
visitCols, visitVals := h.buildVisitUpdateColumns(visitReq)
if len(visitCols) == 0 {
continue // No columns to update
}
visitUpdate := queryUtils.UpdateData{Columns: visitCols, Values: visitVals}
visitFilters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "barcode", Operator: queryUtils.OpEqual, Value: *visitReq.Barcode},
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
},
LogicOp: "AND",
}}
visitReturning := []string{"id", "barcode", "registration_date", "service_date", "check_in_date", "check_in", "active", "fk_ms_patient_id"}
visitSQL, visitArgs, err := h.queryBuilder.BuildUpdateQuery("transaction.tr_patient_visit", visitUpdate, visitFilters, visitReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build visit update query: %w", err)
}
var updatedVisit patientModels.PatientVisit
if err := tx.GetContext(ctx, &updatedVisit, visitSQL, visitArgs...); err != nil {
return nil, fmt.Errorf("failed to update tr_patient_visit: %w", err)
}
updatedVisits = append(updatedVisits, updatedVisit)
}
return updatedVisits, nil
}
// buildVisitUpdateColumns builds update columns and values for a visit
func (h *PatientHandler) buildVisitUpdateColumns(visitReq patientModels.VisitUpdateRequest) ([]string, []interface{}) {
return buildUpdateColumnsFromStruct(&visitReq, visitFieldToColumn)
}
// updateAttachments updates attachments for a patient
func (h *PatientHandler) updateAttachments(ctx context.Context, tx *sqlx.Tx, attachments []patientModels.PatientAttachmentUpdateRequest, patientID int64) ([]patientModels.PatientAttachment, error) {
updatedAttachments := make([]patientModels.PatientAttachment, 0, len(attachments))
for _, attachReq := range attachments {
if attachReq.ID == nil || *attachReq.ID <= 0 {
return nil, fmt.Errorf("attachment must include id to be updated; insert not allowed in UpdatePatient")
}
attachCols, attachVals := h.buildAttachmentUpdateColumns(attachReq)
if len(attachCols) == 0 {
continue // No columns to update
}
attachUpdate := queryUtils.UpdateData{Columns: attachCols, Values: attachVals}
attachFilters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: *attachReq.ID},
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
},
LogicOp: "AND",
}}
attachReturning := []string{"id", "name", "file_name", "directory", "active", "fk_ms_patient_id"}
attachSQL, attachArgs, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient_attachment", attachUpdate, attachFilters, attachReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build attachment update query: %w", err)
}
var updatedAttach patientModels.PatientAttachment
if err := tx.GetContext(ctx, &updatedAttach, attachSQL, attachArgs...); err != nil {
return nil, fmt.Errorf("failed to update ms_patient_attachment: %w", err)
}
updatedAttachments = append(updatedAttachments, updatedAttach)
}
return updatedAttachments, nil
}
// buildAttachmentUpdateColumns builds update columns and values for an attachment
func (h *PatientHandler) buildAttachmentUpdateColumns(attachReq patientModels.PatientAttachmentUpdateRequest) ([]string, []interface{}) {
return buildUpdateColumnsFromStruct(&attachReq, attachmentFieldToColumn)
}
// updatePaymentTypes updates payment types for a patient
func (h *PatientHandler) updatePaymentTypes(ctx context.Context, tx *sqlx.Tx, paymentTypes []patientModels.PatientPaymentTypeUpdateRequest, patientID int64) ([]patientModels.PatientPaymentType, error) {
updatedPaymentTypes := make([]patientModels.PatientPaymentType, 0, len(paymentTypes))
for _, ptReq := range paymentTypes {
if ptReq.Number == nil || strings.TrimSpace(*ptReq.Number) == "" {
return nil, fmt.Errorf("payment type must include number to be updated; insert not allowed in UpdatePatient")
}
ptCols, ptVals := h.buildPaymentTypeUpdateColumns(ptReq)
if len(ptCols) == 0 {
continue // No columns to update
}
ptUpdate := queryUtils.UpdateData{Columns: ptCols, Values: ptVals}
ptFilters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "number", Operator: queryUtils.OpEqual, Value: *ptReq.Number},
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
},
LogicOp: "AND",
}}
ptReturning := []string{"id", "name", "number", "active", "fk_ms_patient_id", "fk_ref_payment_type"}
ptSQL, ptArgs, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient_payment_type", ptUpdate, ptFilters, ptReturning...)
if err != nil {
return nil, fmt.Errorf("failed to build payment type update query: %w", err)
}
var updatedPt patientModels.PatientPaymentType
if err := tx.GetContext(ctx, &updatedPt, ptSQL, ptArgs...); err != nil {
return nil, fmt.Errorf("failed to update ms_patient_payment_type: %w", err)
}
updatedPaymentTypes = append(updatedPaymentTypes, updatedPt)
}
return updatedPaymentTypes, nil
}
// buildPaymentTypeUpdateColumns builds update columns and values for a payment type
func (h *PatientHandler) buildPaymentTypeUpdateColumns(ptReq patientModels.PatientPaymentTypeUpdateRequest) ([]string, []interface{}) {
return buildUpdateColumnsFromStruct(&ptReq, paymentTypeFieldToColumn)
}
// executeDeleteTransaction executes the delete operation in a transaction
func (h *PatientHandler) executeDeleteTransaction(ctx context.Context, dbConn *sqlx.DB, medicalRecordNumber string, delReq struct {
VisitIDs []int64 `json:"visit_ids"`
AttachmentIDs []int64 `json:"attachment_ids"`
PaymentTypeIDs []int64 `json:"payment_type_ids"`
}) error {
tx, err := dbConn.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
// Soft-delete patient (active = false) only if currently active != false
patientUpdate := queryUtils.UpdateData{Columns: []string{"active"}, Values: []interface{}{false}}
patientFilters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "medical_record_number", Operator: queryUtils.OpEqual, Value: medicalRecordNumber},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
patientSQL, patientArgs, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient", patientUpdate, patientFilters)
if err != nil {
return fmt.Errorf("failed to build delete query for patient: %w", err)
}
res, err := tx.ExecContext(ctx, patientSQL, patientArgs...)
if err != nil {
return fmt.Errorf("failed to execute delete for patient: %w", err)
}
ra, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows for patient delete: %w", err)
}
if ra == 0 {
return fmt.Errorf("patient not found: %w", sql.ErrNoRows)
}
// Soft-delete related visits
if err := h.deleteRelatedVisits(ctx, tx, medicalRecordNumber, delReq.VisitIDs); err != nil {
return fmt.Errorf("failed to delete visits: %w", err)
}
// Soft-delete related attachments
if err := h.deleteRelatedAttachments(ctx, tx, medicalRecordNumber, delReq.AttachmentIDs); err != nil {
return fmt.Errorf("failed to delete attachments: %w", err)
}
// Soft-delete related payment types
if err := h.deleteRelatedPaymentTypes(ctx, tx, medicalRecordNumber, delReq.PaymentTypeIDs); err != nil {
return fmt.Errorf("failed to delete payment types: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// deleteRelatedVisits deletes related visits for a patient
func (h *PatientHandler) deleteRelatedVisits(ctx context.Context, tx *sqlx.Tx, medicalRecordNumber string, visitIDs []int64) error {
var patientID int64
// Query patient ID using the query builder and execute within the same transaction
dq := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{{Expression: "mp.id", Alias: "id"}},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.medical_record_number", Operator: queryUtils.OpEqual, Value: medicalRecordNumber},
},
LogicOp: "AND",
}},
Limit: 1,
}
patientSQL, patientArgs, err := h.queryBuilder.BuildQuery(dq)
if err != nil {
return fmt.Errorf("failed to build patient id query: %w", err)
}
if err := tx.GetContext(ctx, &patientID, patientSQL, patientArgs...); err != nil {
return fmt.Errorf("failed to get patient ID: %w", err)
}
visitUpdate := queryUtils.UpdateData{Columns: []string{"active"}, Values: []interface{}{false}}
var visitFilters []queryUtils.FilterGroup
if len(visitIDs) > 0 {
// Delete only specified visit IDs that belong to this patient
visitFilters = []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
{Column: "id", Operator: queryUtils.OpEqual, Value: visitIDs},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
} else {
// Delete all visits for the patient
visitFilters = []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
}
visitSQL, visitArgs, err := h.queryBuilder.BuildUpdateQuery("transaction.tr_patient_visit", visitUpdate, visitFilters)
if err != nil {
return fmt.Errorf("failed to build visit delete query: %w", err)
}
if _, err := tx.ExecContext(ctx, visitSQL, visitArgs...); err != nil {
return fmt.Errorf("failed to execute delete for visits: %w", err)
}
return nil
}
// deleteRelatedAttachments deletes related attachments for a patient
func (h *PatientHandler) deleteRelatedAttachments(ctx context.Context, tx *sqlx.Tx, medicalRecordNumber string, attachmentIDs []int64) error {
var patientID int64
// Query patient ID using the query builder and execute within the same transaction
dq := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{{Expression: "mp.id", Alias: "id"}},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.medical_record_number", Operator: queryUtils.OpEqual, Value: medicalRecordNumber},
},
LogicOp: "AND",
}},
Limit: 1,
}
patientSQL, patientArgs, err := h.queryBuilder.BuildQuery(dq)
if err != nil {
return fmt.Errorf("failed to build patient id query: %w", err)
}
if err := tx.GetContext(ctx, &patientID, patientSQL, patientArgs...); err != nil {
return fmt.Errorf("failed to get patient ID: %w", err)
}
attachUpdate := queryUtils.UpdateData{Columns: []string{"active"}, Values: []interface{}{false}}
var attachFilters []queryUtils.FilterGroup
if len(attachmentIDs) > 0 {
// Delete only specified attachment IDs that belong to this patient
attachFilters = []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
{Column: "id", Operator: queryUtils.OpEqual, Value: attachmentIDs},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
} else {
// Delete all attachments for the patient
attachFilters = []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
}
attachSQL, attachArgs, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient_attachment", attachUpdate, attachFilters)
if err != nil {
return fmt.Errorf("failed to build attachment delete query: %w", err)
}
if _, err := tx.ExecContext(ctx, attachSQL, attachArgs...); err != nil {
return fmt.Errorf("failed to execute delete for attachments: %w", err)
}
return nil
}
// deleteRelatedPaymentTypes deletes related payment types for a patient
func (h *PatientHandler) deleteRelatedPaymentTypes(ctx context.Context, tx *sqlx.Tx, medicalRecordNumber string, paymentTypeIDs []int64) error {
var patientID int64
// Query patient ID using the query builder and execute within the same transaction
dq := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{{Expression: "mp.id", Alias: "id"}},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.medical_record_number", Operator: queryUtils.OpEqual, Value: medicalRecordNumber},
},
LogicOp: "AND",
}},
Limit: 1,
}
patientSQL, patientArgs, err := h.queryBuilder.BuildQuery(dq)
if err != nil {
return fmt.Errorf("failed to build patient id query: %w", err)
}
if err := tx.GetContext(ctx, &patientID, patientSQL, patientArgs...); err != nil {
return fmt.Errorf("failed to get patient ID: %w", err)
}
ptUpdate := queryUtils.UpdateData{Columns: []string{"active"}, Values: []interface{}{false}}
var ptFilters []queryUtils.FilterGroup
if len(paymentTypeIDs) > 0 {
// Delete only specified payment type IDs that belong to this patient
ptFilters = []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
{Column: "id", Operator: queryUtils.OpEqual, Value: paymentTypeIDs},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
} else {
// Delete all payment types for the patient
ptFilters = []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: patientID},
{Column: "active", Operator: queryUtils.OpNotEqual, Value: false},
},
LogicOp: "AND",
}}
}
ptSQL, ptArgs, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient_payment_type", ptUpdate, ptFilters)
if err != nil {
return fmt.Errorf("failed to build payment type delete query: %w", err)
}
if _, err := tx.ExecContext(ctx, ptSQL, ptArgs...); err != nil {
return fmt.Errorf("failed to execute delete for payment types: %w", err)
}
return nil
}
func (h *PatientHandler) syncLocationNamesIfEmpty(ctx context.Context, tx *sqlx.Tx, patientID int64) error {
// Read current FK and DS values from master.ms_patient within the provided transaction using query builder
dq := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
{Expression: "mp.id", Alias: "id"},
{Expression: "mp.fk_sd_provinsi_id", Alias: "fk_sd_provinsi_id"},
{Expression: "mp.fk_sd_kabupaten_kota_id", Alias: "fk_sd_kabupaten_kota_id"},
{Expression: "mp.fk_sd_kecamatan_id", Alias: "fk_sd_kecamatan_id"},
{Expression: "mp.fk_sd_kelurahan_id", Alias: "fk_sd_kelurahan_id"},
{Expression: "mp.ds_sd_provinsi", Alias: "ds_sd_provinsi"},
{Expression: "mp.ds_sd_kabupaten_kota", Alias: "ds_sd_kabupaten_kota"},
{Expression: "mp.ds_sd_kecamatan", Alias: "ds_sd_kecamatan"},
{Expression: "mp.ds_sd_kelurahan", Alias: "ds_sd_kelurahan"},
},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "mp.id", Operator: queryUtils.OpEqual, Value: patientID},
},
LogicOp: "AND",
}},
Limit: 1,
}
selectSQL, selectArgs, err := h.queryBuilder.BuildQuery(dq)
if err != nil {
return fmt.Errorf("failed to build select query for patient: %w", err)
}
var row struct {
ID int64 `db:"id"`
FKProv sql.NullInt64 `db:"fk_sd_provinsi_id"`
FKKab sql.NullInt64 `db:"fk_sd_kabupaten_kota_id"`
FKKec sql.NullInt64 `db:"fk_sd_kecamatan_id"`
FKKel sql.NullInt64 `db:"fk_sd_kelurahan_id"`
DSProv sql.NullString `db:"ds_sd_provinsi"`
DSKab sql.NullString `db:"ds_sd_kabupaten_kota"`
DSKec sql.NullString `db:"ds_sd_kecamatan"`
DSKel sql.NullString `db:"ds_sd_kelurahan"`
}
if err := tx.GetContext(ctx, &row, selectSQL, selectArgs...); err != nil {
return fmt.Errorf("failed to fetch patient row for sync: %w", err)
}
needProv := (!row.DSProv.Valid || strings.TrimSpace(row.DSProv.String) == "") && row.FKProv.Valid && row.FKProv.Int64 > 0
needKab := (!row.DSKab.Valid || strings.TrimSpace(row.DSKab.String) == "") && row.FKKab.Valid && row.FKKab.Int64 > 0
needKec := (!row.DSKec.Valid || strings.TrimSpace(row.DSKec.String) == "") && row.FKKec.Valid && row.FKKec.Int64 > 0
needKel := (!row.DSKel.Valid || strings.TrimSpace(row.DSKel.String) == "") && row.FKKel.Valid && row.FKKel.Int64 > 0
if !needProv && !needKab && !needKec && !needKel {
return nil
}
// Connect to satu_db to read lookup names
dbConn, err := h.db.GetSQLXDB("postgres_satudata")
if err != nil {
return fmt.Errorf("failed to get satu_db connection: %w", err)
}
// Define location configurations to avoid repetition
locationConfigs := []struct {
table string
column string
fkID int64
dsField *sql.NullString
dsCol string
}{
{"daftar_provinsi", "Provinsi", row.FKProv.Int64, &row.DSProv, "ds_sd_provinsi"},
{"daftar_kabupaten_kota", "Kabupaten_kota", row.FKKab.Int64, &row.DSKab, "ds_sd_kabupaten_kota"},
{"daftar_kecamatan", "Kecamatan", row.FKKec.Int64, &row.DSKec, "ds_sd_kecamatan"},
{"daftar_desa_kelurahan", "Desa_kelurahan", row.FKKel.Int64, &row.DSKel, "ds_sd_kelurahan"},
}
updateCols := make([]string, 0)
updateVals := make([]interface{}, 0)
// Process each location configuration
for _, config := range locationConfigs {
// Skip if FK ID is invalid
if config.fkID <= 0 {
continue
}
// Skip if DS field is already populated
if config.dsField.Valid && strings.TrimSpace(config.dsField.String) != "" {
continue
}
// Build raw SQL query since query builder seems to have issues
query := fmt.Sprintf(`SELECT "%s"."%s" AS "name" FROM "%s" WHERE ("id" = $1) LIMIT 1`,
config.table, config.column, config.table)
var out struct {
Name sql.NullString `db:"name"`
}
if err := dbConn.GetContext(ctx, &out, query, config.fkID); err != nil {
logger.Warn("Failed to fetch location name", map[string]interface{}{
"table": config.table,
"id": config.fkID,
"error": err.Error(),
})
continue
}
if out.Name.Valid {
updateCols = append(updateCols, config.dsCol)
updateVals = append(updateVals, out.Name.String)
*config.dsField = sql.NullString{String: out.Name.String, Valid: true}
}
}
if len(updateCols) == 0 {
return nil
}
// Build update using query builder and execute within the same transaction
ud := queryUtils.UpdateData{
Columns: updateCols,
Values: updateVals,
}
filters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: patientID},
},
LogicOp: "AND",
}}
updateSQL, updateArgs, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient", ud, filters)
if err != nil {
return fmt.Errorf("failed to build update query for patient ds_sd_*: %w", err)
}
if _, err := tx.ExecContext(ctx, updateSQL, updateArgs...); err != nil {
return fmt.Errorf("failed to execute update for patient ds_sd_*: %w", err)
}
return nil
}
// executeBulkUpdateTransaction executes the bulk update operation in a transaction
// func (h *PatientHandler) executeBulkUpdateTransaction(ctx context.Context, dbConn *sqlx.DB, reqs []patientModels.PatientUpdateRequest) ([]patientModels.Patient, error) {
// tx, err := dbConn.BeginTxx(ctx, nil)
// if err != nil {
// return nil, fmt.Errorf("failed to begin transaction: %w", err)
// }
// defer func() {
// if err != nil {
// _ = tx.Rollback()
// }
// }()
// updatedPatients := make([]patientModels.Patient, 0, len(reqs))
// for idx, req := range reqs {
// // Validate uniqueness of medical record number excluding itself
// if req.MedicalRecordNumber != nil {
// if isDuplicate, err := h.validateUniqueMedicalRecordNumber(ctx, dbConn, *req.MedicalRecordNumber, fmt.Sprintf("%d", *req.ID)); err != nil {
// return nil, fmt.Errorf("failed to validate medical record number at index %d: %w", idx, err)
// } else if isDuplicate {
// return nil, fmt.Errorf("medical record number already exists at index %d: %s", idx, *req.MedicalRecordNumber)
// }
// }
// // Build update columns from provided fields
// columns, values := h.buildUpdateColumns(req)
// updateData := queryUtils.UpdateData{Columns: columns, Values: values}
// filters := []queryUtils.FilterGroup{{
// Filters: []queryUtils.DynamicFilter{
// {Column: "id", Operator: queryUtils.OpEqual, Value: req.ID},
// },
// LogicOp: "AND",
// }}
// returningCols := []string{
// "id", "name", "medical_record_number", "phone_number", "gender",
// "birth_date", "address", "active", "fk_sd_provinsi_id",
// "fk_sd_kabupaten_kota_id", "fk_sd_kecamatan_id", "fk_sd_kelurahan_id",
// "ds_sd_provinsi", "ds_sd_kabupaten_kota", "ds_sd_kecamatan", "ds_sd_kelurahan",
// }
// var updated patientModels.Patient
// if len(columns) == 0 {
// // No patient-level columns to update: select existing row
// updated, err = h.getPatientByMedicalRecordNumber(*req.MedicalRecordNumber)
// if err != nil {
// return nil, fmt.Errorf("failed to fetch existing patient: %w", err)
// }
// } else {
// sqlQuery, args, err := h.queryBuilder.BuildUpdateQuery("master.ms_patient", updateData, filters, returningCols...)
// if err != nil {
// return nil, fmt.Errorf("failed to build update query at index %d: %w", idx, err)
// }
// if err := tx.GetContext(ctx, &updated, sqlQuery, args...); err != nil {
// return nil, fmt.Errorf("failed to update patient at index %d: %w", idx, err)
// }
// }
// updatedPatients = append(updatedPatients, updated)
// }
// if err := tx.Commit(); err != nil {
// return nil, fmt.Errorf("failed to commit transaction: %w", err)
// }
// return updatedPatients, nil
// }
// processQueryResults processes the query results into a structured format
func (h *PatientHandler) processQueryResults(results []map[string]interface{}) []patientModels.Patient {
patientsMap := make(map[int64]*patientModels.Patient)
order := make([]int64, 0, len(results))
for _, result := range results {
patientID := getInt64(result, "id")
patient, exists := patientsMap[patientID]
if !exists {
patient = &patientModels.Patient{
ID: patientID,
Name: getNullString(result, "name"),
MedicalRecordNumber: getNullString(result, "medical_record_number"),
PhoneNumber: getNullString(result, "phone_number"),
Gender: getNullString(result, "gender"),
BirthDate: getNullTime(result, "birth_date"),
Address: getNullString(result, "address"),
Active: getNullBool(result, "active"),
FKSdProvinsiID: getNullInt64(result, "fk_sd_provinsi_id"),
FKSdKabupatenKotaID: getNullInt64(result, "fk_sd_kabupaten_kota_id"),
FKSdKecamatanID: getNullInt64(result, "fk_sd_kecamatan_id"),
FKSdKelurahanID: getNullInt64(result, "fk_sd_kelurahan_id"),
DsSdProvinsi: getNullString(result, "ds_sd_provinsi"),
DsSdKabupatenKota: getNullString(result, "ds_sd_kabupaten_kota"),
DsSdKecamatan: getNullString(result, "ds_sd_kecamatan"),
DsSdKelurahan: getNullString(result, "ds_sd_kelurahan"),
Visits: []patientModels.PatientVisit{},
Attachments: []patientModels.PatientAttachment{},
PaymentTypes: []patientModels.PatientPaymentType{},
}
patientsMap[patientID] = patient
order = append(order, patientID)
}
// Process and add visit if it exists
if visitID := getInt64(result, "visit_id"); visitID != 0 {
if !h.hasVisit(patient, visitID) {
patient.Visits = append(patient.Visits, patientModels.PatientVisit{
ID: visitID,
Barcode: getNullableInt32(result, "visit_barcode"),
RegistrationDate: getNullTime(result, "visit_registration_date"),
ServiceDate: getNullTime(result, "visit_service_date"),
CheckInDate: getNullTime(result, "visit_check_in_date"),
CheckIn: getNullBool(result, "visit_check_in"),
Active: getNullBool(result, "visit_active"),
FKMsPatientID: getNullableInt32(result, "visit_fk_ms_patient_id"),
})
}
}
// Process and add attachment if it exists
if attachmentID := getInt64(result, "attachment_id"); attachmentID != 0 {
if !h.hasAttachment(patient, attachmentID) {
patient.Attachments = append(patient.Attachments, patientModels.PatientAttachment{
ID: attachmentID,
Name: getNullString(result, "attachment_name"),
FileName: getNullString(result, "attachment_file_name"),
Directory: getNullString(result, "attachment_directory"),
Active: getNullBool(result, "attachment_active"),
FKMsPatientID: getNullableInt32(result, "fk_ms_patient_id"),
})
}
}
// Process and add payment type if it exists
if paymentTypeID := getInt64(result, "payment_type_id"); paymentTypeID != 0 {
if !h.hasPaymentType(patient, paymentTypeID) {
patient.PaymentTypes = append(patient.PaymentTypes, patientModels.PatientPaymentType{
ID: paymentTypeID,
Name: getNullString(result, "payment_type_name"),
Number: getNullString(result, "payment_type_number"),
Active: getNullBool(result, "payment_type_active"),
FKMsPatientID: getNullableInt32(result, "fk_ms_patient_id"),
FKRefPaymentType: getNullableInt32(result, "fk_ref_payment_type"),
})
}
}
}
// Convert map to slice while preserving order
patients := make([]patientModels.Patient, 0, len(patientsMap))
for _, id := range order {
if p, ok := patientsMap[id]; ok {
patients = append(patients, *p)
}
}
return patients
}
// hasVisit checks if a visit already exists in the patient's slice
func (h *PatientHandler) hasVisit(patient *patientModels.Patient, visitID int64) bool {
for _, visit := range patient.Visits {
if visit.ID == visitID {
return true
}
}
return false
}
// hasAttachment checks if an attachment already exists in the patient's slice
func (h *PatientHandler) hasAttachment(patient *patientModels.Patient, attachmentID int64) bool {
for _, att := range patient.Attachments {
if att.ID == attachmentID {
return true
}
}
return false
}
// hasPaymentType checks if a payment type already exists in the patient's slice
func (h *PatientHandler) hasPaymentType(patient *patientModels.Patient, paymentTypeID int64) bool {
for _, pt := range patient.PaymentTypes {
if pt.ID == paymentTypeID {
return true
}
}
return false
}
// getTotalCount gets the total count of records matching the query
func (h *PatientHandler) getTotalCount(ctx context.Context, dbConn *sqlx.DB, query queryUtils.DynamicQuery) (int, error) {
countQuery := queryUtils.DynamicQuery{
From: query.From,
Aliases: query.Aliases,
Filters: query.Filters,
Joins: query.Joins,
}
count, err := h.queryBuilder.ExecuteCount(ctx, dbConn, countQuery)
if err != nil {
return 0, fmt.Errorf("failed to execute count query: %w", err)
}
return int(count), nil
}
// invalidateRelatedCache invalidates all related cache entries
func (h *PatientHandler) invalidateRelatedCache() {
h.cache.DeleteByPrefix("patient:search:")
h.cache.DeleteByPrefix("patient:dynamic:")
h.cache.DeleteByPrefix("patient:stats:")
h.cache.DeleteByPrefix("patient:id:")
h.cache.DeleteByPrefix("patient:medical_record_number:")
}
// getAggregateData gets aggregate data for the patients
func (h *PatientHandler) getAggregateData(ctx context.Context, dbConn *sqlx.DB, filterGroups []queryUtils.FilterGroup) (*models.AggregateData, error) {
aggregate := &models.AggregateData{
ByStatus: make(map[string]int),
}
var wg sync.WaitGroup
var mu sync.Mutex
errChan := make(chan error, 2)
// Count by status
wg.Add(1)
go func() {
defer wg.Done()
queryCtx, queryCancel := context.WithTimeout(ctx, 20*time.Second)
defer queryCancel()
query := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
{Expression: "active"},
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: filterGroups,
GroupBy: []string{"active"},
}
var results []struct {
Status string `db:"active"`
Count int `db:"count"`
}
err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &results)
if err != nil {
errChan <- fmt.Errorf("status query failed: %w", err)
return
}
mu.Lock()
for _, result := range results {
aggregate.ByStatus[result.Status] = result.Count
switch result.Status {
case "active":
aggregate.TotalActive = result.Count
case "draft":
aggregate.TotalDraft = result.Count
case "inactive":
aggregate.TotalInactive = result.Count
}
}
mu.Unlock()
}()
// Get last updated and today's stats
wg.Add(1)
go func() {
defer wg.Done()
queryCtx, queryCancel := context.WithTimeout(ctx, 20*time.Second)
defer queryCancel()
// Last updated
query1 := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{{Expression: "MAX(date_updated)"}},
Filters: filterGroups,
}
var lastUpdated sql.NullTime
err := h.queryBuilder.ExecuteQueryRow(queryCtx, dbConn, query1, &lastUpdated)
if err != nil {
errChan <- fmt.Errorf("last updated query failed: %w", err)
return
}
today := time.Now().Format("2006-01-02")
// Query for created_today
createdTodayQuery := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "DATE(date_created)", Operator: queryUtils.OpEqual, Value: today},
},
LogicOp: "AND",
}),
}
var createdToday int
err = h.queryBuilder.ExecuteQueryRow(queryCtx, dbConn, createdTodayQuery, &createdToday)
if err != nil {
errChan <- fmt.Errorf("created today query failed: %w", err)
return
}
// Query for updated_today
updatedTodayQuery := queryUtils.DynamicQuery{
From: "master.ms_patient",
Aliases: "mp",
Fields: []queryUtils.SelectField{
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "DATE(date_updated)", Operator: queryUtils.OpEqual, Value: today},
{Column: "DATE(date_created)", Operator: queryUtils.OpNotEqual, Value: today},
},
LogicOp: "AND",
}),
}
var updatedToday int
err = h.queryBuilder.ExecuteQueryRow(queryCtx, dbConn, updatedTodayQuery, &updatedToday)
if err != nil {
errChan <- fmt.Errorf("updated today query failed: %w", err)
return
}
mu.Lock()
if lastUpdated.Valid {
aggregate.LastUpdated = &lastUpdated.Time
}
aggregate.CreatedToday = createdToday
aggregate.UpdatedToday = updatedToday
mu.Unlock()
}()
wg.Wait()
close(errChan)
for err := range errChan {
if err != nil {
return nil, err
}
}
return aggregate, nil
}
// logAndRespondError logs an error and sends an error response
func (h *PatientHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) {
logger.Error(message, map[string]interface{}{"error": err.Error(), "status_code": statusCode})
h.respondError(c, message, err, statusCode)
}
// respondError sends an error response
func (h *PatientHandler) respondError(c *gin.Context, message string, err error, statusCode int) {
errorMessage := message
if gin.Mode() == gin.ReleaseMode {
errorMessage = "Internal server error"
}
c.JSON(statusCode, models.ErrorResponse{Error: errorMessage, Code: statusCode, Message: err.Error(), Timestamp: time.Now()})
}
// calculateMeta calculates pagination metadata
func (h *PatientHandler) calculateMeta(limit, offset, total int) models.MetaResponse {
totalPages, currentPage := 0, 1
if limit > 0 {
totalPages = (total + limit - 1) / limit
currentPage = (offset / limit) + 1
}
return models.MetaResponse{
Limit: limit, Offset: offset, Total: total, TotalPages: totalPages,
CurrentPage: currentPage, HasNext: offset+limit < total, HasPrev: offset > 0,
}
}
// =============================================================================
// UTILITY FUNCTIONS (DATA EXTRACTION)
// =============================================================================
// getInt64 safely extracts an int64 from a map[string]interface{}
func getInt64(m map[string]interface{}, key string) int64 {
if val, ok := m[key]; ok {
switch v := val.(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
}
}
return 0
}
// getNullString safely extracts a sql.NullString from a map[string]interface{}
func getNullString(m map[string]interface{}, key string) sql.NullString {
if val, ok := m[key]; ok {
if ns, ok := val.(sql.NullString); ok {
return ns
}
if s, ok := val.(string); ok {
return sql.NullString{String: s, Valid: true}
}
}
return sql.NullString{Valid: false}
}
// getNullableInt32 safely extracts a models.NullableInt32 from a map[string]interface{}
func getNullableInt32(m map[string]interface{}, key string) models.NullableInt32 {
if val, ok := m[key]; ok {
if v, ok := val.(models.NullableInt32); ok {
return v
}
}
return models.NullableInt32{}
}
func getNullInt64(m map[string]interface{}, key string) sql.NullInt64 {
if val, ok := m[key]; ok {
if v, ok := val.(sql.NullInt64); ok {
return v
}
}
return sql.NullInt64{}
}
// getNullBool safely extracts a sql.NullBool from a map[string]interface{}
func getNullBool(m map[string]interface{}, key string) sql.NullBool {
if val, ok := m[key]; ok {
if nb, ok := val.(sql.NullBool); ok {
return nb
}
if b, ok := val.(bool); ok {
return sql.NullBool{Bool: b, Valid: true}
}
}
return sql.NullBool{Valid: false}
}
// getNullTime safely extracts a sql.NullTime from a map[string]interface{}
func getNullTime(m map[string]interface{}, key string) sql.NullTime {
if val, ok := m[key]; ok {
if nt, ok := val.(sql.NullTime); ok {
return nt
}
if t, ok := val.(time.Time); ok {
return sql.NullTime{Time: t, Valid: true}
}
}
return sql.NullTime{Valid: false}
}
// buildUpdateColumnsFromStruct uses reflection to build update columns for structs with simple pointer fields.
// It requires a map to link struct field names to their corresponding database column names.
func buildUpdateColumnsFromStruct(v interface{}, fieldToColumn map[string]string) ([]string, []interface{}) {
val := reflect.ValueOf(v)
if val.Kind() != reflect.Ptr || val.IsNil() {
return nil, nil
}
val = val.Elem()
typ := val.Type()
columns := make([]string, 0, len(fieldToColumn))
values := make([]interface{}, 0, len(fieldToColumn))
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// We only care about fields defined in our map
fieldName := fieldType.Name
if _, ok := fieldToColumn[fieldName]; !ok {
continue
}
// Check if the field is a non-nil pointer
if field.Kind() == reflect.Ptr && !field.IsNil() {
columns = append(columns, fieldToColumn[fieldName])
values = append(values, field.Elem().Interface()) // Get the value the pointer points to
}
}
return columns, values
}