forked from rachmadiyanti.annisa.3004/service_antrean
2444 lines
87 KiB
Go
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
|
|
}
|