From 93c1709af761e247ce6addeaf37edd470b023a3c Mon Sep 17 00:00:00 2001 From: Annisa Rachmadiyanti Date: Fri, 9 Jan 2026 09:10:10 +0700 Subject: [PATCH] Patient + Visit --- internal/handlers/patient/ms_patient.go | 1655 ++++++++++++++++++++++- internal/models/patient/ms_patient.go | 186 ++- internal/routes/v1/routes.go | 9 +- 3 files changed, 1757 insertions(+), 93 deletions(-) diff --git a/internal/handlers/patient/ms_patient.go b/internal/handlers/patient/ms_patient.go index 2ac6ca0..c405a90 100644 --- a/internal/handlers/patient/ms_patient.go +++ b/internal/handlers/patient/ms_patient.go @@ -6,11 +6,16 @@ import ( 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" @@ -31,6 +36,47 @@ var ( 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() { @@ -120,6 +166,7 @@ func (c *InMemoryCache) DeleteByPrefix(prefix string) { type PatientHandler struct { db database.Service queryBuilder *queryUtils.QueryBuilder + validator *validation.DynamicValidator cache *InMemoryCache } @@ -128,10 +175,13 @@ 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 @@ -141,6 +191,7 @@ func NewPatientHandler() *PatientHandler { return &PatientHandler{ db: patientdb, queryBuilder: queryBuilder, + validator: validation.NewDynamicValidator(queryBuilder), cache: NewInMemoryCache(), } } @@ -151,7 +202,7 @@ func NewPatientHandler() *PatientHandler { // GetPatient godoc // @Summary Get Patients List -// @Description Get list of patients with pagination and filters, including their attachments and payment types. +// @Description Get list of patients with pagination and filters, including their visits. // @Tags Patient // @Accept json // @Produce json @@ -173,6 +224,7 @@ func (h *PatientHandler) GetPatient(c *gin.Context) { 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"}, @@ -189,17 +241,15 @@ func (h *PatientHandler) GetPatient(c *gin.Context) { {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"}, - // 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"}, + // 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"}, @@ -207,21 +257,11 @@ func (h *PatientHandler) GetPatient(c *gin.Context) { Joins: []queryUtils.Join{ { Type: "LEFT", - Table: "master.ms_patient_attachment", - Alias: "ma", + Table: "transaction.tr_patient_visit", + Alias: "tpv", 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"}, + {Column: "tpv.fk_ms_patient_id", Operator: queryUtils.OpEqual, Value: "mp.id"}, }, }, }, @@ -359,9 +399,9 @@ func (h *PatientHandler) GetPatient(c *gin.Context) { c.JSON(http.StatusOK, response) } -// GetPatientByIDPost godoc -// @Summary Get Patient by ID -// @Description Retrieves a single patient record by its ID, including attachments and payment types. +// 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 @@ -371,7 +411,7 @@ func (h *PatientHandler) GetPatient(c *gin.Context) { // @Failure 404 {object} models.ErrorResponse "Patient not found" // @Failure 500 {object} models.ErrorResponse "Internal server error" // @Router /patient/detail [post] -func (h *PatientHandler) GetPatientByIDPost(c *gin.Context) { +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) @@ -410,6 +450,7 @@ func (h *PatientHandler) GetPatientByIDPost(c *gin.Context) { 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"}, @@ -426,6 +467,15 @@ func (h *PatientHandler) GetPatientByIDPost(c *gin.Context) { {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"}, @@ -439,6 +489,16 @@ func (h *PatientHandler) GetPatientByIDPost(c *gin.Context) { {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", @@ -463,10 +523,17 @@ func (h *PatientHandler) GetPatientByIDPost(c *gin.Context) { 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", }}, - Limit: 1, + // 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{} @@ -499,7 +566,355 @@ func (h *PatientHandler) GetPatientByIDPost(c *gin.Context) { } // ============================================================================= -// HELPER FUNCTIONS (READ-ONLY) +// 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 @@ -512,7 +927,1098 @@ func (h *PatientHandler) parsePagination(c *gin.Context, query *queryUtils.Dynam } } -// processQueryResults processes the flat query results into a structured nested format +// 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)) @@ -531,14 +2037,15 @@ func (h *PatientHandler) processQueryResults(results []map[string]interface{}) [ BirthDate: getNullTime(result, "birth_date"), Address: getNullString(result, "address"), Active: getNullBool(result, "active"), - FKSdProvinsiID: getNullableInt32(result, "fk_sd_provinsi_id"), - FKSdKabupatenKotaID: getNullableInt32(result, "fk_sd_kabupaten_kota_id"), - FKSdKecamatanID: getNullableInt32(result, "fk_sd_kecamatan_id"), - FKSdKelurahanID: getNullableInt32(result, "fk_sd_kelurahan_id"), + 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{}, } @@ -546,6 +2053,22 @@ func (h *PatientHandler) processQueryResults(results []map[string]interface{}) [ 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) { @@ -586,6 +2109,16 @@ func (h *PatientHandler) processQueryResults(results []map[string]interface{}) [ 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 { @@ -623,6 +2156,15 @@ func (h *PatientHandler) getTotalCount(ctx context.Context, dbConn *sqlx.DB, que 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{ @@ -833,6 +2375,15 @@ func getNullableInt32(m map[string]interface{}, key string) 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 { @@ -858,3 +2409,35 @@ func getNullTime(m map[string]interface{}, key string) sql.NullTime { } 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 +} diff --git a/internal/models/patient/ms_patient.go b/internal/models/patient/ms_patient.go index d687ab2..a003689 100644 --- a/internal/models/patient/ms_patient.go +++ b/internal/models/patient/ms_patient.go @@ -90,24 +90,29 @@ import ( // Patient represents the data structure for the patient table // with proper null handling and optimized JSON marshaling type Patient struct { - ID int64 `json:"id" db:"id"` - Name sql.NullString `json:"name,omitempty" db:"name"` - MedicalRecordNumber sql.NullString `json:"medical_record_number,omitempty" db:"medical_record_number"` - PhoneNumber sql.NullString `json:"phone_number,omitempty" db:"phone_number"` - Gender sql.NullString `json:"gender,omitempty" db:"gender"` - BirthDate sql.NullTime `json:"birth_date,omitempty" db:"birth_date"` - Address sql.NullString `json:"address,omitempty" db:"address"` - Active sql.NullBool `json:"active,omitempty" db:"active"` - FKSdProvinsiID models.NullableInt32 `json:"fk_sd_provinsi_id,omitempty" db:"fk_sd_provinsi_id"` - FKSdKabupatenKotaID models.NullableInt32 `json:"fk_sd_kabupaten_kota_id,omitempty" db:"fk_sd_kabupaten_kota_id"` - FKSdKecamatanID models.NullableInt32 `json:"fk_sd_kecamatan_id,omitempty" db:"fk_sd_kecamatan_id"` - FKSdKelurahanID models.NullableInt32 `json:"fk_sd_kelurahan_id,omitempty" db:"fk_sd_kelurahan_id"` - DsSdProvinsi sql.NullString `json:"ds_sd_provinsi,omitempty" db:"ds_sd_provinsi"` - DsSdKabupatenKota sql.NullString `json:"ds_sd_kabupaten_kota,omitempty" db:"ds_sd_kabupaten_kota"` - DsSdKecamatan sql.NullString `json:"ds_sd_kecamatan,omitempty" db:"ds_sd_kecamatan"` - DsSdKelurahan sql.NullString `json:"ds_sd_kelurahan,omitempty" db:"ds_sd_kelurahan"` - Attachments []PatientAttachment `json:"attachments,omitempty"` - PaymentTypes []PatientPaymentType `json:"payment_types,omitempty"` + ID int64 `json:"id" db:"id"` + Name sql.NullString `json:"name,omitempty" db:"name"` + MedicalRecordNumber sql.NullString `json:"medical_record_number,omitempty" db:"medical_record_number"` + PhoneNumber sql.NullString `json:"phone_number,omitempty" db:"phone_number"` + Gender sql.NullString `json:"gender,omitempty" db:"gender"` + BirthDate sql.NullTime `json:"birth_date,omitempty" db:"birth_date"` + Address sql.NullString `json:"address,omitempty" db:"address"` + Active sql.NullBool `json:"active,omitempty" db:"active"` + FKSdProvinsiID sql.NullInt64 `json:"fk_sd_provinsi_id,omitempty" db:"fk_sd_provinsi_id"` + FKSdKabupatenKotaID sql.NullInt64 `json:"fk_sd_kabupaten_kota_id,omitempty" db:"fk_sd_kabupaten_kota_id"` + FKSdKecamatanID sql.NullInt64 `json:"fk_sd_kecamatan_id,omitempty" db:"fk_sd_kecamatan_id"` + FKSdKelurahanID sql.NullInt64 `json:"fk_sd_kelurahan_id,omitempty" db:"fk_sd_kelurahan_id"` + DsSdProvinsi sql.NullString `json:"ds_sd_provinsi,omitempty" db:"ds_sd_provinsi"` + DsSdKabupatenKota sql.NullString `json:"ds_sd_kabupaten_kota,omitempty" db:"ds_sd_kabupaten_kota"` + DsSdKecamatan sql.NullString `json:"ds_sd_kecamatan,omitempty" db:"ds_sd_kecamatan"` + DsSdKelurahan sql.NullString `json:"ds_sd_kelurahan,omitempty" db:"ds_sd_kelurahan"` + // ProvinsiName sql.NullString `json:"Provinsi,omitempty" db:"Provinsi"` + // KabupatenKotaName sql.NullString `json:"Kabupaten_kota,omitempty" db:"Kabupaten_kota"` + // KecamatanName sql.NullString `json:"Kecamatan,omitempty" db:"Kecamatan"` + // KelurahanName sql.NullString `json:"Desa_kelurahan,omitempty" db:"Desa_kelurahan"` + Attachments []PatientAttachment `json:"attachments,omitempty"` + PaymentTypes []PatientPaymentType `json:"payment_types,omitempty"` + Visits []PatientVisit `json:"visits,omitempty"` } type PatientPaymentType struct { @@ -128,6 +133,17 @@ type PatientAttachment struct { FKMsPatientID models.NullableInt32 `json:"fk_ms_patient_id,omitempty" db:"fk_ms_patient_id"` } +type PatientVisit struct { + ID int64 `json:"id" db:"id"` + Barcode models.NullableInt32 `json:"barcode,omitempty" db:"barcode"` + RegistrationDate sql.NullTime `json:"registration_date,omitempty" db:"registration_date"` + ServiceDate sql.NullTime `json:"service_date,omitempty" db:"service_date"` + CheckInDate sql.NullTime `json:"check_in_date,omitempty" db:"check_in_date"` + CheckIn sql.NullBool `json:"check_in,omitempty" db:"check_in"` + Active sql.NullBool `json:"active,omitempty" db:"active"` + FKMsPatientID models.NullableInt32 `json:"fk_ms_patient_id,omitempty" db:"fk_ms_patient_id"` +} + // Custom JSON marshaling untuk Patient agar NULL values tidak muncul di response func (r Patient) MarshalJSON() ([]byte, error) { type Alias Patient @@ -139,10 +155,10 @@ func (r Patient) MarshalJSON() ([]byte, error) { BirthDate *string `json:"birth_date,omitempty"` Address *string `json:"address,omitempty"` Active *bool `json:"active,omitempty"` - FKSdProvinsiID *int32 `json:"fk_sd_provinsi_id,omitempty"` - FKSdKabupatenKotaID *int32 `json:"fk_sd_kabupaten_kota_id,omitempty"` - FKSdKecamatanID *int32 `json:"fk_sd_kecamatan_id,omitempty"` - FKSdKelurahanID *int32 `json:"fk_sd_kelurahan_id,omitempty"` + FKSdProvinsiID *int `json:"fk_sd_provinsi_id,omitempty"` + FKSdKabupatenKotaID *int `json:"fk_sd_kabupaten_kota_id,omitempty"` + FKSdKecamatanID *int `json:"fk_sd_kecamatan_id,omitempty"` + FKSdKelurahanID *int `json:"fk_sd_kelurahan_id,omitempty"` DsSdProvinsi *string `json:"ds_sd_provinsi,omitempty"` DsSdKabupatenKota *string `json:"ds_sd_kabupaten_kota,omitempty"` DsSdKecamatan *string `json:"ds_sd_kecamatan,omitempty"` @@ -175,19 +191,19 @@ func (r Patient) MarshalJSON() ([]byte, error) { aux.Active = &r.Active.Bool } if r.FKSdProvinsiID.Valid { - fksp := int32(r.FKSdProvinsiID.Int32) + fksp := int(r.FKSdProvinsiID.Int64) aux.FKSdProvinsiID = &fksp } if r.FKSdKabupatenKotaID.Valid { - fksk := int32(r.FKSdKabupatenKotaID.Int32) + fksk := int(r.FKSdKabupatenKotaID.Int64) aux.FKSdKabupatenKotaID = &fksk } if r.FKSdKecamatanID.Valid { - fksc := int32(r.FKSdKecamatanID.Int32) + fksc := int(r.FKSdKecamatanID.Int64) aux.FKSdKecamatanID = &fksc } if r.FKSdKelurahanID.Valid { - fksl := int32(r.FKSdKelurahanID.Int32) + fksl := int(r.FKSdKelurahanID.Int64) aux.FKSdKelurahanID = &fksl } if r.DsSdProvinsi.Valid { @@ -271,6 +287,50 @@ func (r PatientAttachment) MarshalJSON() ([]byte, error) { return json.Marshal(aux) } +func (r PatientVisit) MarshalJSON() ([]byte, error) { + type Alias PatientVisit + aux := &struct { + *Alias + Barcode *int `json:"barcode,omitempty"` + RegistrationDate *string `json:"registration_date,omitempty"` + ServiceDate *string `json:"service_date,omitempty"` + CheckInDate *string `json:"check_in_date,omitempty"` + CheckIn *bool `json:"check_in,omitempty"` + Active *bool `json:"active,omitempty"` + FKMsPatientID *int `json:"fk_ms_patient_id,omitempty"` + }{ + Alias: (*Alias)(&r), + } + + if r.Barcode.Valid { + barcode := int(r.Barcode.Int32) + aux.Barcode = &barcode + } + if r.RegistrationDate.Valid { + regDateStr := r.RegistrationDate.Time.Format(time.RFC3339) + aux.RegistrationDate = ®DateStr + } + if r.ServiceDate.Valid { + svcDateStr := r.ServiceDate.Time.Format(time.RFC3339) + aux.ServiceDate = &svcDateStr + } + if r.CheckInDate.Valid { + checkInDateStr := r.CheckInDate.Time.Format(time.RFC3339) + aux.CheckInDate = &checkInDateStr + } + if r.CheckIn.Valid { + aux.CheckIn = &r.CheckIn.Bool + } + if r.Active.Valid { + aux.Active = &r.Active.Bool + } + if r.FKMsPatientID.Valid { + fkmp := int(r.FKMsPatientID.Int32) + aux.FKMsPatientID = &fkmp + } + return json.Marshal(aux) +} + // Helper methods untuk mendapatkan nilai yang aman func (r *Patient) GetName() string { if r.Name.Valid { @@ -303,20 +363,41 @@ type PatientCreateRequest struct { BirthDate *time.Time `json:"birth_date"` Address *string `json:"address" validate:"min=1,max=255"` Active *bool `json:"active"` - FKSdProvinsiID *int32 `json:"fk_sd_provinsi_id"` - FKSdKabupatenKotaID *int32 `json:"fk_sd_kabupaten_kota_id"` - FKSdKecamatanID *int32 `json:"fk_sd_kecamatan_id"` - FKSdKelurahanID *int32 `json:"fk_sd_kelurahan_id"` - DsSdProvinsi *string `json:"ds_sd_provinsi" validate:"min=1,max=255"` - DsSdKabupatenKota *string `json:"ds_sd_kabupaten_kota" validate:"min=1,max=255"` - DsSdKecamatan *string `json:"ds_sd_kecamatan" validate:"min=1,max=255"` - DsSdKelurahan *string `json:"ds_sd_kelurahan" validate:"min=1,max=255"` + FKSdProvinsiID *int `json:"fk_sd_provinsi_id"` + FKSdKabupatenKotaID *int `json:"fk_sd_kabupaten_kota_id"` + FKSdKecamatanID *int `json:"fk_sd_kecamatan_id"` + FKSdKelurahanID *int `json:"fk_sd_kelurahan_id"` + DsSdProvinsi *string `json:"ds_sd_provinsi"` + DsSdKabupatenKota *string `json:"ds_sd_kabupaten_kota"` + DsSdKecamatan *string `json:"ds_sd_kecamatan"` + DsSdKelurahan *string `json:"ds_sd_kelurahan"` Attachments []PatientAttachmentCreateRequest `json:"attachments,omitempty"` Payments []PatientPaymentTypeCreateRequest `json:"payment_types,omitempty"` + Visits []VisitCreateRequest `json:"visits,omitempty"` +} + +type VisitCreateRequest struct { + // ID *int `json:"id"` + Barcode *int32 `json:"barcode" validate:"omitempty,min=1"` + RegistrationDate *time.Time `json:"registration_date" validate:"omitempty"` + ServiceDate *time.Time `json:"service_date" validate:"omitempty"` + CheckInDate *time.Time `json:"check_in_date" validate:"omitempty"` + CheckIn *bool `json:"check_in" validate:"omitempty"` + Active *bool `json:"active" validate:"omitempty"` +} + +type VisitUpdateRequest struct { + ID *int `json:"id" validate:"required"` + Barcode *int32 `json:"barcode" validate:"omitempty,min=1"` + RegistrationDate *time.Time `json:"registration_date" validate:"omitempty"` + ServiceDate *time.Time `json:"service_date" validate:"omitempty"` + CheckInDate *time.Time `json:"check_in_date" validate:"omitempty"` + CheckIn *bool `json:"check_in" validate:"omitempty"` + Active *bool `json:"active" validate:"omitempty"` } type PatientPostRequest struct { - // ID int64 `json:"id" validate:"required,min=1"` + // ID int64 `json:"id" validate:"required"` MedicalRecordNumber string `json:"medical_record_number" validate:"required,min=1"` } @@ -328,24 +409,25 @@ type PatientCreateResponse struct { // Update request type PatientUpdateRequest struct { - ID *int `json:"-" validate:"required,min=1"` - Name *string `json:"name" validate:"min=1,max=100"` - MedicalRecordNumber *string `json:"medical_record_number" validate:"min=1,max=20"` - PhoneNumber *string `json:"phone_number" validate:"min=1,max=20"` - Gender *string `json:"gender" validate:"max=1"` + // ID *int `json:"-" validate:"required,min=1"` + Name *string `json:"name"` + MedicalRecordNumber *string `json:"medical_record_number" validate:"required,min=1,max=20"` + PhoneNumber *string `json:"phone_number"` + Gender *string `json:"gender"` BirthDate *time.Time `json:"birth_date"` - Address *string `json:"address" validate:"min=1,max=255"` + Address *string `json:"address"` Active *bool `json:"active"` - FKSdProvinsiID *int32 `json:"fk_sd_provinsi_id"` - FKSdKabupatenKotaID *int32 `json:"fk_sd_kabupaten_kota_id"` - FKSdKecamatanID *int32 `json:"fk_sd_kecamatan_id"` - FKSdKelurahanID *int32 `json:"fk_sd_kelurahan_id"` - DsSdProvinsi *string `json:"ds_sd_provinsi" validate:"max=255"` - DsSdKabupatenKota *string `json:"ds_sd_kabupaten_kota" validate:"max=255"` - DsSdKecamatan *string `json:"ds_sd_kecamatan" validate:"max=255"` - DsSdKelurahan *string `json:"ds_sd_kelurahan" validate:"max=255"` - Attachments []PatientAttachmentCreateRequest `json:"attachments,omitempty"` - Payments []PatientPaymentTypeCreateRequest `json:"payment_types,omitempty"` + FKSdProvinsiID *int `json:"fk_sd_provinsi_id"` + FKSdKabupatenKotaID *int `json:"fk_sd_kabupaten_kota_id"` + FKSdKecamatanID *int `json:"fk_sd_kecamatan_id"` + FKSdKelurahanID *int `json:"fk_sd_kelurahan_id"` + DsSdProvinsi *string `json:"ds_sd_provinsi"` + DsSdKabupatenKota *string `json:"ds_sd_kabupaten_kota"` + DsSdKecamatan *string `json:"ds_sd_kecamatan"` + DsSdKelurahan *string `json:"ds_sd_kelurahan"` + Attachments []PatientAttachmentUpdateRequest `json:"attachments,omitempty"` + Payments []PatientPaymentTypeUpdateRequest `json:"payment_types,omitempty"` + Visits []VisitUpdateRequest `json:"visits,omitempty"` } type PatientAttachmentCreateRequest struct { @@ -361,7 +443,7 @@ type PatientPaymentTypeCreateRequest struct { Name *string `json:"name" validate:"omitempty,min=1,max=20"` Number *string `json:"number" validate:"omitempty,min=1,max=20"` Active *bool `json:"active" validate:"omitempty"` - FkRefPaymentType *int `json:"fk_ref_payment_type" validate:"omitempty,min=1"` + FKRefPaymentType *int `json:"fk_ref_payment_type" validate:"omitempty,min=1"` } type PatientAttachmentUpdateRequest struct { diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index c30b86f..35b34f3 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -195,12 +195,11 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { patientMspatientHandler := patientMspatientHandlers.NewPatientHandler() patientMspatientGroup := v1.Group("/patient") { - // patientMspatientGroup.GET("/", patientMspatientHandler.GetMs_patient) - // patientMspatientGroup.POST("/", patientMspatientHandler.CreateMs_patient) - // patientMspatientGroup.PUT("/:medical_record_number", patientMspatientHandler.UpdateMs_patient) - // patientMspatientGroup.DELETE("/:medical_record_number", patientMspatientHandler.DeleteMs_patient) + patientMspatientGroup.PUT("/:medical_record_number", patientMspatientHandler.UpdatePatient) + patientMspatientGroup.DELETE("/:medical_record_number", patientMspatientHandler.DeletePatient) patientMspatientGroup.GET("/", patientMspatientHandler.GetPatient) - patientMspatientGroup.POST("/detail", patientMspatientHandler.GetPatientByIDPost) + patientMspatientGroup.POST("/detail", patientMspatientHandler.GetPatientByMedicalRecordNumberPost) + patientMspatientGroup.POST("/", patientMspatientHandler.CreatePatient) } // =============================================================================