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" "context" "database/sql" "fmt" "net/http" "reflect" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/jmoiron/sqlx" ) // ============================================================================= // PATIENT HANDLER STRUCT // ============================================================================= // PatientHandler handles patient services type PatientHandler struct { db database.Service queryBuilder *queryUtils.QueryBuilder validator *validation.DynamicValidator cache *InMemoryCache validate *validator.Validate // Field-to-column mappings for different entities fieldMappings map[string]map[string]string } // NewPatientHandler creates a new PatientHandler with a pre-configured QueryBuilder func NewPatientHandler() *PatientHandler { // Initialize database connection and validator db := database.New(config.LoadConfig()) if db == nil { logger.Fatal("Failed to initialize database connection") } validate := validator.New() validate.RegisterValidation("patient_status", validatePatientStatus) // 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", }) // Initialize field mappings fieldMappings := map[string]map[string]string{ "patient": { "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", }, "visit": { "Barcode": "barcode", "RegistrationDate": "registration_date", "ServiceDate": "service_date", "CheckInDate": "check_in_date", "CheckIn": "check_in", "Active": "active", }, "attachment": { "Name": "name", "FileName": "file_name", "Directory": "directory", "Active": "active", "FKMsPatientID": "fk_ms_patient_id", }, "paymentType": { "Name": "name", "Number": "number", "Active": "active", "FKMsPatientID": "fk_ms_patient_id", "FKRefPaymentType": "fk_ref_payment_type", }, } return &PatientHandler{ db: db, queryBuilder: queryBuilder, validator: validation.NewDynamicValidator(queryBuilder), cache: NewInMemoryCache(), validate: validate, fieldMappings: fieldMappings, } } // 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 }) } // ============================================================================= // 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() // Get database connection dbConn, err := h.db.GetSQLXDB("db_antrean") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return } // Parse pagination and filters query, err := h.buildPatientQuery(c) if err != nil { h.respondError(c, err.Error(), err, http.StatusBadRequest) return } // Check cache for search results if search := c.Query("search"); search != "" { cacheKey := fmt.Sprintf("patient:search:%s:%d:%d", search, query.Limit, query.Offset) 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 { h.sendPatientResponse(c, patients, query, dbConn, ctx, true) return } } } // 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) // Cache results if this was a search query if search := c.Query("search"); search != "" && len(patients) > 0 { cacheKey := fmt.Sprintf("patient:search:%s:%d:%d", search, query.Limit, query.Offset) h.cache.Set(cacheKey, patients, 15*time.Minute) logger.Info("Cached search results", map[string]interface{}{"search": search, "count": len(patients)}) } // Send response h.sendPatientResponse(c, patients, query, dbConn, ctx, false) } // 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 := h.validate.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 and execute query query := h.buildPatientDetailQuery(req.MedicalRecordNumber) 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) { var req patientModels.PatientCreateRequest if err := c.ShouldBindJSON(&req); err != nil { h.respondError(c, "Invalid request body", err, http.StatusBadRequest) return } if err := h.validate.Struct(&req); err != nil { h.respondError(c, "Validation failed", err, http.StatusBadRequest) return } if req.MedicalRecordNumber == nil || strings.TrimSpace(*req.MedicalRecordNumber) == "" { h.respondError(c, "medical_record_number required", fmt.Errorf("missing medical_record_number"), http.StatusBadRequest) return } medRec := strings.TrimSpace(*req.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(), 60*time.Second) defer cancel() // Check if patient with same medical_record_number already exists existing, err := h.getPatientByMedicalRecordNumber(medRec) if err == nil && existing.ID != 0 { // Patient exists -> attach relations only 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 req.Visits != nil { if _, err := h.insertVisits(ctx, tx, req.Visits, existing.ID); err != nil { _ = tx.Rollback() h.logAndRespondError(c, "Failed to insert visits for existing patient", 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, "Failed to insert attachments for existing patient", 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, "Failed to insert payment types for existing patient", 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 including newly inserted relations updated, err := h.getPatientByMedicalRecordNumber(medRec) if err != nil { h.logAndRespondError(c, "Failed to fetch updated patient after inserting relations", err, http.StatusInternalServerError) return } h.invalidateRelatedCache() c.JSON(http.StatusCreated, gin.H{ "message": "Relations ditambahkan ke pasien yang ada", "data": updated, }) return } // If error is other than not found, return error if err != nil && err != sql.ErrNoRows { h.logAndRespondError(c, "Failed to check existing patient", err, http.StatusInternalServerError) return } // Patient not found -> create new patient with relations createdPatient, err := h.executeCreateTransaction(ctx, dbConn, req) if err != nil { h.logAndRespondError(c, "Failed to create patient", err, http.StatusInternalServerError) return } h.invalidateRelatedCache() c.JSON(http.StatusCreated, gin.H{ "message": "Patient berhasil dibuat", "data": createdPatient, }) } // 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 := h.validate.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() // 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: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) } // ============================================================================= // HELPER FUNCTIONS // ============================================================================= // buildPatientQuery builds the base query for patient list func (h *PatientHandler) buildPatientQuery(c *gin.Context) (queryUtils.DynamicQuery, error) { // 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) // 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 { return query, fmt.Errorf("invalid 'active' value; must be true or false") } } // Handle search if search := c.Query("search"); search != "" { if len(search) > 50 { search = search[:50] } 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 + "%"}, } 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"}) } return query, nil } // buildPatientDetailQuery builds the query for patient details func (h *PatientHandler) buildPatientDetailQuery(medicalRecordNumber string) queryUtils.DynamicQuery { return 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: 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", }}, Sort: []queryUtils.SortField{ {Column: "tpv.registration_date", Order: "DESC"}, }, } } // sendPatientResponse sends the patient response with optional aggregate data func (h *PatientHandler) sendPatientResponse(c *gin.Context, patients []patientModels.Patient, query queryUtils.DynamicQuery, dbConn *sqlx.DB, ctx context.Context, fromCache bool) { var aggregateData *models.AggregateData var err error // Get aggregate data if requested if c.Query("include_summary") == "true" { if fromCache { // For cached results, we need to rebuild the filter groups // This is a simplified approach - in a real implementation, you might want to cache the aggregate data too aggregateData, err = h.getAggregateData(ctx, dbConn, query.Filters) if err != nil { h.logAndRespondError(c, "Failed to get aggregate data", err, http.StatusInternalServerError) return } } else { aggregateData, err = h.getAggregateData(ctx, dbConn, query.Filters) if err != nil { h.logAndRespondError(c, "Failed to get aggregate data", err, http.StatusInternalServerError) return } } } // Get total count for pagination metadata var total int if fromCache { total = len(patients) } else { total, err = h.getTotalCount(ctx, dbConn, query) if err != nil { h.logAndRespondError(c, "Failed to get total count", err, http.StatusInternalServerError) return } } // Build and send response meta := h.calculateMeta(query.Limit, query.Offset, total) message := "Data patient berhasil diambil" if fromCache { message = "Data patient berhasil diambil (dari cache)" } response := patientModels.PatientGetResponse{ Message: message, Data: patients, Meta: meta, } if aggregateData != nil { response.Summary = aggregateData } c.JSON(http.StatusOK, response) } // 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 } } // getPatientByMedicalRecordNumber retrieves a patient by medical record number 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, req patientModels.PatientCreateRequest) (patientModels.Patient, error) { tx, err := dbConn.BeginTxx(ctx, nil) if err != nil { return patientModels.Patient{}, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err != nil { _ = tx.Rollback() } }() // 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 patientModels.Patient{}, fmt.Errorf("failed to build insert query for patient: %w", err) } var createdPatient patientModels.Patient if err := tx.GetContext(ctx, &createdPatient, patientSQL, patientArgs...); err != nil { return patientModels.Patient{}, fmt.Errorf("failed to insert patient: %w", err) } // Sync location names if needed if err := h.syncLocationNamesIfEmpty(ctx, tx, createdPatient.ID); err != nil { return patientModels.Patient{}, fmt.Errorf("failed to sync location names: %w", err) } // Insert visits if req.Visits != nil { createdVisits, err := h.insertVisits(ctx, tx, req.Visits, createdPatient.ID) if err != nil { return patientModels.Patient{}, fmt.Errorf("failed to insert visits: %w", err) } createdPatient.Visits = createdVisits } // Insert attachments if req.Attachments != nil { createdAttachments, err := h.insertAttachments(ctx, tx, req.Attachments, createdPatient.ID) if err != nil { return patientModels.Patient{}, fmt.Errorf("failed to insert attachments: %w", err) } createdPatient.Attachments = createdAttachments } // Insert payment types if req.Payments != nil { createdPaymentTypes, err := h.insertPaymentTypes(ctx, tx, req.Payments, createdPatient.ID) if err != nil { return patientModels.Patient{}, fmt.Errorf("failed to insert payment types: %w", err) } createdPatient.PaymentTypes = createdPaymentTypes } if err := tx.Commit(); err != nil { return patientModels.Patient{}, fmt.Errorf("failed to commit transaction: %w", err) } return createdPatient, 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, "patient") 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 } // 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 } // buildUpdateColumns builds update columns and values from the request func (h *PatientHandler) buildUpdateColumns(req interface{}, entityType string) ([]string, []interface{}) { fieldToColumn, ok := h.fieldMappings[entityType] if !ok { return nil, nil } return buildUpdateColumnsFromStruct(req, fieldToColumn) } // 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 dq := queryUtils.DynamicQuery{ From: "transaction.tr_patient_visit", Aliases: "tpv", Fields: []queryUtils.SelectField{{Expression: "tpv.id", Alias: "id"}}, Filters: []queryUtils.FilterGroup{{ Filters: []queryUtils.DynamicFilter{ {Column: "tpv.barcode", Operator: queryUtils.OpEqual, Value: barcodeVal}, }, LogicOp: "AND", }}, Limit: 1, } existSQL, existArgs, err := h.queryBuilder.BuildQuery(dq) if err != nil { return nil, fmt.Errorf("failed to build barcode existence query: %w", err) } err = tx.GetContext(ctx, &existingID, existSQL, existArgs...) 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 } // 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.buildUpdateColumns(visitReq, "visit") 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 } // 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.buildUpdateColumns(attachReq, "attachment") 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 } // 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.buildUpdateColumns(ptReq, "paymentType") 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 } // 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 } // syncLocationNamesIfEmpty syncs location names from satu_db if they are empty 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 } // 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 { // handle already-correct type if v, ok := val.(models.NullableInt32); ok { return v } // handle common numeric types returned by DB driver switch v := val.(type) { case int64: return models.NullableInt32{Int32: int32(v), Valid: true} case int: return models.NullableInt32{Int32: int32(v), Valid: true} case float64: return models.NullableInt32{Int32: int32(v), Valid: true} case nil: return models.NullableInt32{} } } 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 }