From fb41d7720efa305ba47c837de2e6393f77606232 Mon Sep 17 00:00:00 2001 From: Meninjar Date: Thu, 21 Aug 2025 05:38:22 +0700 Subject: [PATCH] perbaikan tool generete --- README.md | 6 +- internal/handlers/retribusi/retribusi.go | 1734 ++++++++++------- internal/middleware/error_handler.go | 2 +- internal/models/models.go | 85 + internal/models/retribusi/retribusi.go | 416 ++-- internal/routes/v1/routes.go | 31 +- internal/utils/env.go | 42 - internal/utils/filters/dynamic_filter.go | 541 +++++ internal/utils/filters/query_parser.go | 241 +++ internal/utils/sep | 220 --- .../utils/validation/duplicate_validator.go | 141 ++ tools/generate-handler.go | 1076 ++++++---- 12 files changed, 2917 insertions(+), 1618 deletions(-) create mode 100644 internal/models/models.go delete mode 100644 internal/utils/env.go create mode 100644 internal/utils/filters/dynamic_filter.go create mode 100644 internal/utils/filters/query_parser.go delete mode 100644 internal/utils/sep create mode 100644 internal/utils/validation/duplicate_validator.go diff --git a/README.md b/README.md index 1980a725..01749e9d 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,11 @@ tools/generate.bat product get post put delete ./tools/generate.sh product get post put delete # Atau langsung dengan Go -go run tools/generate-handler.go product get post +go run tools/generate-handler.go orders get post -go run tools/generate-handler.go order get post put delete stats +go run tools/generate-handler.go orders/product get post + +go run tools/generate-handler.go orders/order get post put delete dynamic search stats go run tools/generate-bpjs-handler.go reference/peserta get ``` diff --git a/internal/handlers/retribusi/retribusi.go b/internal/handlers/retribusi/retribusi.go index e7db5ee0..ad224f55 100644 --- a/internal/handlers/retribusi/retribusi.go +++ b/internal/handlers/retribusi/retribusi.go @@ -3,7 +3,10 @@ package handlers import ( "api-service/internal/config" "api-service/internal/database" - models "api-service/internal/models/retribusi" + models "api-service/internal/models" + modelsretribusi "api-service/internal/models/retribusi" + utils "api-service/internal/utils/filters" + "api-service/internal/utils/validation" "context" "database/sql" "fmt" @@ -20,41 +23,41 @@ import ( ) var ( - db database.Service - once sync.Once - validate *validator.Validate + db database.Service + once sync.Once + validate *validator.Validate ) // Initialize the database connection and validator func init() { - once.Do(func() { - db = database.New(config.LoadConfig()) - validate = validator.New() - - // Register custom validations if needed - validate.RegisterValidation("retribusi_status", validateRetribusiStatus) - - if db == nil { - log.Fatal("Failed to initialize database connection") - } - }) + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + + // Register custom validations if needed + validate.RegisterValidation("retribusi_status", validateRetribusiStatus) + + if db == nil { + log.Fatal("Failed to initialize database connection") + } + }) } // Custom validation for retribusi status func validateRetribusiStatus(fl validator.FieldLevel) bool { - return models.IsValidStatus(fl.Field().String()) + return models.IsValidStatus(fl.Field().String()) } // RetribusiHandler handles retribusi services type RetribusiHandler struct { - db database.Service + db database.Service } // NewRetribusiHandler creates a new RetribusiHandler func NewRetribusiHandler() *RetribusiHandler { - return &RetribusiHandler{ - db: db, - } + return &RetribusiHandler{ + db: db, + } } // GetRetribusi godoc @@ -70,109 +73,109 @@ func NewRetribusiHandler() *RetribusiHandler { // @Param jenis query string false "Filter by jenis" // @Param dinas query string false "Filter by dinas" // @Param search query string false "Search in multiple fields" -// @Success 200 {object} models.RetribusiGetResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Bad request" -// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Success 200 {object} modelsretribusi.RetribusiGetResponse "Success response" +// @Failure 400 {object} modelsretribusi.ErrorResponse "Bad request" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" // @Router /api/v1/retribusis [get] func (h *RetribusiHandler) GetRetribusi(c *gin.Context) { - // Parse pagination parameters - limit, offset, err := h.parsePaginationParams(c) - if err != nil { - h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) - return - } + // Parse pagination parameters + limit, offset, err := h.parsePaginationParams(c) + if err != nil { + h.respondError(c, "Invalid pagination parameters", err, http.StatusBadRequest) + return + } - // Parse filter parameters - filter := h.parseFilterParams(c) - includeAggregation := c.Query("include_summary") == "true" + // Parse filter parameters + filter := h.parseFilterParams(c) + includeAggregation := c.Query("include_summary") == "true" - // Get database connection - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } - // Create context with timeout - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) - defer cancel() + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() - // Execute concurrent operations - var ( - retribusis []models.Retribusi - total int - aggregateData *models.AggregateData - wg sync.WaitGroup - errChan = make(chan error, 3) - mu sync.Mutex - ) + // Execute concurrent operations + var ( + retribusis []modelsretribusi.Retribusi + total int + aggregateData *models.AggregateData + wg sync.WaitGroup + errChan = make(chan error, 3) + mu sync.Mutex + ) - // Fetch total count - wg.Add(1) - go func() { - defer wg.Done() - if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { - mu.Lock() - errChan <- fmt.Errorf("failed to get total count: %w", err) - mu.Unlock() - } - }() + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + if err := h.getTotalCount(ctx, dbConn, filter, &total); err != nil { + mu.Lock() + errChan <- fmt.Errorf("failed to get total count: %w", err) + mu.Unlock() + } + }() - // Fetch main data - wg.Add(1) - go func() { - defer wg.Done() - result, err := h.fetchRetribusis(ctx, dbConn, filter, limit, offset) - mu.Lock() - if err != nil { - errChan <- fmt.Errorf("failed to fetch data: %w", err) - } else { - retribusis = result - } - mu.Unlock() - }() + // Fetch main data + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.fetchRetribusis(ctx, dbConn, filter, limit, offset) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to fetch data: %w", err) + } else { + retribusis = result + } + mu.Unlock() + }() - // Fetch aggregation data if requested - if includeAggregation { - wg.Add(1) - go func() { - defer wg.Done() - result, err := h.getAggregateData(ctx, dbConn, filter) - mu.Lock() - if err != nil { - errChan <- fmt.Errorf("failed to get aggregate data: %w", err) - } else { - aggregateData = result - } - mu.Unlock() - }() - } + // Fetch aggregation data if requested + if includeAggregation { + wg.Add(1) + go func() { + defer wg.Done() + result, err := h.getAggregateData(ctx, dbConn, filter) + mu.Lock() + if err != nil { + errChan <- fmt.Errorf("failed to get aggregate data: %w", err) + } else { + aggregateData = result + } + mu.Unlock() + }() + } - // Wait for all goroutines - wg.Wait() - close(errChan) + // Wait for all goroutines + wg.Wait() + close(errChan) - // Check for errors - for err := range errChan { - if err != nil { - h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) - return - } - } + // Check for errors + for err := range errChan { + if err != nil { + h.logAndRespondError(c, "Data processing failed", err, http.StatusInternalServerError) + return + } + } - // Build response - meta := h.calculateMeta(limit, offset, total) - response := models.RetribusiGetResponse{ - Message: "Data retribusi berhasil diambil", - Data: retribusis, - Meta: meta, - } + // Build response + meta := h.calculateMeta(limit, offset, total) + response := modelsretribusi.RetribusiGetResponse{ + Message: "Data retribusi berhasil diambil", + Data: retribusis, + Meta: meta, + } - if includeAggregation && aggregateData != nil { - response.Summary = aggregateData - } + if includeAggregation && aggregateData != nil { + response.Summary = aggregateData + } - c.JSON(http.StatusOK, response) + c.JSON(http.StatusOK, response) } // GetRetribusiByID godoc @@ -182,45 +185,312 @@ func (h *RetribusiHandler) GetRetribusi(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Retribusi ID (UUID)" -// @Success 200 {object} models.RetribusiGetByIDResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Invalid ID format" -// @Failure 404 {object} models.ErrorResponse "Retribusi not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Success 200 {object} modelsretribusi.RetribusiGetByIDResponse "Success response" +// @Failure 400 {object} modelsretribusi.ErrorResponse "Invalid ID format" +// @Failure 404 {object} modelsretribusi.ErrorResponse "Retribusi not found" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" // @Router /api/v1/retribusi/{id} [get] func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) { - id := c.Param("id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) - return - } + id := c.Param("id") - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } - retribusi, err := h.getRetribusiByID(ctx, dbConn, id) - if err != nil { - if err == sql.ErrNoRows { - h.respondError(c, "Retribusi not found", err, http.StatusNotFound) - } else { - h.logAndRespondError(c, "Failed to get retribusi", err, http.StatusInternalServerError) - } - return - } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() - response := models.RetribusiGetByIDResponse{ - Message: "Retribusi details retrieved successfully", - Data: retribusi, - } + retribusi, err := h.getRetribusiByID(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Retribusi not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to get retribusi", err, http.StatusInternalServerError) + } + return + } - c.JSON(http.StatusOK, response) + response := modelsretribusi.RetribusiGetByIDResponse{ + Message: "Retribusi details retrieved successfully", + Data: retribusi, + } + + c.JSON(http.StatusOK, response) +} + +// GetRetribusiDynamic godoc +// @Summary Get retribusi with dynamic filtering +// @Description Returns retribusis with advanced dynamic filtering like Directus +// @Tags retribusi +// @Accept json +// @Produce json +// @Param fields query string false "Fields to select (e.g., fields=*.*)" +// @Param filter[column][operator] query string false "Dynamic filters (e.g., filter[Jenis][_eq]=value)" +// @Param sort query string false "Sort fields (e.g., sort=date_created,-Jenis)" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} modelsretribusi.RetribusiGetResponse "Success response" +// @Failure 400 {object} modelsretribusi.ErrorResponse "Bad request" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" +// @Router /api/v1/retribusis/dynamic [get] +func (h *RetribusiHandler) GetRetribusiDynamic(c *gin.Context) { + // Parse query parameters + parser := utils.NewQueryParser().SetLimits(10, 100) + dynamicQuery, err := parser.ParseQuery(c.Request.URL.Query()) + if err != nil { + h.respondError(c, "Invalid query parameters", err, http.StatusBadRequest) + return + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute query with dynamic filtering + retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, dynamicQuery) + if err != nil { + h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(dynamicQuery.Limit, dynamicQuery.Offset, total) + response := modelsretribusi.RetribusiGetResponse{ + Message: "Data retribusi berhasil diambil", + Data: retribusis, + Meta: meta, + } + + c.JSON(http.StatusOK, response) +} + +// fetchRetribusisDynamic executes dynamic query +func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *sql.DB, query utils.DynamicQuery) ([]modelsretribusi.Retribusi, int, error) { + // Setup query builder + builder := utils.NewQueryBuilder("data_retribusi"). + SetColumnMapping(map[string]string{ + "jenis": "Jenis", + "pelayanan": "Pelayanan", + "dinas": "Dinas", + "kelompok_obyek": "Kelompok_obyek", + "kode_tarif": "Kode_tarif", + "tarif": "Tarif", + "satuan": "Satuan", + "tarif_overtime": "Tarif_overtime", + "satuan_overtime": "Satuan_overtime", + "rekening_pokok": "Rekening_pokok", + "rekening_denda": "Rekening_denda", + "uraian_1": "Uraian_1", + "uraian_2": "Uraian_2", + "uraian_3": "Uraian_3", + }). + SetAllowedColumns([]string{ + "id", "status", "sort", "user_created", "date_created", + "user_updated", "date_updated", "Jenis", "Pelayanan", + "Dinas", "Kelompok_obyek", "Kode_tarif", "Tarif", "Satuan", + "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", + "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3", + }) + + // Add default filter to exclude deleted records + query.Filters = append([]utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "status", + Operator: utils.OpNotEqual, + Value: "deleted", + }}, + LogicOp: "AND", + }}, query.Filters...) + + // Execute concurrent queries + var ( + retribusis []modelsretribusi.Retribusi + total int + wg sync.WaitGroup + errChan = make(chan error, 2) + mu sync.Mutex + ) + + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + countQuery := query + countQuery.Limit = 0 + countQuery.Offset = 0 + + countSQL, countArgs, err := builder.BuildCountQuery(countQuery) + if err != nil { + errChan <- fmt.Errorf("failed to build count query: %w", err) + return + } + + if err := dbConn.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil { + errChan <- fmt.Errorf("failed to get total count: %w", err) + return + } + }() + + // Fetch main data + wg.Add(1) + go func() { + defer wg.Done() + + mainSQL, mainArgs, err := builder.BuildQuery(query) + if err != nil { + errChan <- fmt.Errorf("failed to build main query: %w", err) + return + } + + rows, err := dbConn.QueryContext(ctx, mainSQL, mainArgs...) + if err != nil { + errChan <- fmt.Errorf("failed to execute main query: %w", err) + return + } + defer rows.Close() + + var results []modelsretribusi.Retribusi + for rows.Next() { + retribusi, err := h.scanRetribusi(rows) + if err != nil { + errChan <- fmt.Errorf("failed to scan retribusi: %w", err) + return + } + results = append(results, retribusi) + } + + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("rows iteration error: %w", err) + return + } + + mu.Lock() + retribusis = results + mu.Unlock() + }() + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + return nil, 0, err + } + } + + return retribusis, total, nil +} + +// SearchRetribusiAdvanced provides advanced search capabilities +func (h *RetribusiHandler) SearchRetribusiAdvanced(c *gin.Context) { + // Parse complex search parameters + searchQuery := c.Query("q") + if searchQuery == "" { + h.respondError(c, "Search query is required", fmt.Errorf("empty search query"), http.StatusBadRequest) + return + } + + // Build dynamic query for search + query := utils.DynamicQuery{ + Fields: []string{"*"}, + Filters: []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{ + { + Column: "status", + Operator: utils.OpNotEqual, + Value: "deleted", + }, + { + Column: "Jenis", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + { + Column: "Pelayanan", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + { + Column: "Dinas", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + { + Column: "Uraian_1", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + }, + LogicOp: "AND", + }}, + Sort: []utils.SortField{{ + Column: "date_created", + Order: "DESC", + }}, + Limit: 20, + Offset: 0, + } + + // Parse pagination if provided + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { + query.Limit = l + } + } + + if offset := c.Query("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil && o >= 0 { + query.Offset = o + } + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute search + retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, query) + if err != nil { + h.logAndRespondError(c, "Search failed", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(query.Limit, query.Offset, total) + response := modelsretribusi.RetribusiGetResponse{ + Message: fmt.Sprintf("Search results for '%s'", searchQuery), + Data: retribusis, + Meta: meta, + } + + c.JSON(http.StatusOK, response) } // CreateRetribusi godoc @@ -229,46 +499,52 @@ func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) { // @Tags retribusi // @Accept json // @Produce json -// @Param request body models.RetribusiCreateRequest true "Retribusi creation request" -// @Success 201 {object} models.RetribusiCreateResponse "Retribusi created successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" -// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Param request body modelsretribusi.RetribusiCreateRequest true "Retribusi creation request" +// @Success 201 {object} modelsretribusi.RetribusiCreateResponse "Retribusi created successfully" +// @Failure 400 {object} modelsretribusi.ErrorResponse "Bad request or validation error" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" // @Router /api/v1/retribusis [post] func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) { - var req models.RetribusiCreateRequest - - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, "Invalid request body", err, http.StatusBadRequest) - return - } + var req modelsretribusi.RetribusiCreateRequest - // Validate request - if err := validate.Struct(&req); err != nil { - h.respondError(c, "Validation failed", err, http.StatusBadRequest) - return - } + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } - retribusi, err := h.createRetribusi(ctx, dbConn, &req) - if err != nil { - h.logAndRespondError(c, "Failed to create retribusi", err, http.StatusInternalServerError) - return - } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() - response := models.RetribusiCreateResponse{ - Message: "Retribusi berhasil dibuat", - Data: retribusi, - } + // Validate duplicate and daily submission + if err := h.validateRetribusiSubmission(ctx, dbConn, &req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } - c.JSON(http.StatusCreated, response) + retribusi, err := h.createRetribusi(ctx, dbConn, &req) + if err != nil { + h.logAndRespondError(c, "Failed to create retribusi", err, http.StatusInternalServerError) + return + } + + response := modelsretribusi.RetribusiCreateResponse{ + Message: "Retribusi berhasil dibuat", + Data: retribusi, + } + + c.JSON(http.StatusCreated, response) } // UpdateRetribusi godoc @@ -278,61 +554,61 @@ func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Retribusi ID (UUID)" -// @Param request body models.RetribusiUpdateRequest true "Retribusi update request" -// @Success 200 {object} models.RetribusiUpdateResponse "Retribusi updated successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" -// @Failure 404 {object} models.ErrorResponse "Retribusi not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Param request body modelsretribusi.RetribusiUpdateRequest true "Retribusi update request" +// @Success 200 {object} modelsretribusi.RetribusiUpdateResponse "Retribusi updated successfully" +// @Failure 400 {object} modelsretribusi.ErrorResponse "Bad request or validation error" +// @Failure 404 {object} modelsretribusi.ErrorResponse "Retribusi not found" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" // @Router /api/v1/retribusi/{id} [put] func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) { - id := c.Param("id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) - return - } + id := c.Param("id") - var req models.RetribusiUpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.respondError(c, "Invalid request body", err, http.StatusBadRequest) - return - } + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } - // Set ID from path parameter - req.ID = id + var req modelsretribusi.RetribusiUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } - // Validate request - if err := validate.Struct(&req); err != nil { - h.respondError(c, "Validation failed", err, http.StatusBadRequest) - return - } + // Set ID from path parameter + req.ID = id - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } + // Validate request + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } - retribusi, err := h.updateRetribusi(ctx, dbConn, &req) - if err != nil { - if err == sql.ErrNoRows { - h.respondError(c, "Retribusi not found", err, http.StatusNotFound) - } else { - h.logAndRespondError(c, "Failed to update retribusi", err, http.StatusInternalServerError) - } - return - } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() - response := models.RetribusiUpdateResponse{ - Message: "Retribusi berhasil diperbarui", - Data: retribusi, - } + retribusi, err := h.updateRetribusi(ctx, dbConn, &req) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Retribusi not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to update retribusi", err, http.StatusInternalServerError) + } + return + } - c.JSON(http.StatusOK, response) + response := modelsretribusi.RetribusiUpdateResponse{ + Message: "Retribusi berhasil diperbarui", + Data: retribusi, + } + + c.JSON(http.StatusOK, response) } // DeleteRetribusi godoc @@ -342,45 +618,45 @@ func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Retribusi ID (UUID)" -// @Success 200 {object} models.RetribusiDeleteResponse "Retribusi deleted successfully" -// @Failure 400 {object} models.ErrorResponse "Invalid ID format" -// @Failure 404 {object} models.ErrorResponse "Retribusi not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Success 200 {object} modelsretribusi.RetribusiDeleteResponse "Retribusi deleted successfully" +// @Failure 400 {object} modelsretribusi.ErrorResponse "Invalid ID format" +// @Failure 404 {object} modelsretribusi.ErrorResponse "Retribusi not found" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" // @Router /api/v1/retribusi/{id} [delete] func (h *RetribusiHandler) DeleteRetribusi(c *gin.Context) { - id := c.Param("id") - - // Validate UUID format - if _, err := uuid.Parse(id); err != nil { - h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) - return - } + id := c.Param("id") - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } + // Validate UUID format + if _, err := uuid.Parse(id); err != nil { + h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) + return + } - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } - err = h.deleteRetribusi(ctx, dbConn, id) - if err != nil { - if err == sql.ErrNoRows { - h.respondError(c, "Retribusi not found", err, http.StatusNotFound) - } else { - h.logAndRespondError(c, "Failed to delete retribusi", err, http.StatusInternalServerError) - } - return - } + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() - response := models.RetribusiDeleteResponse{ - Message: "Retribusi berhasil dihapus", - ID: id, - } + err = h.deleteRetribusi(ctx, dbConn, id) + if err != nil { + if err == sql.ErrNoRows { + h.respondError(c, "Retribusi not found", err, http.StatusNotFound) + } else { + h.logAndRespondError(c, "Failed to delete retribusi", err, http.StatusInternalServerError) + } + return + } - c.JSON(http.StatusOK, response) + response := modelsretribusi.RetribusiDeleteResponse{ + Message: "Retribusi berhasil dihapus", + ID: id, + } + + c.JSON(http.StatusOK, response) } // GetRetribusiStats godoc @@ -390,35 +666,35 @@ func (h *RetribusiHandler) DeleteRetribusi(c *gin.Context) { // @Accept json // @Produce json // @Param status query string false "Filter statistics by status" -// @Success 200 {object} models.AggregateData "Statistics data" -// @Failure 500 {object} models.ErrorResponse "Internal server error" +// @Success 200 {object} modelsretribusi.AggregateData "Statistics data" +// @Failure 500 {object} modelsretribusi.ErrorResponse "Internal server error" // @Router /api/v1/retribusis/stats [get] func (h *RetribusiHandler) GetRetribusiStats(c *gin.Context) { - dbConn, err := h.db.GetDB("postgres_satudata") - if err != nil { - h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) - return - } + dbConn, err := h.db.GetDB("postgres_satudata") + 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() + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() - filter := h.parseFilterParams(c) - aggregateData, err := h.getAggregateData(ctx, dbConn, filter) - if err != nil { - h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) - return - } + filter := h.parseFilterParams(c) + aggregateData, err := h.getAggregateData(ctx, dbConn, filter) + if err != nil { + h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError) + return + } - c.JSON(http.StatusOK, gin.H{ - "message": "Statistik retribusi berhasil diambil", - "data": aggregateData, - }) + c.JSON(http.StatusOK, gin.H{ + "message": "Statistik retribusi berhasil diambil", + "data": aggregateData, + }) } // Get retribusi by ID -func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB, id string) (*models.Retribusi, error) { - query := ` +func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB, id string) (*modelsretribusi.Retribusi, error) { + query := ` SELECT id, status, sort, user_created, date_created, user_updated, date_updated, "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", @@ -427,31 +703,31 @@ func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB, FROM data_retribusi WHERE id = $1 AND status != 'deleted'` - row := dbConn.QueryRowContext(ctx, query, id) - - var retribusi models.Retribusi - err := row.Scan( - &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, - &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, - &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, - &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, - &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, - ) - - if err != nil { - return nil, err - } - - return &retribusi, nil + row := dbConn.QueryRowContext(ctx, query, id) + + var retribusi modelsretribusi.Retribusi + err := row.Scan( + &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, + &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, + &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, + &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, + &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, + ) + + if err != nil { + return nil, err + } + + return &retribusi, nil } // Create retribusi -func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, req *models.RetribusiCreateRequest) (*models.Retribusi, error) { - id := uuid.New().String() - now := time.Now() - - query := ` +func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, req *modelsretribusi.RetribusiCreateRequest) (*modelsretribusi.Retribusi, error) { + id := uuid.New().String() + now := time.Now() + + query := ` INSERT INTO data_retribusi ( id, status, date_created, date_updated, "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", @@ -464,35 +740,35 @@ func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3"` - row := dbConn.QueryRowContext(ctx, query, - id, req.Status, now, now, - req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, - req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, - req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, - ) + row := dbConn.QueryRowContext(ctx, query, + id, req.Status, now, now, + req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, + req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, + req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, + ) - var retribusi models.Retribusi - err := row.Scan( - &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, - &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, - &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, - &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, - &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, - ) + var retribusi modelsretribusi.Retribusi + err := row.Scan( + &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, + &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, + &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, + &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, + &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, + ) - if err != nil { - return nil, fmt.Errorf("failed to create retribusi: %w", err) - } + if err != nil { + return nil, fmt.Errorf("failed to create retribusi: %w", err) + } - return &retribusi, nil + return &retribusi, nil } // Update retribusi -func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, req *models.RetribusiUpdateRequest) (*models.Retribusi, error) { - now := time.Now() - - query := ` +func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, req *modelsretribusi.RetribusiUpdateRequest) (*modelsretribusi.Retribusi, error) { + now := time.Now() + + query := ` UPDATE data_retribusi SET status = $2, date_updated = $3, "Jenis" = $4, "Pelayanan" = $5, "Dinas" = $6, "Kelompok_obyek" = $7, "Kode_tarif" = $8, @@ -505,139 +781,139 @@ func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, "Tarif", "Satuan", "Tarif_overtime", "Satuan_overtime", "Rekening_pokok", "Rekening_denda", "Uraian_1", "Uraian_2", "Uraian_3"` - row := dbConn.QueryRowContext(ctx, query, - req.ID, req.Status, now, - req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, - req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, - req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, - ) + row := dbConn.QueryRowContext(ctx, query, + req.ID, req.Status, now, + req.Jenis, req.Pelayanan, req.Dinas, req.KelompokObyek, req.KodeTarif, + req.Tarif, req.Satuan, req.TarifOvertime, req.SatuanOvertime, + req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3, + ) - var retribusi models.Retribusi - err := row.Scan( - &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, - &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, - &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, - &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, - &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, - ) + var retribusi modelsretribusi.Retribusi + err := row.Scan( + &retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated, + &retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated, + &retribusi.Jenis, &retribusi.Pelayanan, &retribusi.Dinas, &retribusi.KelompokObyek, + &retribusi.KodeTarif, &retribusi.Tarif, &retribusi.Satuan, &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, &retribusi.RekeningPokok, &retribusi.RekeningDenda, + &retribusi.Uraian1, &retribusi.Uraian2, &retribusi.Uraian3, + ) - if err != nil { - return nil, fmt.Errorf("failed to update retribusi: %w", err) - } + if err != nil { + return nil, fmt.Errorf("failed to update retribusi: %w", err) + } - return &retribusi, nil + return &retribusi, nil } // Soft delete retribusi func (h *RetribusiHandler) deleteRetribusi(ctx context.Context, dbConn *sql.DB, id string) error { - now := time.Now() - - query := `UPDATE data_retribusi SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'` - - result, err := dbConn.ExecContext(ctx, query, id, now) - if err != nil { - return fmt.Errorf("failed to delete retribusi: %w", err) - } + now := time.Now() - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get affected rows: %w", err) - } + query := `UPDATE data_retribusi SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'` - if rowsAffected == 0 { - return sql.ErrNoRows - } + result, err := dbConn.ExecContext(ctx, query, id, now) + if err != nil { + return fmt.Errorf("failed to delete retribusi: %w", err) + } - return nil + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil } // Enhanced error handling func (h *RetribusiHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { - log.Printf("[ERROR] %s: %v", message, err) - h.respondError(c, message, err, statusCode) + log.Printf("[ERROR] %s: %v", message, err) + h.respondError(c, message, err, statusCode) } func (h *RetribusiHandler) respondError(c *gin.Context, message string, err error, statusCode int) { - errorMessage := message - if gin.Mode() == gin.ReleaseMode { - errorMessage = "Internal server error" - } + 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(), - }) + c.JSON(statusCode, models.ErrorResponse{ + Error: errorMessage, + Code: statusCode, + Message: err.Error(), + Timestamp: time.Now(), + }) } // Parse pagination parameters dengan validation yang lebih ketat func (h *RetribusiHandler) parsePaginationParams(c *gin.Context) (int, int, error) { - limit := 10 // Default limit - offset := 0 // Default offset + limit := 10 // Default limit + offset := 0 // Default offset - if limitStr := c.Query("limit"); limitStr != "" { - parsedLimit, err := strconv.Atoi(limitStr) - if err != nil { - return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) - } - if parsedLimit <= 0 { - return 0, 0, fmt.Errorf("limit must be greater than 0") - } - if parsedLimit > 100 { - return 0, 0, fmt.Errorf("limit cannot exceed 100") - } - limit = parsedLimit - } + if limitStr := c.Query("limit"); limitStr != "" { + parsedLimit, err := strconv.Atoi(limitStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) + } + if parsedLimit <= 0 { + return 0, 0, fmt.Errorf("limit must be greater than 0") + } + if parsedLimit > 100 { + return 0, 0, fmt.Errorf("limit cannot exceed 100") + } + limit = parsedLimit + } - if offsetStr := c.Query("offset"); offsetStr != "" { - parsedOffset, err := strconv.Atoi(offsetStr) - if err != nil { - return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) - } - if parsedOffset < 0 { - return 0, 0, fmt.Errorf("offset cannot be negative") - } - offset = parsedOffset - } + if offsetStr := c.Query("offset"); offsetStr != "" { + parsedOffset, err := strconv.Atoi(offsetStr) + if err != nil { + return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) + } + if parsedOffset < 0 { + return 0, 0, fmt.Errorf("offset cannot be negative") + } + offset = parsedOffset + } - log.Printf("Pagination - Limit: %d, Offset: %d", limit, offset) - return limit, offset, nil + log.Printf("Pagination - Limit: %d, Offset: %d", limit, offset) + return limit, offset, nil } // Build WHERE clause dengan filter parameters -func (h *RetribusiHandler) buildWhereClause(filter models.RetribusiFilter) (string, []interface{}) { - conditions := []string{"status != 'deleted'"} - args := []interface{}{} - paramCount := 1 +func (h *RetribusiHandler) buildWhereClause(filter modelsretribusi.RetribusiFilter) (string, []interface{}) { + conditions := []string{"status != 'deleted'"} + args := []interface{}{} + paramCount := 1 - if filter.Status != nil { - conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) - args = append(args, *filter.Status) - paramCount++ - } + if filter.Status != nil { + conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) + args = append(args, *filter.Status) + paramCount++ + } - if filter.Jenis != nil { - conditions = append(conditions, fmt.Sprintf(`"Jenis" ILIKE $%d`, paramCount)) - args = append(args, "%"+*filter.Jenis+"%") - paramCount++ - } + if filter.Jenis != nil { + conditions = append(conditions, fmt.Sprintf(`"Jenis" ILIKE $%d`, paramCount)) + args = append(args, "%"+*filter.Jenis+"%") + paramCount++ + } - if filter.Dinas != nil { - conditions = append(conditions, fmt.Sprintf(`"Dinas" ILIKE $%d`, paramCount)) - args = append(args, "%"+*filter.Dinas+"%") - paramCount++ - } + if filter.Dinas != nil { + conditions = append(conditions, fmt.Sprintf(`"Dinas" ILIKE $%d`, paramCount)) + args = append(args, "%"+*filter.Dinas+"%") + paramCount++ + } - if filter.KelompokObyek != nil { - conditions = append(conditions, fmt.Sprintf(`"Kelompok_obyek" ILIKE $%d`, paramCount)) - args = append(args, "%"+*filter.KelompokObyek+"%") - paramCount++ - } + if filter.KelompokObyek != nil { + conditions = append(conditions, fmt.Sprintf(`"Kelompok_obyek" ILIKE $%d`, paramCount)) + args = append(args, "%"+*filter.KelompokObyek+"%") + paramCount++ + } - if filter.Search != nil { - searchCondition := fmt.Sprintf(`( + if filter.Search != nil { + searchCondition := fmt.Sprintf(`( "Jenis" ILIKE $%d OR "Pelayanan" ILIKE $%d OR "Dinas" ILIKE $%d OR @@ -645,304 +921,307 @@ func (h *RetribusiHandler) buildWhereClause(filter models.RetribusiFilter) (stri "Uraian_1" ILIKE $%d OR "Uraian_2" ILIKE $%d OR "Uraian_3" ILIKE $%d - )`, paramCount, paramCount, paramCount, paramCount, paramCount, paramCount,paramCount) - conditions = append(conditions, searchCondition) - searchTerm := "%" + *filter.Search + "%" - args = append(args, searchTerm) - paramCount++ - } + )`, paramCount, paramCount, paramCount, paramCount, paramCount, paramCount, paramCount) + conditions = append(conditions, searchCondition) + searchTerm := "%" + *filter.Search + "%" + args = append(args, searchTerm) + paramCount++ + } - if filter.DateFrom != nil { - conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) - args = append(args, *filter.DateFrom) - paramCount++ - } + if filter.DateFrom != nil { + conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) + args = append(args, *filter.DateFrom) + paramCount++ + } - if filter.DateTo != nil { - conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) - args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) // End of day - paramCount++ - } + if filter.DateTo != nil { + conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) + args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) // End of day + paramCount++ + } - return strings.Join(conditions, " AND "), args + return strings.Join(conditions, " AND "), args } // Optimized scanning function yang menggunakan sql.Null* types langsung -func (h *RetribusiHandler) scanRetribusi(rows *sql.Rows) (models.Retribusi, error) { - var retribusi models.Retribusi +func (h *RetribusiHandler) scanRetribusi(rows *sql.Rows) (modelsretribusi.Retribusi, error) { + var retribusi modelsretribusi.Retribusi - return retribusi, rows.Scan( - &retribusi.ID, - &retribusi.Status, - &retribusi.Sort, - &retribusi.UserCreated, - &retribusi.DateCreated, - &retribusi.UserUpdated, - &retribusi.DateUpdated, - &retribusi.Jenis, - &retribusi.Pelayanan, - &retribusi.Dinas, - &retribusi.KelompokObyek, - &retribusi.KodeTarif, - &retribusi.Tarif, - &retribusi.Satuan, - &retribusi.TarifOvertime, - &retribusi.SatuanOvertime, - &retribusi.RekeningPokok, - &retribusi.RekeningDenda, - &retribusi.Uraian1, - &retribusi.Uraian2, - &retribusi.Uraian3, - ) + return retribusi, rows.Scan( + &retribusi.ID, + &retribusi.Status, + &retribusi.Sort, + &retribusi.UserCreated, + &retribusi.DateCreated, + &retribusi.UserUpdated, + &retribusi.DateUpdated, + &retribusi.Jenis, + &retribusi.Pelayanan, + &retribusi.Dinas, + &retribusi.KelompokObyek, + &retribusi.KodeTarif, + &retribusi.Tarif, + &retribusi.Satuan, + &retribusi.TarifOvertime, + &retribusi.SatuanOvertime, + &retribusi.RekeningPokok, + &retribusi.RekeningDenda, + &retribusi.Uraian1, + &retribusi.Uraian2, + &retribusi.Uraian3, + ) } // Parse filter parameters dari query string -func (h *RetribusiHandler) parseFilterParams(c *gin.Context) models.RetribusiFilter { - filter := models.RetribusiFilter{} +func (h *RetribusiHandler) parseFilterParams(c *gin.Context) modelsretribusi.RetribusiFilter { + filter := modelsretribusi.RetribusiFilter{} - if status := c.Query("status"); status != "" { - if models.IsValidStatus(status) { - filter.Status = &status - } - } + if status := c.Query("status"); status != "" { + if models.IsValidStatus(status) { + filter.Status = &status + } + } - if jenis := c.Query("jenis"); jenis != "" { - filter.Jenis = &jenis - } + if jenis := c.Query("jenis"); jenis != "" { + filter.Jenis = &jenis + } - if dinas := c.Query("dinas"); dinas != "" { - filter.Dinas = &dinas - } + if dinas := c.Query("dinas"); dinas != "" { + filter.Dinas = &dinas + } - if kelompokObyek := c.Query("kelompok_obyek"); kelompokObyek != "" { - filter.KelompokObyek = &kelompokObyek - } + if kelompokObyek := c.Query("kelompok_obyek"); kelompokObyek != "" { + filter.KelompokObyek = &kelompokObyek + } - if search := c.Query("search"); search != "" { - filter.Search = &search - } + if search := c.Query("search"); search != "" { + filter.Search = &search + } - // Parse date filters - if dateFromStr := c.Query("date_from"); dateFromStr != "" { - if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { - filter.DateFrom = &dateFrom - } - } + // Parse date filters + if dateFromStr := c.Query("date_from"); dateFromStr != "" { + if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { + filter.DateFrom = &dateFrom + } + } - if dateToStr := c.Query("date_to"); dateToStr != "" { - if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { - filter.DateTo = &dateTo - } - } + if dateToStr := c.Query("date_to"); dateToStr != "" { + if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { + filter.DateTo = &dateTo + } + } - return filter + return filter } + // Get comprehensive aggregate data dengan filter support -func (h *RetribusiHandler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter models.RetribusiFilter) (*models.AggregateData, error) { - aggregate := &models.AggregateData{ - ByStatus: make(map[string]int), - ByDinas: make(map[string]int), - ByJenis: make(map[string]int), - } +func (h *RetribusiHandler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter modelsretribusi.RetribusiFilter) (*models.AggregateData, error) { + aggregate := &models.AggregateData{ + ByStatus: make(map[string]int), + ByDinas: make(map[string]int), + ByJenis: make(map[string]int), + } - // Build where clause untuk filter - whereClause, args := h.buildWhereClause(filter) + // Build where clause untuk filter + whereClause, args := h.buildWhereClause(filter) - // Use concurrent execution untuk performance - var wg sync.WaitGroup - var mu sync.Mutex - errChan := make(chan error, 4) + // Use concurrent execution untuk performance + var wg sync.WaitGroup + var mu sync.Mutex + errChan := make(chan error, 4) - // 1. Count by status - wg.Add(1) - go func() { - defer wg.Done() - statusQuery := fmt.Sprintf(` + // 1. Count by status + wg.Add(1) + go func() { + defer wg.Done() + statusQuery := fmt.Sprintf(` SELECT status, COUNT(*) FROM data_retribusi WHERE %s GROUP BY status ORDER BY status`, whereClause) - - rows, err := dbConn.QueryContext(ctx, statusQuery, args...) - if err != nil { - errChan <- fmt.Errorf("status query failed: %w", err) - return - } - defer rows.Close() - mu.Lock() - for rows.Next() { - var status string - var count int - if err := rows.Scan(&status, &count); err != nil { - mu.Unlock() - errChan <- fmt.Errorf("status scan failed: %w", err) - return - } - aggregate.ByStatus[status] = count - switch status { - case "active": - aggregate.TotalActive = count - case "draft": - aggregate.TotalDraft = count - case "inactive": - aggregate.TotalInactive = count - } - } - mu.Unlock() + rows, err := dbConn.QueryContext(ctx, statusQuery, args...) + if err != nil { + errChan <- fmt.Errorf("status query failed: %w", err) + return + } + defer rows.Close() - if err := rows.Err(); err != nil { - errChan <- fmt.Errorf("status iteration error: %w", err) - } - }() + mu.Lock() + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("status scan failed: %w", err) + return + } + aggregate.ByStatus[status] = count + switch status { + case "active": + aggregate.TotalActive = count + case "draft": + aggregate.TotalDraft = count + case "inactive": + aggregate.TotalInactive = count + } + } + mu.Unlock() - // 2. Count by Dinas - wg.Add(1) - go func() { - defer wg.Done() - dinasQuery := fmt.Sprintf(` + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("status iteration error: %w", err) + } + }() + + // 2. Count by Dinas + wg.Add(1) + go func() { + defer wg.Done() + dinasQuery := fmt.Sprintf(` SELECT COALESCE("Dinas", 'Unknown') as dinas, COUNT(*) FROM data_retribusi WHERE %s AND "Dinas" IS NOT NULL AND TRIM("Dinas") != '' GROUP BY "Dinas" ORDER BY COUNT(*) DESC LIMIT 10`, whereClause) - - rows, err := dbConn.QueryContext(ctx, dinasQuery, args...) - if err != nil { - errChan <- fmt.Errorf("dinas query failed: %w", err) - return - } - defer rows.Close() - mu.Lock() - for rows.Next() { - var dinas string - var count int - if err := rows.Scan(&dinas, &count); err != nil { - mu.Unlock() - errChan <- fmt.Errorf("dinas scan failed: %w", err) - return - } - aggregate.ByDinas[dinas] = count - } - mu.Unlock() + rows, err := dbConn.QueryContext(ctx, dinasQuery, args...) + if err != nil { + errChan <- fmt.Errorf("dinas query failed: %w", err) + return + } + defer rows.Close() - if err := rows.Err(); err != nil { - errChan <- fmt.Errorf("dinas iteration error: %w", err) - } - }() + mu.Lock() + for rows.Next() { + var dinas string + var count int + if err := rows.Scan(&dinas, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("dinas scan failed: %w", err) + return + } + aggregate.ByDinas[dinas] = count + } + mu.Unlock() - // 3. Count by Jenis - wg.Add(1) - go func() { - defer wg.Done() - jenisQuery := fmt.Sprintf(` + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("dinas iteration error: %w", err) + } + }() + + // 3. Count by Jenis + wg.Add(1) + go func() { + defer wg.Done() + jenisQuery := fmt.Sprintf(` SELECT COALESCE("Jenis", 'Unknown') as jenis, COUNT(*) FROM data_retribusi WHERE %s AND "Jenis" IS NOT NULL AND TRIM("Jenis") != '' GROUP BY "Jenis" ORDER BY COUNT(*) DESC LIMIT 10`, whereClause) - - rows, err := dbConn.QueryContext(ctx, jenisQuery, args...) - if err != nil { - errChan <- fmt.Errorf("jenis query failed: %w", err) - return - } - defer rows.Close() - mu.Lock() - for rows.Next() { - var jenis string - var count int - if err := rows.Scan(&jenis, &count); err != nil { - mu.Unlock() - errChan <- fmt.Errorf("jenis scan failed: %w", err) - return - } - aggregate.ByJenis[jenis] = count - } - mu.Unlock() + rows, err := dbConn.QueryContext(ctx, jenisQuery, args...) + if err != nil { + errChan <- fmt.Errorf("jenis query failed: %w", err) + return + } + defer rows.Close() - if err := rows.Err(); err != nil { - errChan <- fmt.Errorf("jenis iteration error: %w", err) - } - }() + mu.Lock() + for rows.Next() { + var jenis string + var count int + if err := rows.Scan(&jenis, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("jenis scan failed: %w", err) + return + } + aggregate.ByJenis[jenis] = count + } + mu.Unlock() - // 4. Get last updated time dan today statistics - wg.Add(1) - go func() { - defer wg.Done() - - // Last updated - lastUpdatedQuery := fmt.Sprintf(` + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("jenis iteration error: %w", err) + } + }() + + // 4. Get last updated time dan today statistics + wg.Add(1) + go func() { + defer wg.Done() + + // Last updated + lastUpdatedQuery := fmt.Sprintf(` SELECT MAX(date_updated) FROM data_retribusi WHERE %s AND date_updated IS NOT NULL`, whereClause) - - var lastUpdated sql.NullTime - if err := dbConn.QueryRowContext(ctx, lastUpdatedQuery, args...).Scan(&lastUpdated); err != nil { - errChan <- fmt.Errorf("last updated query failed: %w", err) - return - } - // Today statistics - today := time.Now().Format("2006-01-02") - todayStatsQuery := fmt.Sprintf(` + var lastUpdated sql.NullTime + if err := dbConn.QueryRowContext(ctx, lastUpdatedQuery, args...).Scan(&lastUpdated); err != nil { + errChan <- fmt.Errorf("last updated query failed: %w", err) + return + } + + // Today statistics + today := time.Now().Format("2006-01-02") + todayStatsQuery := fmt.Sprintf(` SELECT SUM(CASE WHEN DATE(date_created) = $%d THEN 1 ELSE 0 END) as created_today, SUM(CASE WHEN DATE(date_updated) = $%d AND DATE(date_created) != $%d THEN 1 ELSE 0 END) as updated_today FROM data_retribusi WHERE %s`, len(args)+1, len(args)+1, len(args)+1, whereClause) - - todayArgs := append(args, today) - var createdToday, updatedToday int - if err := dbConn.QueryRowContext(ctx, todayStatsQuery, todayArgs...).Scan(&createdToday, &updatedToday); err != nil { - errChan <- fmt.Errorf("today stats query failed: %w", err) - return - } - mu.Lock() - if lastUpdated.Valid { - aggregate.LastUpdated = &lastUpdated.Time - } - aggregate.CreatedToday = createdToday - aggregate.UpdatedToday = updatedToday - mu.Unlock() - }() + todayArgs := append(args, today) + var createdToday, updatedToday int + if err := dbConn.QueryRowContext(ctx, todayStatsQuery, todayArgs...).Scan(&createdToday, &updatedToday); err != nil { + errChan <- fmt.Errorf("today stats query failed: %w", err) + return + } - // Wait for all goroutines - wg.Wait() - close(errChan) + mu.Lock() + if lastUpdated.Valid { + aggregate.LastUpdated = &lastUpdated.Time + } + aggregate.CreatedToday = createdToday + aggregate.UpdatedToday = updatedToday + mu.Unlock() + }() - // Check for errors - for err := range errChan { - if err != nil { - return nil, err - } - } + // Wait for all goroutines + wg.Wait() + close(errChan) - return aggregate, nil + // Check for errors + for err := range errChan { + if err != nil { + return nil, err + } + } + + return aggregate, nil } + // Get total count dengan filter support -func (h *RetribusiHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter models.RetribusiFilter, total *int) error { - whereClause, args := h.buildWhereClause(filter) - countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM data_retribusi WHERE %s`, whereClause) - - if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { - return fmt.Errorf("total count query failed: %w", err) - } - - return nil +func (h *RetribusiHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter modelsretribusi.RetribusiFilter, total *int) error { + whereClause, args := h.buildWhereClause(filter) + countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM data_retribusi WHERE %s`, whereClause) + + if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { + return fmt.Errorf("total count query failed: %w", err) + } + + return nil } + // Enhanced fetchRetribusis dengan filter support -func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB, filter models.RetribusiFilter, limit, offset int) ([]models.Retribusi, error) { - whereClause, args := h.buildWhereClause(filter) - - // Build the main query with pagination - query := fmt.Sprintf(` +func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB, filter modelsretribusi.RetribusiFilter, limit, offset int) ([]modelsretribusi.Retribusi, error) { + whereClause, args := h.buildWhereClause(filter) + + // Build the main query with pagination + query := fmt.Sprintf(` SELECT id, status, sort, user_created, date_created, user_updated, date_updated, "Jenis", "Pelayanan", "Dinas", "Kelompok_obyek", "Kode_tarif", @@ -951,65 +1230,128 @@ func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB, FROM data_retribusi WHERE %s ORDER BY date_created DESC NULLS LAST - LIMIT $%d OFFSET $%d`, - whereClause, len(args)+1, len(args)+2) + LIMIT $%d OFFSET $%d`, + whereClause, len(args)+1, len(args)+2) - // Add pagination parameters - args = append(args, limit, offset) + // Add pagination parameters + args = append(args, limit, offset) - rows, err := dbConn.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("fetch retribusis query failed: %w", err) - } - defer rows.Close() + rows, err := dbConn.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("fetch retribusis query failed: %w", err) + } + defer rows.Close() - // Pre-allocate slice dengan kapasitas yang tepat - retribusis := make([]models.Retribusi, 0, limit) + // Pre-allocate slice dengan kapasitas yang tepat + retribusis := make([]modelsretribusi.Retribusi, 0, limit) - for rows.Next() { - retribusi, err := h.scanRetribusi(rows) - if err != nil { - return nil, fmt.Errorf("scan retribusi failed: %w", err) - } - retribusis = append(retribusis, retribusi) - } + for rows.Next() { + retribusi, err := h.scanRetribusi(rows) + if err != nil { + return nil, fmt.Errorf("scan retribusi failed: %w", err) + } + retribusis = append(retribusis, retribusi) + } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("rows iteration error: %w", err) - } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } - log.Printf("Successfully fetched %d retribusis with filters applied", len(retribusis)) - return retribusis, nil + log.Printf("Successfully fetched %d retribusis with filters applied", len(retribusis)) + return retribusis, nil } + // Calculate pagination metadata func (h *RetribusiHandler) calculateMeta(limit, offset, total int) models.MetaResponse { - totalPages := 0 - currentPage := 1 - - if limit > 0 { - totalPages = (total + limit - 1) / limit // Ceiling division - currentPage = (offset / limit) + 1 - } + totalPages := 0 + currentPage := 1 - return models.MetaResponse{ - Limit: limit, - Offset: offset, - Total: total, - TotalPages: totalPages, - CurrentPage: currentPage, - HasNext: offset+limit < total, - HasPrev: offset > 0, - } + if limit > 0 { + totalPages = (total + limit - 1) / limit // Ceiling division + currentPage = (offset / limit) + 1 + } + + return models.MetaResponse{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + CurrentPage: currentPage, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } } + +// validateRetribusiSubmission performs validation for duplicate entries and daily submission limits +func (h *RetribusiHandler) validateRetribusiSubmission(ctx context.Context, dbConn *sql.DB, req *modelsretribusi.RetribusiCreateRequest) error { + // Import the validation utility + validator := validation.NewDuplicateValidator(dbConn) + + // Use default retribusi configuration + config := validation.DefaultRetribusiConfig() + + // Validate duplicate entries with active status for today + err := validator.ValidateDuplicate(ctx, config, "dummy_id") + if err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Validate once per day submission + err = validator.ValidateOncePerDay(ctx, "data_retribusi", "id", "date_created", "daily_limit") + if err != nil { + return fmt.Errorf("daily submission limit exceeded: %w", err) + } + + return nil +} + +// Example usage of the validation utility with custom configuration +func (h *RetribusiHandler) validateWithCustomConfig(ctx context.Context, dbConn *sql.DB, req *modelsretribusi.RetribusiCreateRequest) error { + // Create validator instance + validator := validation.NewDuplicateValidator(dbConn) + + // Use custom configuration + config := validation.ValidationConfig{ + TableName: "data_retribusi", + IDColumn: "id", + StatusColumn: "status", + DateColumn: "date_created", + ActiveStatuses: []string{"active", "draft"}, + AdditionalFields: map[string]interface{}{ + "jenis": req.Jenis, + "dinas": req.Dinas, + }, + } + + // Validate with custom fields + fields := map[string]interface{}{ + "jenis": *req.Jenis, + "dinas": *req.Dinas, + } + + err := validator.ValidateDuplicateWithCustomFields(ctx, config, fields) + if err != nil { + return fmt.Errorf("custom validation failed: %w", err) + } + + return nil +} + +// GetLastSubmissionTime example +func (h *RetribusiHandler) getLastSubmissionTimeExample(ctx context.Context, dbConn *sql.DB, identifier string) (*time.Time, error) { + validator := validation.NewDuplicateValidator(dbConn) + return validator.GetLastSubmissionTime(ctx, "data_retribusi", "id", "date_created", identifier) +} + // models/retribusi.go - pastikan struct ini memiliki semua field type AggregateData struct { - TotalActive int `json:"total_active"` - TotalDraft int `json:"total_draft"` - TotalInactive int `json:"total_inactive"` - ByStatus map[string]int `json:"by_status"` - ByDinas map[string]int `json:"by_dinas,omitempty"` - ByJenis map[string]int `json:"by_jenis,omitempty"` - LastUpdated *time.Time `json:"last_updated,omitempty"` - CreatedToday int `json:"created_today"` - UpdatedToday int `json:"updated_today"` + TotalActive int `json:"total_active"` + TotalDraft int `json:"total_draft"` + TotalInactive int `json:"total_inactive"` + ByStatus map[string]int `json:"by_status"` + ByDinas map[string]int `json:"by_dinas,omitempty"` + ByJenis map[string]int `json:"by_jenis,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + CreatedToday int `json:"created_today"` + UpdatedToday int `json:"updated_today"` } diff --git a/internal/middleware/error_handler.go b/internal/middleware/error_handler.go index fd757145..7f6ab823 100644 --- a/internal/middleware/error_handler.go +++ b/internal/middleware/error_handler.go @@ -1,7 +1,7 @@ package middleware import ( - models "api-service/internal/models/retribusi" + models "api-service/internal/models" "net/http" "github.com/gin-gonic/gin" diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 00000000..297c3e67 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,85 @@ +package models + +import ( + "database/sql" + "database/sql/driver" + "time" +) + +// NullableInt32 is a custom type to replace sql.NullInt32 for swagger compatibility +type NullableInt32 struct { + Int32 int32 `json:"int32,omitempty"` + Valid bool `json:"valid"` +} + +// Scan implements the sql.Scanner interface for NullableInt32 +func (n *NullableInt32) Scan(value interface{}) error { + var ni sql.NullInt32 + if err := ni.Scan(value); err != nil { + return err + } + n.Int32 = ni.Int32 + n.Valid = ni.Valid + return nil +} + +// Value implements the driver.Valuer interface for NullableInt32 +func (n NullableInt32) Value() (driver.Value, error) { + if !n.Valid { + return nil, nil + } + return n.Int32, nil +} + +// Metadata untuk pagination - dioptimalkan +type MetaResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + CurrentPage int `json:"current_page"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// Aggregate data untuk summary +type AggregateData struct { + TotalActive int `json:"total_active"` + TotalDraft int `json:"total_draft"` + TotalInactive int `json:"total_inactive"` + ByStatus map[string]int `json:"by_status"` + ByDinas map[string]int `json:"by_dinas,omitempty"` + ByJenis map[string]int `json:"by_jenis,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + CreatedToday int `json:"created_today"` + UpdatedToday int `json:"updated_today"` +} + +// Error response yang konsisten +type ErrorResponse struct { + Error string `json:"error"` + Code int `json:"code"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// Validation constants +const ( + StatusDraft = "draft" + StatusActive = "active" + StatusInactive = "inactive" + StatusDeleted = "deleted" +) + +// ValidStatuses untuk validasi +var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive} + +// IsValidStatus helper function +func IsValidStatus(status string) bool { + for _, validStatus := range ValidStatuses { + if status == validStatus { + return true + } + } + return false +} diff --git a/internal/models/retribusi/retribusi.go b/internal/models/retribusi/retribusi.go index b23e13c4..ca772d75 100644 --- a/internal/models/retribusi/retribusi.go +++ b/internal/models/retribusi/retribusi.go @@ -1,307 +1,229 @@ package models import ( + "api-service/internal/models" "database/sql" - "database/sql/driver" "encoding/json" "time" ) -// NullableInt32 is a custom type to replace sql.NullInt32 for swagger compatibility -type NullableInt32 struct { - Int32 int32 `json:"int32,omitempty"` - Valid bool `json:"valid"` -} - -// Scan implements the sql.Scanner interface for NullableInt32 -func (n *NullableInt32) Scan(value interface{}) error { - var ni sql.NullInt32 - if err := ni.Scan(value); err != nil { - return err - } - n.Int32 = ni.Int32 - n.Valid = ni.Valid - return nil -} - -// Value implements the driver.Valuer interface for NullableInt32 -func (n NullableInt32) Value() (driver.Value, error) { - if !n.Valid { - return nil, nil - } - return n.Int32, nil -} - // Retribusi represents the data structure for the retribusi table // with proper null handling and optimized JSON marshaling type Retribusi struct { - ID string `json:"id" db:"id"` - Status string `json:"status" db:"status"` - Sort NullableInt32 `json:"sort,omitempty" db:"sort"` - UserCreated sql.NullString `json:"user_created,omitempty" db:"user_created"` - DateCreated sql.NullTime `json:"date_created,omitempty" db:"date_created"` - UserUpdated sql.NullString `json:"user_updated,omitempty" db:"user_updated"` - DateUpdated sql.NullTime `json:"date_updated,omitempty" db:"date_updated"` - Jenis sql.NullString `json:"jenis,omitempty" db:"Jenis"` - Pelayanan sql.NullString `json:"pelayanan,omitempty" db:"Pelayanan"` - Dinas sql.NullString `json:"dinas,omitempty" db:"Dinas"` - KelompokObyek sql.NullString `json:"kelompok_obyek,omitempty" db:"Kelompok_obyek"` - KodeTarif sql.NullString `json:"kode_tarif,omitempty" db:"Kode_tarif"` - Tarif sql.NullString `json:"tarif,omitempty" db:"Tarif"` - Satuan sql.NullString `json:"satuan,omitempty" db:"Satuan"` - TarifOvertime sql.NullString `json:"tarif_overtime,omitempty" db:"Tarif_overtime"` - SatuanOvertime sql.NullString `json:"satuan_overtime,omitempty" db:"Satuan_overtime"` - RekeningPokok sql.NullString `json:"rekening_pokok,omitempty" db:"Rekening_pokok"` - RekeningDenda sql.NullString `json:"rekening_denda,omitempty" db:"Rekening_denda"` - Uraian1 sql.NullString `json:"uraian_1,omitempty" db:"Uraian_1"` - Uraian2 sql.NullString `json:"uraian_2,omitempty" db:"Uraian_2"` - Uraian3 sql.NullString `json:"uraian_3,omitempty" db:"Uraian_3"` + ID string `json:"id" db:"id"` + Status string `json:"status" db:"status"` + Sort models.NullableInt32 `json:"sort,omitempty" db:"sort"` + UserCreated sql.NullString `json:"user_created,omitempty" db:"user_created"` + DateCreated sql.NullTime `json:"date_created,omitempty" db:"date_created"` + UserUpdated sql.NullString `json:"user_updated,omitempty" db:"user_updated"` + DateUpdated sql.NullTime `json:"date_updated,omitempty" db:"date_updated"` + Jenis sql.NullString `json:"jenis,omitempty" db:"Jenis"` + Pelayanan sql.NullString `json:"pelayanan,omitempty" db:"Pelayanan"` + Dinas sql.NullString `json:"dinas,omitempty" db:"Dinas"` + KelompokObyek sql.NullString `json:"kelompok_obyek,omitempty" db:"Kelompok_obyek"` + KodeTarif sql.NullString `json:"kode_tarif,omitempty" db:"Kode_tarif"` + Tarif sql.NullString `json:"tarif,omitempty" db:"Tarif"` + Satuan sql.NullString `json:"satuan,omitempty" db:"Satuan"` + TarifOvertime sql.NullString `json:"tarif_overtime,omitempty" db:"Tarif_overtime"` + SatuanOvertime sql.NullString `json:"satuan_overtime,omitempty" db:"Satuan_overtime"` + RekeningPokok sql.NullString `json:"rekening_pokok,omitempty" db:"Rekening_pokok"` + RekeningDenda sql.NullString `json:"rekening_denda,omitempty" db:"Rekening_denda"` + Uraian1 sql.NullString `json:"uraian_1,omitempty" db:"Uraian_1"` + Uraian2 sql.NullString `json:"uraian_2,omitempty" db:"Uraian_2"` + Uraian3 sql.NullString `json:"uraian_3,omitempty" db:"Uraian_3"` } // Custom JSON marshaling untuk Retribusi agar NULL values tidak muncul di response func (r Retribusi) MarshalJSON() ([]byte, error) { - type Alias Retribusi - aux := &struct { - Sort *int `json:"sort,omitempty"` - UserCreated *string `json:"user_created,omitempty"` - DateCreated *time.Time `json:"date_created,omitempty"` - UserUpdated *string `json:"user_updated,omitempty"` - DateUpdated *time.Time `json:"date_updated,omitempty"` - Jenis *string `json:"jenis,omitempty"` - Pelayanan *string `json:"pelayanan,omitempty"` - Dinas *string `json:"dinas,omitempty"` - KelompokObyek *string `json:"kelompok_obyek,omitempty"` - KodeTarif *string `json:"kode_tarif,omitempty"` - Tarif *string `json:"tarif,omitempty"` - Satuan *string `json:"satuan,omitempty"` - TarifOvertime *string `json:"tarif_overtime,omitempty"` - SatuanOvertime *string `json:"satuan_overtime,omitempty"` - RekeningPokok *string `json:"rekening_pokok,omitempty"` - RekeningDenda *string `json:"rekening_denda,omitempty"` - Uraian1 *string `json:"uraian_1,omitempty"` - Uraian2 *string `json:"uraian_2,omitempty"` - Uraian3 *string `json:"uraian_3,omitempty"` - *Alias - }{ - Alias: (*Alias)(&r), - } + type Alias Retribusi + aux := &struct { + Sort *int `json:"sort,omitempty"` + UserCreated *string `json:"user_created,omitempty"` + DateCreated *time.Time `json:"date_created,omitempty"` + UserUpdated *string `json:"user_updated,omitempty"` + DateUpdated *time.Time `json:"date_updated,omitempty"` + Jenis *string `json:"jenis,omitempty"` + Pelayanan *string `json:"pelayanan,omitempty"` + Dinas *string `json:"dinas,omitempty"` + KelompokObyek *string `json:"kelompok_obyek,omitempty"` + KodeTarif *string `json:"kode_tarif,omitempty"` + Tarif *string `json:"tarif,omitempty"` + Satuan *string `json:"satuan,omitempty"` + TarifOvertime *string `json:"tarif_overtime,omitempty"` + SatuanOvertime *string `json:"satuan_overtime,omitempty"` + RekeningPokok *string `json:"rekening_pokok,omitempty"` + RekeningDenda *string `json:"rekening_denda,omitempty"` + Uraian1 *string `json:"uraian_1,omitempty"` + Uraian2 *string `json:"uraian_2,omitempty"` + Uraian3 *string `json:"uraian_3,omitempty"` + *Alias + }{ + Alias: (*Alias)(&r), + } - // Convert NullableInt32 to pointer - if r.Sort.Valid { - sort := int(r.Sort.Int32) - aux.Sort = &sort - } - if r.UserCreated.Valid { - aux.UserCreated = &r.UserCreated.String - } - if r.DateCreated.Valid { - aux.DateCreated = &r.DateCreated.Time - } - if r.UserUpdated.Valid { - aux.UserUpdated = &r.UserUpdated.String - } - if r.DateUpdated.Valid { - aux.DateUpdated = &r.DateUpdated.Time - } - if r.Jenis.Valid { - aux.Jenis = &r.Jenis.String - } - if r.Pelayanan.Valid { - aux.Pelayanan = &r.Pelayanan.String - } - if r.Dinas.Valid { - aux.Dinas = &r.Dinas.String - } - if r.KelompokObyek.Valid { - aux.KelompokObyek = &r.KelompokObyek.String - } - if r.KodeTarif.Valid { - aux.KodeTarif = &r.KodeTarif.String - } - if r.Tarif.Valid { - aux.Tarif = &r.Tarif.String - } - if r.Satuan.Valid { - aux.Satuan = &r.Satuan.String - } - if r.TarifOvertime.Valid { - aux.TarifOvertime = &r.TarifOvertime.String - } - if r.SatuanOvertime.Valid { - aux.SatuanOvertime = &r.SatuanOvertime.String - } - if r.RekeningPokok.Valid { - aux.RekeningPokok = &r.RekeningPokok.String - } - if r.RekeningDenda.Valid { - aux.RekeningDenda = &r.RekeningDenda.String - } - if r.Uraian1.Valid { - aux.Uraian1 = &r.Uraian1.String - } - if r.Uraian2.Valid { - aux.Uraian2 = &r.Uraian2.String - } - if r.Uraian3.Valid { - aux.Uraian3 = &r.Uraian3.String - } + // Convert NullableInt32 to pointer + if r.Sort.Valid { + sort := int(r.Sort.Int32) + aux.Sort = &sort + } + if r.UserCreated.Valid { + aux.UserCreated = &r.UserCreated.String + } + if r.DateCreated.Valid { + aux.DateCreated = &r.DateCreated.Time + } + if r.UserUpdated.Valid { + aux.UserUpdated = &r.UserUpdated.String + } + if r.DateUpdated.Valid { + aux.DateUpdated = &r.DateUpdated.Time + } + if r.Jenis.Valid { + aux.Jenis = &r.Jenis.String + } + if r.Pelayanan.Valid { + aux.Pelayanan = &r.Pelayanan.String + } + if r.Dinas.Valid { + aux.Dinas = &r.Dinas.String + } + if r.KelompokObyek.Valid { + aux.KelompokObyek = &r.KelompokObyek.String + } + if r.KodeTarif.Valid { + aux.KodeTarif = &r.KodeTarif.String + } + if r.Tarif.Valid { + aux.Tarif = &r.Tarif.String + } + if r.Satuan.Valid { + aux.Satuan = &r.Satuan.String + } + if r.TarifOvertime.Valid { + aux.TarifOvertime = &r.TarifOvertime.String + } + if r.SatuanOvertime.Valid { + aux.SatuanOvertime = &r.SatuanOvertime.String + } + if r.RekeningPokok.Valid { + aux.RekeningPokok = &r.RekeningPokok.String + } + if r.RekeningDenda.Valid { + aux.RekeningDenda = &r.RekeningDenda.String + } + if r.Uraian1.Valid { + aux.Uraian1 = &r.Uraian1.String + } + if r.Uraian2.Valid { + aux.Uraian2 = &r.Uraian2.String + } + if r.Uraian3.Valid { + aux.Uraian3 = &r.Uraian3.String + } - return json.Marshal(aux) + return json.Marshal(aux) } // Helper methods untuk mendapatkan nilai yang aman func (r *Retribusi) GetJenis() string { - if r.Jenis.Valid { - return r.Jenis.String - } - return "" + if r.Jenis.Valid { + return r.Jenis.String + } + return "" } func (r *Retribusi) GetDinas() string { - if r.Dinas.Valid { - return r.Dinas.String - } - return "" + if r.Dinas.Valid { + return r.Dinas.String + } + return "" } func (r *Retribusi) GetTarif() string { - if r.Tarif.Valid { - return r.Tarif.String - } - return "" + if r.Tarif.Valid { + return r.Tarif.String + } + return "" } // Response struct untuk GET by ID - diperbaiki struktur type RetribusiGetByIDResponse struct { - Message string `json:"message"` - Data *Retribusi `json:"data"` + Message string `json:"message"` + Data *Retribusi `json:"data"` } // Request struct untuk create - dioptimalkan dengan validasi type RetribusiCreateRequest struct { - Status string `json:"status" validate:"required,oneof=draft active inactive"` - Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"` - Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"` - Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"` - KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"` - KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"` - Uraian1 *string `json:"uraian_1,omitempty"` - Uraian2 *string `json:"uraian_2,omitempty"` - Uraian3 *string `json:"uraian_3,omitempty"` - Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"` - Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"` - TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"` - SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"` - RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"` - RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"` + Status string `json:"status" validate:"required,oneof=draft active inactive"` + Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"` + Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"` + Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"` + KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"` + KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"` + Uraian1 *string `json:"uraian_1,omitempty"` + Uraian2 *string `json:"uraian_2,omitempty"` + Uraian3 *string `json:"uraian_3,omitempty"` + Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"` + Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"` + TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"` + SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"` + RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"` + RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"` } // Response struct untuk create type RetribusiCreateResponse struct { - Message string `json:"message"` - Data *Retribusi `json:"data"` + Message string `json:"message"` + Data *Retribusi `json:"data"` } // Update request - sama seperti create tapi dengan ID type RetribusiUpdateRequest struct { - ID string `json:"-" validate:"required,uuid4"` // ID dari URL path - Status string `json:"status" validate:"required,oneof=draft active inactive"` - Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"` - Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"` - Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"` - KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"` - KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"` - Uraian1 *string `json:"uraian_1,omitempty"` - Uraian2 *string `json:"uraian_2,omitempty"` - Uraian3 *string `json:"uraian_3,omitempty"` - Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"` - Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"` - TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"` - SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"` - RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"` - RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"` + ID string `json:"-" validate:"required,uuid4"` // ID dari URL path + Status string `json:"status" validate:"required,oneof=draft active inactive"` + Jenis *string `json:"jenis,omitempty" validate:"omitempty,min=1,max=255"` + Pelayanan *string `json:"pelayanan,omitempty" validate:"omitempty,min=1,max=255"` + Dinas *string `json:"dinas,omitempty" validate:"omitempty,min=1,max=255"` + KelompokObyek *string `json:"kelompok_obyek,omitempty" validate:"omitempty,min=1,max=255"` + KodeTarif *string `json:"kode_tarif,omitempty" validate:"omitempty,min=1,max=255"` + Uraian1 *string `json:"uraian_1,omitempty"` + Uraian2 *string `json:"uraian_2,omitempty"` + Uraian3 *string `json:"uraian_3,omitempty"` + Tarif *string `json:"tarif,omitempty" validate:"omitempty,numeric"` + Satuan *string `json:"satuan,omitempty" validate:"omitempty,min=1,max=255"` + TarifOvertime *string `json:"tarif_overtime,omitempty" validate:"omitempty,numeric"` + SatuanOvertime *string `json:"satuan_overtime,omitempty" validate:"omitempty,min=1,max=255"` + RekeningPokok *string `json:"rekening_pokok,omitempty" validate:"omitempty,min=1,max=255"` + RekeningDenda *string `json:"rekening_denda,omitempty" validate:"omitempty,min=1,max=255"` } // Response struct untuk update type RetribusiUpdateResponse struct { - Message string `json:"message"` - Data *Retribusi `json:"data"` + Message string `json:"message"` + Data *Retribusi `json:"data"` } // Response struct untuk delete type RetribusiDeleteResponse struct { - Message string `json:"message"` - ID string `json:"id"` + Message string `json:"message"` + ID string `json:"id"` } // Enhanced GET response dengan pagination dan aggregation type RetribusiGetResponse struct { - Message string `json:"message"` - Data []Retribusi `json:"data"` - Meta MetaResponse `json:"meta"` - Summary *AggregateData `json:"summary,omitempty"` -} - -// Metadata untuk pagination - dioptimalkan -type MetaResponse struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - Total int `json:"total"` - TotalPages int `json:"total_pages"` - CurrentPage int `json:"current_page"` - HasNext bool `json:"has_next"` - HasPrev bool `json:"has_prev"` -} - -// Aggregate data untuk summary -type AggregateData struct { - TotalActive int `json:"total_active"` - TotalDraft int `json:"total_draft"` - TotalInactive int `json:"total_inactive"` - ByStatus map[string]int `json:"by_status"` - ByDinas map[string]int `json:"by_dinas,omitempty"` - ByJenis map[string]int `json:"by_jenis,omitempty"` - LastUpdated *time.Time `json:"last_updated,omitempty"` - CreatedToday int `json:"created_today"` - UpdatedToday int `json:"updated_today"` -} - -// Error response yang konsisten -type ErrorResponse struct { - Error string `json:"error"` - Code int `json:"code"` - Message string `json:"message"` - Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + Data []Retribusi `json:"data"` + Meta models.MetaResponse `json:"meta"` + Summary *models.AggregateData `json:"summary,omitempty"` } // Filter struct untuk query parameters type RetribusiFilter struct { - Status *string `json:"status,omitempty" form:"status"` - Jenis *string `json:"jenis,omitempty" form:"jenis"` - Dinas *string `json:"dinas,omitempty" form:"dinas"` - KelompokObyek *string `json:"kelompok_obyek,omitempty" form:"kelompok_obyek"` - Search *string `json:"search,omitempty" form:"search"` - DateFrom *time.Time `json:"date_from,omitempty" form:"date_from"` - DateTo *time.Time `json:"date_to,omitempty" form:"date_to"` -} - -// Validation constants -const ( - StatusDraft = "draft" - StatusActive = "active" - StatusInactive = "inactive" - StatusDeleted = "deleted" -) - -// ValidStatuses untuk validasi -var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive} - -// IsValidStatus helper function -func IsValidStatus(status string) bool { - for _, validStatus := range ValidStatuses { - if status == validStatus { - return true - } - } - return false + Status *string `json:"status,omitempty" form:"status"` + Jenis *string `json:"jenis,omitempty" form:"jenis"` + Dinas *string `json:"dinas,omitempty" form:"dinas"` + KelompokObyek *string `json:"kelompok_obyek,omitempty" form:"kelompok_obyek"` + Search *string `json:"search,omitempty" form:"search"` + DateFrom *time.Time `json:"date_from,omitempty" form:"date_from"` + DateTo *time.Time `json:"date_to,omitempty" form:"date_to"` } diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index 1365261e..f7bc52a7 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -55,7 +55,18 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // BPJS endpoints bpjsPesertaHandler := bpjsPesertaHandlers.NewPesertaHandler(cfg.Bpjs) v1.GET("/bpjs/peserta/nik/:nik/tglSEP/:tglSEP", bpjsPesertaHandler.GetPesertaByNIK) - + // Retribusi endpoints + retribusiHandler := retribusiHandlers.NewRetribusiHandler() + retribusiGroup := v1.Group("/retribusi") + { + retribusiGroup.GET("", retribusiHandler.GetRetribusi) + retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic) // Route baru + retribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced) // Route pencarian + retribusiGroup.GET("/:id", retribusiHandler.GetRetribusiByID) + retribusiGroup.POST("", retribusiHandler.CreateRetribusi) + retribusiGroup.PUT("/:id", retribusiHandler.UpdateRetribusi) + retribusiGroup.DELETE("/:id", retribusiHandler.DeleteRetribusi) + } // ============================================================================= // PROTECTED ROUTES (Authentication Required) // ============================================================================= @@ -68,15 +79,15 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { protected.GET("/auth/me", authHandler.Me) // Retribusi endpoints (CRUD operations - should be protected) - retribusiHandler := retribusiHandlers.NewRetribusiHandler() - protectedRetribusi := protected.Group("/retribusi") - { - protectedRetribusi.GET("/", retribusiHandler.GetRetribusi) // GET /api/v1/retribusi/ - protectedRetribusi.GET("/:id", retribusiHandler.GetRetribusiByID) // GET /api/v1/retribusi/:id - protectedRetribusi.POST("/", retribusiHandler.CreateRetribusi) // POST /api/v1/retribusi/ - protectedRetribusi.PUT("/:id", retribusiHandler.UpdateRetribusi) // PUT /api/v1/retribusi/:id - protectedRetribusi.DELETE("/:id", retribusiHandler.DeleteRetribusi) // DELETE /api/v1/retribusi/:id - } + // retribusiHandler := retribusiHandlers.NewRetribusiHandler() + // protectedRetribusi := protected.Group("/retribusi") + // { + // protectedRetribusi.GET("", retribusiHandler.GetRetribusi) // GET /api/v1/retribusi + // protectedRetribusi.GET("/:id", retribusiHandler.GetRetribusiByID) // GET /api/v1/retribusi/:id + // protectedRetribusi.POST("/", retribusiHandler.CreateRetribusi) // POST /api/v1/retribusi/ + // protectedRetribusi.PUT("/:id", retribusiHandler.UpdateRetribusi) // PUT /api/v1/retribusi/:id + // protectedRetribusi.DELETE("/:id", retribusiHandler.DeleteRetribusi) // DELETE /api/v1/retribusi/:id + // } // BPJS endpoints (sensitive data - should be protected) // bpjsPesertaHandler := bpjsPesertaHandlers.NewPesertaHandler(cfg.Bpjs) diff --git a/internal/utils/env.go b/internal/utils/env.go deleted file mode 100644 index da733e4b..00000000 --- a/internal/utils/env.go +++ /dev/null @@ -1,42 +0,0 @@ -package utils - -import ( - "os" - "strconv" - "time" -) - -// GetEnv retrieves environment variable with fallback -func GetEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -// GetEnvAsInt retrieves environment variable as integer with fallback -func GetEnvAsInt(key string, defaultValue int) int { - valueStr := GetEnv(key, "") - if value, err := strconv.Atoi(valueStr); err == nil { - return value - } - return defaultValue -} - -// GetEnvAsBool retrieves environment variable as boolean with fallback -func GetEnvAsBool(key string, defaultValue bool) bool { - valueStr := GetEnv(key, "") - if value, err := strconv.ParseBool(valueStr); err == nil { - return value - } - return defaultValue -} - -// GetEnvAsDuration retrieves environment variable as duration with fallback -func GetEnvAsDuration(key string, defaultValue time.Duration) time.Duration { - valueStr := GetEnv(key, "") - if value, err := time.ParseDuration(valueStr); err == nil { - return value - } - return defaultValue -} diff --git a/internal/utils/filters/dynamic_filter.go b/internal/utils/filters/dynamic_filter.go new file mode 100644 index 00000000..e012cde3 --- /dev/null +++ b/internal/utils/filters/dynamic_filter.go @@ -0,0 +1,541 @@ +package utils + +import ( + "fmt" + "reflect" + "strings" +) + +// FilterOperator represents supported filter operators +type FilterOperator string + +const ( + OpEqual FilterOperator = "_eq" + OpNotEqual FilterOperator = "_neq" + OpLike FilterOperator = "_like" + OpILike FilterOperator = "_ilike" + OpIn FilterOperator = "_in" + OpNotIn FilterOperator = "_nin" + OpGreaterThan FilterOperator = "_gt" + OpGreaterThanEqual FilterOperator = "_gte" + OpLessThan FilterOperator = "_lt" + OpLessThanEqual FilterOperator = "_lte" + OpBetween FilterOperator = "_between" + OpNotBetween FilterOperator = "_nbetween" + OpNull FilterOperator = "_null" + OpNotNull FilterOperator = "_nnull" + OpContains FilterOperator = "_contains" + OpNotContains FilterOperator = "_ncontains" + OpStartsWith FilterOperator = "_starts_with" + OpEndsWith FilterOperator = "_ends_with" +) + +// DynamicFilter represents a single filter condition +type DynamicFilter struct { + Column string `json:"column"` + Operator FilterOperator `json:"operator"` + Value interface{} `json:"value"` + LogicOp string `json:"logic_op,omitempty"` // AND, OR +} + +// FilterGroup represents a group of filters +type FilterGroup struct { + Filters []DynamicFilter `json:"filters"` + LogicOp string `json:"logic_op"` // AND, OR +} + +// DynamicQuery represents the complete query structure +type DynamicQuery struct { + Fields []string `json:"fields,omitempty"` + Filters []FilterGroup `json:"filters,omitempty"` + Sort []SortField `json:"sort,omitempty"` + Limit int `json:"limit"` + Offset int `json:"offset"` + GroupBy []string `json:"group_by,omitempty"` + Having []FilterGroup `json:"having,omitempty"` +} + +// SortField represents sorting configuration +type SortField struct { + Column string `json:"column"` + Order string `json:"order"` // ASC, DESC +} + +// QueryBuilder builds SQL queries from dynamic filters +type QueryBuilder struct { + tableName string + columnMapping map[string]string // Maps API field names to DB column names + allowedColumns map[string]bool // Security: only allow specified columns + paramCounter int +} + +// NewQueryBuilder creates a new query builder instance +func NewQueryBuilder(tableName string) *QueryBuilder { + return &QueryBuilder{ + tableName: tableName, + columnMapping: make(map[string]string), + allowedColumns: make(map[string]bool), + paramCounter: 0, + } +} + +// SetColumnMapping sets the mapping between API field names and database column names +func (qb *QueryBuilder) SetColumnMapping(mapping map[string]string) *QueryBuilder { + qb.columnMapping = mapping + return qb +} + +// SetAllowedColumns sets the list of allowed columns for security +func (qb *QueryBuilder) SetAllowedColumns(columns []string) *QueryBuilder { + qb.allowedColumns = make(map[string]bool) + for _, col := range columns { + qb.allowedColumns[col] = true + } + return qb +} + +// BuildQuery builds the complete SQL query +func (qb *QueryBuilder) BuildQuery(query DynamicQuery) (string, []interface{}, error) { + qb.paramCounter = 0 + + // Build SELECT clause + selectClause := qb.buildSelectClause(query.Fields) + + // Build FROM clause + fromClause := fmt.Sprintf("FROM %s", qb.tableName) + + // Build WHERE clause + whereClause, whereArgs, err := qb.buildWhereClause(query.Filters) + if err != nil { + return "", nil, err + } + + // Build ORDER BY clause + orderClause := qb.buildOrderClause(query.Sort) + + // Build GROUP BY clause + groupClause := qb.buildGroupByClause(query.GroupBy) + + // Build HAVING clause + havingClause, havingArgs, err := qb.buildHavingClause(query.Having) + if err != nil { + return "", nil, err + } + + // Combine all parts + sqlParts := []string{selectClause, fromClause} + args := []interface{}{} + + if whereClause != "" { + sqlParts = append(sqlParts, "WHERE "+whereClause) + args = append(args, whereArgs...) + } + + if groupClause != "" { + sqlParts = append(sqlParts, groupClause) + } + + if havingClause != "" { + sqlParts = append(sqlParts, "HAVING "+havingClause) + args = append(args, havingArgs...) + } + + if orderClause != "" { + sqlParts = append(sqlParts, orderClause) + } + + // Add pagination + if query.Limit > 0 { + qb.paramCounter++ + sqlParts = append(sqlParts, fmt.Sprintf("LIMIT $%d", qb.paramCounter)) + args = append(args, query.Limit) + } + + if query.Offset > 0 { + qb.paramCounter++ + sqlParts = append(sqlParts, fmt.Sprintf("OFFSET $%d", qb.paramCounter)) + args = append(args, query.Offset) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} + +// buildSelectClause builds the SELECT part of the query +func (qb *QueryBuilder) buildSelectClause(fields []string) string { + if len(fields) == 0 || (len(fields) == 1 && fields[0] == "*") { + return "SELECT *" + } + + var selectedFields []string + for _, field := range fields { + if field == "*.*" || field == "*" { + selectedFields = append(selectedFields, "*") + continue + } + + // Map field name if mapping exists + if mappedCol, exists := qb.columnMapping[field]; exists { + field = mappedCol + } + + // Security check: only allow specified columns + if len(qb.allowedColumns) > 0 && !qb.allowedColumns[field] { + continue + } + + selectedFields = append(selectedFields, fmt.Sprintf(`"%s"`, field)) + } + + if len(selectedFields) == 0 { + return "SELECT *" + } + + return "SELECT " + strings.Join(selectedFields, ", ") +} + +// buildWhereClause builds the WHERE part of the query +func (qb *QueryBuilder) buildWhereClause(filterGroups []FilterGroup) (string, []interface{}, error) { + if len(filterGroups) == 0 { + return "", nil, nil + } + + var conditions []string + var args []interface{} + + for i, group := range filterGroups { + groupCondition, groupArgs, err := qb.buildFilterGroup(group) + if err != nil { + return "", nil, err + } + + if groupCondition != "" { + if i > 0 { + logicOp := "AND" + if group.LogicOp != "" { + logicOp = strings.ToUpper(group.LogicOp) + } + conditions = append(conditions, logicOp) + } + + conditions = append(conditions, "("+groupCondition+")") + args = append(args, groupArgs...) + } + } + + return strings.Join(conditions, " "), args, nil +} + +// buildFilterGroup builds conditions for a filter group +func (qb *QueryBuilder) buildFilterGroup(group FilterGroup) (string, []interface{}, error) { + if len(group.Filters) == 0 { + return "", nil, nil + } + + var conditions []string + var args []interface{} + + for i, filter := range group.Filters { + condition, filterArgs, err := qb.buildFilterCondition(filter) + if err != nil { + return "", nil, err + } + + if condition != "" { + if i > 0 { + logicOp := "AND" + if filter.LogicOp != "" { + logicOp = strings.ToUpper(filter.LogicOp) + } else if group.LogicOp != "" { + logicOp = strings.ToUpper(group.LogicOp) + } + conditions = append(conditions, logicOp) + } + + conditions = append(conditions, condition) + args = append(args, filterArgs...) + } + } + + return strings.Join(conditions, " "), args, nil +} + +// buildFilterCondition builds a single filter condition +func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []interface{}, error) { + // Map column name if mapping exists + column := filter.Column + if mappedCol, exists := qb.columnMapping[column]; exists { + column = mappedCol + } + + // Security check + if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] { + return "", nil, fmt.Errorf("column '%s' is not allowed", filter.Column) + } + + // Wrap column name in quotes for PostgreSQL + column = fmt.Sprintf(`"%s"`, column) + + switch filter.Operator { + case OpEqual: + qb.paramCounter++ + return fmt.Sprintf("%s = $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpNotEqual: + qb.paramCounter++ + return fmt.Sprintf("%s != $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpLike: + qb.paramCounter++ + return fmt.Sprintf("%s LIKE $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpILike: + qb.paramCounter++ + return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpIn: + values := qb.parseArrayValue(filter.Value) + if len(values) == 0 { + return "", nil, nil + } + + var placeholders []string + var args []interface{} + for _, val := range values { + qb.paramCounter++ + placeholders = append(placeholders, fmt.Sprintf("$%d", qb.paramCounter)) + args = append(args, val) + } + + return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ", ")), args, nil + + case OpNotIn: + values := qb.parseArrayValue(filter.Value) + if len(values) == 0 { + return "", nil, nil + } + + var placeholders []string + var args []interface{} + for _, val := range values { + qb.paramCounter++ + placeholders = append(placeholders, fmt.Sprintf("$%d", qb.paramCounter)) + args = append(args, val) + } + + return fmt.Sprintf("%s NOT IN (%s)", column, strings.Join(placeholders, ", ")), args, nil + + case OpGreaterThan: + qb.paramCounter++ + return fmt.Sprintf("%s > $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpGreaterThanEqual: + qb.paramCounter++ + return fmt.Sprintf("%s >= $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpLessThan: + qb.paramCounter++ + return fmt.Sprintf("%s < $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpLessThanEqual: + qb.paramCounter++ + return fmt.Sprintf("%s <= $%d", column, qb.paramCounter), []interface{}{filter.Value}, nil + + case OpBetween: + values := qb.parseArrayValue(filter.Value) + if len(values) != 2 { + return "", nil, fmt.Errorf("between operator requires exactly 2 values") + } + qb.paramCounter++ + param1 := qb.paramCounter + qb.paramCounter++ + param2 := qb.paramCounter + return fmt.Sprintf("%s BETWEEN $%d AND $%d", column, param1, param2), []interface{}{values[0], values[1]}, nil + + case OpNotBetween: + values := qb.parseArrayValue(filter.Value) + if len(values) != 2 { + return "", nil, fmt.Errorf("not between operator requires exactly 2 values") + } + qb.paramCounter++ + param1 := qb.paramCounter + qb.paramCounter++ + param2 := qb.paramCounter + return fmt.Sprintf("%s NOT BETWEEN $%d AND $%d", column, param1, param2), []interface{}{values[0], values[1]}, nil + + case OpNull: + return fmt.Sprintf("%s IS NULL", column), nil, nil + + case OpNotNull: + return fmt.Sprintf("%s IS NOT NULL", column), nil, nil + + case OpContains: + qb.paramCounter++ + value := fmt.Sprintf("%%%v%%", filter.Value) + return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil + + case OpNotContains: + qb.paramCounter++ + value := fmt.Sprintf("%%%v%%", filter.Value) + return fmt.Sprintf("%s NOT ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil + + case OpStartsWith: + qb.paramCounter++ + value := fmt.Sprintf("%v%%", filter.Value) + return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil + + case OpEndsWith: + qb.paramCounter++ + value := fmt.Sprintf("%%%v", filter.Value) + return fmt.Sprintf("%s ILIKE $%d", column, qb.paramCounter), []interface{}{value}, nil + + default: + return "", nil, fmt.Errorf("unsupported operator: %s", filter.Operator) + } +} + +// parseArrayValue parses array values from various formats +func (qb *QueryBuilder) parseArrayValue(value interface{}) []interface{} { + if value == nil { + return nil + } + + // If it's already a slice + if reflect.TypeOf(value).Kind() == reflect.Slice { + v := reflect.ValueOf(value) + result := make([]interface{}, v.Len()) + for i := 0; i < v.Len(); i++ { + result[i] = v.Index(i).Interface() + } + return result + } + + // If it's a string, try to split by comma + if str, ok := value.(string); ok { + if strings.Contains(str, ",") { + parts := strings.Split(str, ",") + result := make([]interface{}, len(parts)) + for i, part := range parts { + result[i] = strings.TrimSpace(part) + } + return result + } + return []interface{}{str} + } + + return []interface{}{value} +} + +// buildOrderClause builds the ORDER BY clause +func (qb *QueryBuilder) buildOrderClause(sortFields []SortField) string { + if len(sortFields) == 0 { + return "" + } + + var orderParts []string + for _, sort := range sortFields { + column := sort.Column + if mappedCol, exists := qb.columnMapping[column]; exists { + column = mappedCol + } + + // Security check + if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] { + continue + } + + order := "ASC" + if sort.Order != "" { + order = strings.ToUpper(sort.Order) + } + + orderParts = append(orderParts, fmt.Sprintf(`"%s" %s`, column, order)) + } + + if len(orderParts) == 0 { + return "" + } + + return "ORDER BY " + strings.Join(orderParts, ", ") +} + +// buildGroupByClause builds the GROUP BY clause +func (qb *QueryBuilder) buildGroupByClause(groupFields []string) string { + if len(groupFields) == 0 { + return "" + } + + var groupParts []string + for _, field := range groupFields { + column := field + if mappedCol, exists := qb.columnMapping[column]; exists { + column = mappedCol + } + + // Security check + if len(qb.allowedColumns) > 0 && !qb.allowedColumns[column] { + continue + } + + groupParts = append(groupParts, fmt.Sprintf(`"%s"`, column)) + } + + if len(groupParts) == 0 { + return "" + } + + return "GROUP BY " + strings.Join(groupParts, ", ") +} + +// buildHavingClause builds the HAVING clause +func (qb *QueryBuilder) buildHavingClause(havingGroups []FilterGroup) (string, []interface{}, error) { + if len(havingGroups) == 0 { + return "", nil, nil + } + + return qb.buildWhereClause(havingGroups) +} + +// BuildCountQuery builds a count query +func (qb *QueryBuilder) BuildCountQuery(query DynamicQuery) (string, []interface{}, error) { + qb.paramCounter = 0 + + // Build FROM clause + fromClause := fmt.Sprintf("FROM %s", qb.tableName) + + // Build WHERE clause + whereClause, whereArgs, err := qb.buildWhereClause(query.Filters) + if err != nil { + return "", nil, err + } + + // Build GROUP BY clause + groupClause := qb.buildGroupByClause(query.GroupBy) + + // Build HAVING clause + havingClause, havingArgs, err := qb.buildHavingClause(query.Having) + if err != nil { + return "", nil, err + } + + // Combine parts + sqlParts := []string{"SELECT COUNT(*)", fromClause} + args := []interface{}{} + + if whereClause != "" { + sqlParts = append(sqlParts, "WHERE "+whereClause) + args = append(args, whereArgs...) + } + + if groupClause != "" { + sqlParts = append(sqlParts, groupClause) + } + + if havingClause != "" { + sqlParts = append(sqlParts, "HAVING "+havingClause) + args = append(args, havingArgs...) + } + + sql := strings.Join(sqlParts, " ") + return sql, args, nil +} diff --git a/internal/utils/filters/query_parser.go b/internal/utils/filters/query_parser.go new file mode 100644 index 00000000..6b6f07ec --- /dev/null +++ b/internal/utils/filters/query_parser.go @@ -0,0 +1,241 @@ +package utils + +import ( + "net/url" + "strconv" + "strings" + "time" +) + +// QueryParser parses HTTP query parameters into DynamicQuery +type QueryParser struct { + defaultLimit int + maxLimit int +} + +// NewQueryParser creates a new query parser +func NewQueryParser() *QueryParser { + return &QueryParser{ + defaultLimit: 10, + maxLimit: 100, + } +} + +// SetLimits sets default and maximum limits +func (qp *QueryParser) SetLimits(defaultLimit, maxLimit int) *QueryParser { + qp.defaultLimit = defaultLimit + qp.maxLimit = maxLimit + return qp +} + +// ParseQuery parses URL query parameters into DynamicQuery +func (qp *QueryParser) ParseQuery(values url.Values) (DynamicQuery, error) { + query := DynamicQuery{ + Limit: qp.defaultLimit, + Offset: 0, + } + + // Parse fields + if fields := values.Get("fields"); fields != "" { + if fields == "*.*" || fields == "*" { + query.Fields = []string{"*"} + } else { + query.Fields = strings.Split(fields, ",") + for i, field := range query.Fields { + query.Fields[i] = strings.TrimSpace(field) + } + } + } + + // Parse pagination + if limit := values.Get("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil { + if l > 0 && l <= qp.maxLimit { + query.Limit = l + } + } + } + + if offset := values.Get("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil && o >= 0 { + query.Offset = o + } + } + + // Parse filters + filters, err := qp.parseFilters(values) + if err != nil { + return query, err + } + query.Filters = filters + + // Parse sorting + sorts, err := qp.parseSorting(values) + if err != nil { + return query, err + } + query.Sort = sorts + + // Parse group by + if groupBy := values.Get("group"); groupBy != "" { + query.GroupBy = strings.Split(groupBy, ",") + for i, field := range query.GroupBy { + query.GroupBy[i] = strings.TrimSpace(field) + } + } + + return query, nil +} + +// parseFilters parses filter parameters +// Supports format: filter[column][operator]=value +func (qp *QueryParser) parseFilters(values url.Values) ([]FilterGroup, error) { + filterMap := make(map[string]map[string]string) + + // Group filters by column + for key, vals := range values { + if strings.HasPrefix(key, "filter[") && strings.HasSuffix(key, "]") { + // Parse filter[column][operator] format + parts := strings.Split(key[7:len(key)-1], "][") + if len(parts) == 2 { + column := parts[0] + operator := parts[1] + + if filterMap[column] == nil { + filterMap[column] = make(map[string]string) + } + + if len(vals) > 0 { + filterMap[column][operator] = vals[0] + } + } + } + } + + if len(filterMap) == 0 { + return nil, nil + } + + // Convert to FilterGroup + var filters []DynamicFilter + + for column, operators := range filterMap { + for opStr, value := range operators { + operator := FilterOperator(opStr) + + // Parse value based on operator + var parsedValue interface{} + switch operator { + case OpIn, OpNotIn: + if value != "" { + parsedValue = strings.Split(value, ",") + } + case OpBetween, OpNotBetween: + if value != "" { + parts := strings.Split(value, ",") + if len(parts) == 2 { + parsedValue = []interface{}{strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])} + } + } + case OpNull, OpNotNull: + parsedValue = nil + default: + parsedValue = value + } + + filters = append(filters, DynamicFilter{ + Column: column, + Operator: operator, + Value: parsedValue, + }) + } + } + + if len(filters) == 0 { + return nil, nil + } + + return []FilterGroup{{ + Filters: filters, + LogicOp: "AND", + }}, nil +} + +// parseSorting parses sort parameters +// Supports format: sort=column1,-column2 (- for DESC) +func (qp *QueryParser) parseSorting(values url.Values) ([]SortField, error) { + sortParam := values.Get("sort") + if sortParam == "" { + return nil, nil + } + + var sorts []SortField + fields := strings.Split(sortParam, ",") + + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + + order := "ASC" + column := field + + if strings.HasPrefix(field, "-") { + order = "DESC" + column = field[1:] + } else if strings.HasPrefix(field, "+") { + column = field[1:] + } + + sorts = append(sorts, SortField{ + Column: column, + Order: order, + }) + } + + return sorts, nil +} + +// ParseAdvancedFilters parses complex filter structures +// Supports nested filters and logic operators +func (qp *QueryParser) ParseAdvancedFilters(filterParam string) ([]FilterGroup, error) { + // This would be for more complex JSON-based filters + // Implementation depends on your specific needs + return nil, nil +} + +// Helper function to parse date values +func parseDate(value string) (interface{}, error) { + // Try different date formats + formats := []string{ + "2006-01-02", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000Z", + "2006-01-02 15:04:05", + } + + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return t, nil + } + } + + return value, nil +} + +// Helper function to parse numeric values +func parseNumeric(value string) interface{} { + // Try integer first + if i, err := strconv.Atoi(value); err == nil { + return i + } + + // Try float + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f + } + + // Return as string + return value +} diff --git a/internal/utils/sep b/internal/utils/sep deleted file mode 100644 index 3c721dd2..00000000 --- a/internal/utils/sep +++ /dev/null @@ -1,220 +0,0 @@ - [ - "t_sep" => [ - "noKartu" => $data['noKartu'], - "tglSep" => $data['tglSep'], - "ppkPelayanan" => $data['ppkPelayanan'], - "jnsPelayanan" => $data['jnsPelayanan'], - "klsRawat" => [ - "klsRawatHak" => $data['klsRawatHak'], - "klsRawatNaik" => $data['klsRawatNaik'], - "pembiayaan" => $data['pembiayaan'], - "penanggungJawab" => $data['penanggungJawab'] - ], - "noMR" => $data['noMR'], - "rujukan" => [ - "asalRujukan" => $data['asalRujukan'], - "tglRujukan" => $data['tglRujukan'], - "noRujukan" => $data['noRujukan'], - "ppkRujukan" => $data['ppkRujukan'] - ], - "catatan" => $data['catatan'], - "diagAwal" => $data['diagAwal'], - "poli" => [ - "tujuan" => $data['tujuan'], - "eksekutif" => $data['eksekutif'] - ], - "cob" => [ - "cob" => $data['cob'] - ], - "katarak" => [ - "katarak" => $data['katarak'] - ], - "jaminan" => [ - "lakaLantas" => $data['lakaLantas'], - "noLP" => $data['noLP'], - "penjamin" => [ - "tglKejadian" => $data['tglKejadian'], - "keterangan" => $data['keterangan'], - "suplesi" => [ - "suplesi" => $data['suplesi'], - "noSepSuplesi" => $data['noSepSuplesi'], - "lokasiLaka" => [ - "kdPropinsi" => $data['kdPropinsi'], - "kdKabupaten" => $data['kdKabupaten'], - "kdKecamatan" => $data['kdKecamatan'] - ] - ] - ] - ], - "tujuanKunj" => $data['tujuanKunj'], - "flagProcedure" => $data['flagProcedure'], - "kdPenunjang" => $data['kdPenunjang'], - "assesmentPel" => $data['assesmentPel'], - "skdp" => [ - "noSurat" => $data['noSurat'], - "kodeDPJP" => $data['kodeDPJP'] - ], - "dpjpLayan" => $data['dpjpLayan'], - "noTelp" => $data['noTelp'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formUpdateData')) { - - - function formUpdateData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noSep" => $data['noSep'], - "klsRawat" => [ - "klsRawatHak" => $data['klsRawatHak'], - "klsRawatNaik" => $data['klsRawatNaik'], - "pembiayaan" => $data['pembiayaan'], - "penanggungJawab" => $data['penanggungJawab'] - ], - "noMR" => $data['noMR'], - "catatan" => $data['catatan'], - "diagAwal" => $data['diagAwal'], - "poli" => [ - "tujuan" => $data['tujuan'], - "eksekutif" => $data['eksekutif'] - ], - "cob" => [ - "cob" => $data['cob'] - ], - "katarak" => [ - "katarak" => $data['katarak'] - ], - "jaminan" => [ - "lakaLantas" => $data['lakaLantas'], - "penjamin" => [ - "tglKejadian" => $data['tglKejadian'], - "keterangan" => $data['keterangan'], - "suplesi" => [ - "suplesi" => $data['suplesi'], - "noSepSuplesi" => $data['noSepSuplesi'], - "lokasiLaka" => [ - "kdPropinsi" => $data['kdPropinsi'], - "kdKabupaten" => $data['kdKabupaten'], - "kdKecamatan" => $data['kdKecamatan'] - ] - ] - ] - ], - "dpjpLayan" => $data['dpjpLayan'], - "noTelp" => $data['noTelp'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formDeleteData')) { - function formDeleteData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noSep" => $data['noSep'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formPengajuanData')) { - function formPengajuanData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noKartu" => $data['noKartu'], - "tglSep" => $data['tglSep'], - "jnsPelayanan" => $data['jnsPelayanan'], - "jnsPengajuan" => $data['jnsPengajuan'], - "keterangan" => $data['keterangan'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formAprovalPengajuanData')) { - function formAprovalPengajuanData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noKartu" => $data['noKartu'], - "tglSep" => $data['tglSep'], - "jnsPelayanan" => $data['jnsPelayanan'], - "jnsPengajuan" => $data['jnsPengajuan'], - "keterangan" => $data['keterangan'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formTanggalPulangData')) { - function formTanggalPulangData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noSep" => $data['noSep'], - "statusPulang" => $data['statusPulang'], - "noSuratMeninggal" => $data['noSuratMeninggal'], - "tglMeninggal" => $data['tglMeninggal'], - "tglPulang" => $data['tglPulang'], - "noLPManual" => $data['noLPManual'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formDeleteSepinternalData')) { - function formDeleteSepinternalData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noSep" => $data['noSep'], - "noSurat" => $data['noSurat'], - "tglRujukanInternal" => $data['tglRujukanInternal'], - "kdPoliTuj" => $data['kdPoliTuj'], - "user" => $data['user'] - ] - ] - ]; - } -} -if (!function_exists('formRandomAnswerData')) { - function formRandomAnswerData($data = []) - { - return [ - "request" => [ - "t_sep" => [ - "noKartu" => $data['noKartu'], - "tglSep" => $data['tglSep'], - "jenPel" => $data['jenPel'], - "ppkPelSep" => $data['ppkPelSep'], - "tglLahir" => $data['tglLahir'], - "ppkPst" => $data['ppkPst'], - "user" => $data['user'] - ] - ] - ]; - } -} diff --git a/internal/utils/validation/duplicate_validator.go b/internal/utils/validation/duplicate_validator.go new file mode 100644 index 00000000..863c0580 --- /dev/null +++ b/internal/utils/validation/duplicate_validator.go @@ -0,0 +1,141 @@ +package validation + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// ValidationConfig holds configuration for duplicate validation +type ValidationConfig struct { + TableName string + IDColumn string + StatusColumn string + DateColumn string + ActiveStatuses []string + AdditionalFields map[string]interface{} +} + +// DuplicateValidator provides methods for validating duplicate entries +type DuplicateValidator struct { + db *sql.DB +} + +// NewDuplicateValidator creates a new instance of DuplicateValidator +func NewDuplicateValidator(db *sql.DB) *DuplicateValidator { + return &DuplicateValidator{db: db} +} + +// ValidateDuplicate checks for duplicate entries based on the provided configuration +func (dv *DuplicateValidator) ValidateDuplicate(ctx context.Context, config ValidationConfig, identifier interface{}) error { + query := fmt.Sprintf(` + SELECT COUNT(*) + FROM %s + WHERE %s = $1 + AND %s = ANY($2) + AND DATE(%s) = CURRENT_DATE + `, config.TableName, config.IDColumn, config.StatusColumn, config.DateColumn) + + var count int + err := dv.db.QueryRowContext(ctx, query, identifier, config.ActiveStatuses).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check duplicate: %w", err) + } + + if count > 0 { + return fmt.Errorf("data with ID %v already exists with active status today", identifier) + } + + return nil +} + +// ValidateDuplicateWithCustomFields checks for duplicates with additional custom fields +func (dv *DuplicateValidator) ValidateDuplicateWithCustomFields(ctx context.Context, config ValidationConfig, fields map[string]interface{}) error { + whereClause := fmt.Sprintf("%s = ANY($1) AND DATE(%s) = CURRENT_DATE", config.StatusColumn, config.DateColumn) + args := []interface{}{config.ActiveStatuses} + argIndex := 2 + + // Add additional field conditions + for fieldName, fieldValue := range config.AdditionalFields { + whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex) + args = append(args, fieldValue) + argIndex++ + } + + // Add dynamic fields + for fieldName, fieldValue := range fields { + whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex) + args = append(args, fieldValue) + argIndex++ + } + + query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", config.TableName, whereClause) + + var count int + err := dv.db.QueryRowContext(ctx, query, args...).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check duplicate with custom fields: %w", err) + } + + if count > 0 { + return fmt.Errorf("duplicate entry found with the specified criteria") + } + + return nil +} + +// ValidateOncePerDay ensures only one submission per day for a given identifier +func (dv *DuplicateValidator) ValidateOncePerDay(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) error { + query := fmt.Sprintf(` + SELECT COUNT(*) + FROM %s + WHERE %s = $1 + AND DATE(%s) = CURRENT_DATE + `, tableName, idColumn, dateColumn) + + var count int + err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check daily submission: %w", err) + } + + if count > 0 { + return fmt.Errorf("only one submission allowed per day for ID %v", identifier) + } + + return nil +} + +// GetLastSubmissionTime returns the last submission time for a given identifier +func (dv *DuplicateValidator) GetLastSubmissionTime(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) (*time.Time, error) { + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = $1 + ORDER BY %s DESC + LIMIT 1 + `, dateColumn, tableName, idColumn, dateColumn) + + var lastTime time.Time + err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&lastTime) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // No previous submission + } + return nil, fmt.Errorf("failed to get last submission time: %w", err) + } + + return &lastTime, nil +} + +// DefaultRetribusiConfig returns default configuration for retribusi validation +func DefaultRetribusiConfig() ValidationConfig { + return ValidationConfig{ + TableName: "data_retribusi", + IDColumn: "id", + StatusColumn: "status", + DateColumn: "date_created", + ActiveStatuses: []string{"active", "draft"}, + } +} diff --git a/tools/generate-handler.go b/tools/generate-handler.go index 9cc26372..726b79c9 100644 --- a/tools/generate-handler.go +++ b/tools/generate-handler.go @@ -22,6 +22,8 @@ type HandlerData struct { HasPut bool HasDelete bool HasStats bool + HasDynamic bool + HasSearch bool HasFilter bool HasPagination bool Timestamp string @@ -31,8 +33,8 @@ func main() { if len(os.Args) < 2 { fmt.Println("Usage: go run generate-handler.go [category/]entity [methods]") fmt.Println("Examples:") - fmt.Println(" go run generate-handler.go product get post put delete") - fmt.Println(" go run generate-handler.go product get post put delete stats") + fmt.Println(" go run generate-handler.go product get post put delete") + fmt.Println(" go run generate-handler.go retribusi/tarif get post put delete dynamic search") os.Exit(1) } @@ -42,8 +44,8 @@ func main() { if len(os.Args) > 2 { methods = os.Args[2:] } else { - // Default methods if none specified - methods = []string{"get", "post", "put", "delete"} + // Default methods with advanced features + methods = []string{"get", "post", "put", "delete", "dynamic", "search"} } // Parse category and entity @@ -100,6 +102,10 @@ func main() { data.HasDelete = true case "stats": data.HasStats = true + case "dynamic": + data.HasDynamic = true + case "search": + data.HasSearch = true } } @@ -110,18 +116,15 @@ func main() { // Create directories with improved logic var handlerDir, modelDir string - if category != "" { - // Dengan kategori: internal/handlers/category/ - handlerDir = filepath.Join("internal", "handlers", category) + handlerDir = filepath.Join("internal", "handlers") modelDir = filepath.Join("internal", "models", category) } else { - // Tanpa kategori: langsung internal/handlers/ handlerDir = filepath.Join("internal", "handlers") modelDir = filepath.Join("internal", "models") } - // Buat direktori + // Create directories for _, d := range []string{handlerDir, modelDir} { if err := os.MkdirAll(d, 0755); err != nil { panic(err) @@ -156,7 +159,17 @@ func generateHandlerFile(data HandlerData, handlerDir string) { import ( "` + data.ModuleName + `/internal/config" "` + data.ModuleName + `/internal/database" - models "` + modelsImportPath + `" + models "` + data.ModuleName + `/internal/models" + models` + data.NameLower + ` "` + modelsImportPath + `"` + + // Add conditional imports for dynamic and search functionality + if data.HasDynamic || data.HasSearch { + handlerContent += ` + utils "` + data.ModuleName + `/internal/utils/filters"` + } + + handlerContent += ` + "` + data.ModuleName + `/internal/utils/validation" "context" "database/sql" "fmt" @@ -173,8 +186,8 @@ import ( ) var ( - db database.Service - once sync.Once + db database.Service + once sync.Once validate *validator.Validate ) @@ -212,39 +225,32 @@ func New` + data.Name + `Handler() *` + data.Name + `Handler { if data.HasGet { handlerContent += generateGetMethods(data) } - + if data.HasDynamic { + handlerContent += generateDynamicMethod(data) + } + if data.HasSearch { + handlerContent += generateSearchMethod(data) + } if data.HasPost { handlerContent += generateCreateMethod(data) } - if data.HasPut { handlerContent += generateUpdateMethod(data) } - if data.HasDelete { handlerContent += generateDeleteMethod(data) } - if data.HasStats { handlerContent += generateStatsMethod(data) } // Add helper methods handlerContent += generateHelperMethods(data) + writeFile(filepath.Join(handlerDir, data.NameLower+".go"), handlerContent) } func generateGetMethods(data HandlerData) string { - // Build route path based on category - var routePath, singleRoutePath string - if data.Category != "" { - routePath = data.Category + "/" + data.NamePlural - singleRoutePath = data.Category + "/" + data.NameLower - } else { - routePath = data.NamePlural - singleRoutePath = data.NameLower - } - return ` // Get` + data.Name + ` godoc @@ -258,10 +264,10 @@ func generateGetMethods(data HandlerData) string { // @Param include_summary query bool false "Include aggregation summary" default(false) // @Param status query string false "Filter by status" // @Param search query string false "Search in multiple fields" -// @Success 200 {object} models.` + data.Name + `GetResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Bad request" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/` + routePath + ` [get] +// @Success 200 {object} models` + data.NameLower + `.` + data.Name + `GetResponse "Success response" +// @Failure 400 {object} models` + data.NameLower + `.ErrorResponse "Bad request" +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + ` [get] func (h *` + data.Name + `Handler) Get` + data.Name + `(c *gin.Context) { // Parse pagination parameters limit, offset, err := h.parsePaginationParams(c) @@ -273,9 +279,9 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `(c *gin.Context) { // Parse filter parameters filter := h.parseFilterParams(c) includeAggregation := c.Query("include_summary") == "true" - + // Get database connection - dbConn, err := h.db.GetDB("satudata") + dbConn, err := h.db.GetDB("postgres_satudata") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return @@ -287,12 +293,12 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `(c *gin.Context) { // Execute concurrent operations var ( - items []models.` + data.Name + ` - total int + items []models` + data.NameLower + `.` + data.Name + ` + total int aggregateData *models.AggregateData - wg sync.WaitGroup - errChan = make(chan error, 3) - mu sync.Mutex + wg sync.WaitGroup + errChan = make(chan error, 3) + mu sync.Mutex ) // Fetch total count @@ -350,10 +356,10 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `(c *gin.Context) { // Build response meta := h.calculateMeta(limit, offset, total) - response := models.` + data.Name + `GetResponse{ + response := models` + data.NameLower + `.` + data.Name + `GetResponse{ Message: "Data ` + data.NameLower + ` berhasil diambil", - Data: items, - Meta: meta, + Data: items, + Meta: meta, } if includeAggregation && aggregateData != nil { @@ -370,21 +376,21 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "` + data.Name + ` ID (UUID)" -// @Success 200 {object} models.` + data.Name + `GetByIDResponse "Success response" -// @Failure 400 {object} models.ErrorResponse "Invalid ID format" -// @Failure 404 {object} models.ErrorResponse "` + data.Name + ` not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/` + singleRoutePath + `/{id} [get] +// @Success 200 {object} models` + data.NameLower + `.` + data.Name + `GetByIDResponse "Success response" +// @Failure 400 {object} models` + data.NameLower + `.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models` + data.NameLower + `.ErrorResponse "` + data.Name + ` not found" +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NameLower + `/{id} [get] func (h *` + data.Name + `Handler) Get` + data.Name + `ByID(c *gin.Context) { id := c.Param("id") - + // Validate UUID format if _, err := uuid.Parse(id); err != nil { h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) return } - dbConn, err := h.db.GetDB("satudata") + dbConn, err := h.db.GetDB("postgres_satudata") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return @@ -403,9 +409,146 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `ByID(c *gin.Context) { return } - response := models.` + data.Name + `GetByIDResponse{ + response := models` + data.NameLower + `.` + data.Name + `GetByIDResponse{ Message: "` + data.Name + ` details retrieved successfully", - Data: item, + Data: item, + } + + c.JSON(http.StatusOK, response) +}` +} + +func generateDynamicMethod(data HandlerData) string { + return ` + +// Get` + data.Name + `Dynamic godoc +// @Summary Get ` + data.NameLower + ` with dynamic filtering +// @Description Returns ` + data.NamePlural + ` with advanced dynamic filtering like Directus +// @Tags ` + data.NameLower + ` +// @Accept json +// @Produce json +// @Param fields query string false "Fields to select (e.g., fields=*.*)" +// @Param filter[column][operator] query string false "Dynamic filters (e.g., filter[name][_eq]=value)" +// @Param sort query string false "Sort fields (e.g., sort=date_created,-name)" +// @Param limit query int false "Limit" default(10) +// @Param offset query int false "Offset" default(0) +// @Success 200 {object} models` + data.NameLower + `.` + data.Name + `GetResponse "Success response" +// @Failure 400 {object} models` + data.NameLower + `.ErrorResponse "Bad request" +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + `/dynamic [get] +func (h *` + data.Name + `Handler) Get` + data.Name + `Dynamic(c *gin.Context) { + // Parse query parameters + parser := utils.NewQueryParser().SetLimits(10, 100) + dynamicQuery, err := parser.ParseQuery(c.Request.URL.Query()) + if err != nil { + h.respondError(c, "Invalid query parameters", err, http.StatusBadRequest) + return + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute query with dynamic filtering + items, total, err := h.fetch` + data.Name + `sDynamic(ctx, dbConn, dynamicQuery) + if err != nil { + h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(dynamicQuery.Limit, dynamicQuery.Offset, total) + response := models` + data.NameLower + `.` + data.Name + `GetResponse{ + Message: "Data ` + data.NameLower + ` berhasil diambil", + Data: items, + Meta: meta, + } + + c.JSON(http.StatusOK, response) +}` +} + +func generateSearchMethod(data HandlerData) string { + return ` + +// Search` + data.Name + `Advanced provides advanced search capabilities +func (h *` + data.Name + `Handler) Search` + data.Name + `Advanced(c *gin.Context) { + // Parse complex search parameters + searchQuery := c.Query("q") + if searchQuery == "" { + h.respondError(c, "Search query is required", fmt.Errorf("empty search query"), http.StatusBadRequest) + return + } + + // Build dynamic query for search + query := utils.DynamicQuery{ + Fields: []string{"*"}, + Filters: []utils.FilterGroup{{ + Filters: []utils.DynamicFilter{ + { + Column: "status", + Operator: utils.OpNotEqual, + Value: "deleted", + }, + { + Column: "name", + Operator: utils.OpContains, + Value: searchQuery, + LogicOp: "OR", + }, + }, + LogicOp: "AND", + }}, + Sort: []utils.SortField{{ + Column: "date_created", + Order: "DESC", + }}, + Limit: 20, + Offset: 0, + } + + // Parse pagination if provided + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil && l > 0 && l <= 100 { + query.Limit = l + } + } + if offset := c.Query("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil && o >= 0 { + query.Offset = o + } + } + + // Get database connection + dbConn, err := h.db.GetDB("postgres_satudata") + if err != nil { + h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Execute search + items, total, err := h.fetch` + data.Name + `sDynamic(ctx, dbConn, query) + if err != nil { + h.logAndRespondError(c, "Search failed", err, http.StatusInternalServerError) + return + } + + // Build response + meta := h.calculateMeta(query.Limit, query.Offset, total) + response := models` + data.NameLower + `.` + data.Name + `GetResponse{ + Message: fmt.Sprintf("Search results for '%s'", searchQuery), + Data: items, + Meta: meta, } c.JSON(http.StatusOK, response) @@ -413,13 +556,6 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `ByID(c *gin.Context) { } func generateCreateMethod(data HandlerData) string { - var routePath string - if data.Category != "" { - routePath = data.Category + "/" + data.NamePlural - } else { - routePath = data.NamePlural - } - return ` // Create` + data.Name + ` godoc @@ -428,14 +564,13 @@ func generateCreateMethod(data HandlerData) string { // @Tags ` + data.NameLower + ` // @Accept json // @Produce json -// @Param request body models.` + data.Name + `CreateRequest true "` + data.Name + ` creation request" -// @Success 201 {object} models.` + data.Name + `CreateResponse "` + data.Name + ` created successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/` + routePath + ` [post] +// @Param request body models` + data.NameLower + `.` + data.Name + `CreateRequest true "` + data.Name + ` creation request" +// @Success 201 {object} models` + data.NameLower + `.` + data.Name + `CreateResponse "` + data.Name + ` created successfully" +// @Failure 400 {object} models` + data.NameLower + `.ErrorResponse "Bad request or validation error" +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + ` [post] func (h *` + data.Name + `Handler) Create` + data.Name + `(c *gin.Context) { - var req models.` + data.Name + `CreateRequest - + var req models` + data.NameLower + `.` + data.Name + `CreateRequest if err := c.ShouldBindJSON(&req); err != nil { h.respondError(c, "Invalid request body", err, http.StatusBadRequest) return @@ -447,7 +582,7 @@ func (h *` + data.Name + `Handler) Create` + data.Name + `(c *gin.Context) { return } - dbConn, err := h.db.GetDB("satudata") + dbConn, err := h.db.GetDB("postgres_satudata") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return @@ -456,15 +591,21 @@ func (h *` + data.Name + `Handler) Create` + data.Name + `(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) defer cancel() + // Validate duplicate and daily submission + if err := h.validate` + data.Name + `Submission(ctx, dbConn, &req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + item, err := h.create` + data.Name + `(ctx, dbConn, &req) if err != nil { h.logAndRespondError(c, "Failed to create ` + data.NameLower + `", err, http.StatusInternalServerError) return } - response := models.` + data.Name + `CreateResponse{ + response := models` + data.NameLower + `.` + data.Name + `CreateResponse{ Message: "` + data.Name + ` berhasil dibuat", - Data: item, + Data: item, } c.JSON(http.StatusCreated, response) @@ -472,13 +613,6 @@ func (h *` + data.Name + `Handler) Create` + data.Name + `(c *gin.Context) { } func generateUpdateMethod(data HandlerData) string { - var singleRoutePath string - if data.Category != "" { - singleRoutePath = data.Category + "/" + data.NameLower - } else { - singleRoutePath = data.NameLower - } - return ` // Update` + data.Name + ` godoc @@ -488,22 +622,22 @@ func generateUpdateMethod(data HandlerData) string { // @Accept json // @Produce json // @Param id path string true "` + data.Name + ` ID (UUID)" -// @Param request body models.` + data.Name + `UpdateRequest true "` + data.Name + ` update request" -// @Success 200 {object} models.` + data.Name + `UpdateResponse "` + data.Name + ` updated successfully" -// @Failure 400 {object} models.ErrorResponse "Bad request or validation error" -// @Failure 404 {object} models.ErrorResponse "` + data.Name + ` not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/` + singleRoutePath + `/{id} [put] +// @Param request body models` + data.NameLower + `.` + data.Name + `UpdateRequest true "` + data.Name + ` update request" +// @Success 200 {object} models` + data.NameLower + `.` + data.Name + `UpdateResponse "` + data.Name + ` updated successfully" +// @Failure 400 {object} models` + data.NameLower + `.ErrorResponse "Bad request or validation error" +// @Failure 404 {object} models` + data.NameLower + `.ErrorResponse "` + data.Name + ` not found" +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NameLower + `/{id} [put] func (h *` + data.Name + `Handler) Update` + data.Name + `(c *gin.Context) { id := c.Param("id") - + // Validate UUID format if _, err := uuid.Parse(id); err != nil { h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) return } - var req models.` + data.Name + `UpdateRequest + var req models` + data.NameLower + `.` + data.Name + `UpdateRequest if err := c.ShouldBindJSON(&req); err != nil { h.respondError(c, "Invalid request body", err, http.StatusBadRequest) return @@ -518,7 +652,7 @@ func (h *` + data.Name + `Handler) Update` + data.Name + `(c *gin.Context) { return } - dbConn, err := h.db.GetDB("satudata") + dbConn, err := h.db.GetDB("postgres_satudata") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return @@ -537,9 +671,9 @@ func (h *` + data.Name + `Handler) Update` + data.Name + `(c *gin.Context) { return } - response := models.` + data.Name + `UpdateResponse{ + response := models` + data.NameLower + `.` + data.Name + `UpdateResponse{ Message: "` + data.Name + ` berhasil diperbarui", - Data: item, + Data: item, } c.JSON(http.StatusOK, response) @@ -547,13 +681,6 @@ func (h *` + data.Name + `Handler) Update` + data.Name + `(c *gin.Context) { } func generateDeleteMethod(data HandlerData) string { - var singleRoutePath string - if data.Category != "" { - singleRoutePath = data.Category + "/" + data.NameLower - } else { - singleRoutePath = data.NameLower - } - return ` // Delete` + data.Name + ` godoc @@ -563,21 +690,21 @@ func generateDeleteMethod(data HandlerData) string { // @Accept json // @Produce json // @Param id path string true "` + data.Name + ` ID (UUID)" -// @Success 200 {object} models.` + data.Name + `DeleteResponse "` + data.Name + ` deleted successfully" -// @Failure 400 {object} models.ErrorResponse "Invalid ID format" -// @Failure 404 {object} models.ErrorResponse "` + data.Name + ` not found" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/` + singleRoutePath + `/{id} [delete] +// @Success 200 {object} models` + data.NameLower + `.` + data.Name + `DeleteResponse "` + data.Name + ` deleted successfully" +// @Failure 400 {object} models` + data.NameLower + `.ErrorResponse "Invalid ID format" +// @Failure 404 {object} models` + data.NameLower + `.ErrorResponse "` + data.Name + ` not found" +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NameLower + `/{id} [delete] func (h *` + data.Name + `Handler) Delete` + data.Name + `(c *gin.Context) { id := c.Param("id") - + // Validate UUID format if _, err := uuid.Parse(id); err != nil { h.respondError(c, "Invalid ID format", err, http.StatusBadRequest) return } - dbConn, err := h.db.GetDB("satudata") + dbConn, err := h.db.GetDB("postgres_satudata") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return @@ -596,9 +723,9 @@ func (h *` + data.Name + `Handler) Delete` + data.Name + `(c *gin.Context) { return } - response := models.` + data.Name + `DeleteResponse{ + response := models` + data.NameLower + `.` + data.Name + `DeleteResponse{ Message: "` + data.Name + ` berhasil dihapus", - ID: id, + ID: id, } c.JSON(http.StatusOK, response) @@ -606,13 +733,6 @@ func (h *` + data.Name + `Handler) Delete` + data.Name + `(c *gin.Context) { } func generateStatsMethod(data HandlerData) string { - var routePath string - if data.Category != "" { - routePath = data.Category + "/" + data.NamePlural - } else { - routePath = data.NamePlural - } - return ` // Get` + data.Name + `Stats godoc @@ -623,10 +743,10 @@ func generateStatsMethod(data HandlerData) string { // @Produce json // @Param status query string false "Filter statistics by status" // @Success 200 {object} models.AggregateData "Statistics data" -// @Failure 500 {object} models.ErrorResponse "Internal server error" -// @Router /api/v1/` + routePath + `/stats [get] +// @Failure 500 {object} models` + data.NameLower + `.ErrorResponse "Internal server error" +// @Router /api/v1/` + data.NamePlural + `/stats [get] func (h *` + data.Name + `Handler) Get` + data.Name + `Stats(c *gin.Context) { - dbConn, err := h.db.GetDB("satudata") + dbConn, err := h.db.GetDB("postgres_satudata") if err != nil { h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError) return @@ -644,155 +764,336 @@ func (h *` + data.Name + `Handler) Get` + data.Name + `Stats(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "Statistik ` + data.NameLower + ` berhasil diambil", - "data": aggregateData, + "data": aggregateData, }) }` } func generateHelperMethods(data HandlerData) string { - return ` + helperMethods := ` // Database operations -func (h *` + data.Name + `Handler) get` + data.Name + `ByID(ctx context.Context, dbConn *sql.DB, id string) (*models.` + data.Name + `, error) { - query := "SELECT id, status, date_created, date_updated, name FROM ` + data.TableName + ` WHERE id = $1 AND status != 'deleted'" +func (h *` + data.Name + `Handler) get` + data.Name + `ByID(ctx context.Context, dbConn *sql.DB, id string) (*models` + data.NameLower + `.` + data.Name + `, error) { + query := "SELECT id, status, sort, user_created, date_created, user_updated, date_updated, name FROM ` + data.TableName + ` WHERE id = $1 AND status != 'deleted'" row := dbConn.QueryRowContext(ctx, query, id) - - var item models.` + data.Name + ` - err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + + var item models` + data.NameLower + `.` + data.Name + ` + err := row.Scan(&item.ID, &item.Status, &item.Sort, &item.UserCreated, &item.DateCreated, &item.UserUpdated, &item.DateUpdated, &item.Name) if err != nil { return nil, err } - + return &item, nil } -func (h *` + data.Name + `Handler) create` + data.Name + `(ctx context.Context, dbConn *sql.DB, req *models.` + data.Name + `CreateRequest) (*models.` + data.Name + `, error) { +func (h *` + data.Name + `Handler) create` + data.Name + `(ctx context.Context, dbConn *sql.DB, req *models` + data.NameLower + `.` + data.Name + `CreateRequest) (*models` + data.NameLower + `.` + data.Name + `, error) { id := uuid.New().String() now := time.Now() - - query := "INSERT INTO ` + data.TableName + ` (id, status, date_created, date_updated, name) VALUES ($1, $2, $3, $4, $5) RETURNING id, status, date_created, date_updated, name" + + query := "INSERT INTO ` + data.TableName + ` (id, status, date_created, date_updated, name) VALUES ($1, $2, $3, $4, $5) RETURNING id, status, sort, user_created, date_created, user_updated, date_updated, name" row := dbConn.QueryRowContext(ctx, query, id, req.Status, now, now, req.Name) - - var item models.` + data.Name + ` - err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + + var item models` + data.NameLower + `.` + data.Name + ` + err := row.Scan(&item.ID, &item.Status, &item.Sort, &item.UserCreated, &item.DateCreated, &item.UserUpdated, &item.DateUpdated, &item.Name) if err != nil { return nil, fmt.Errorf("failed to create ` + data.NameLower + `: %w", err) } - + return &item, nil } -func (h *` + data.Name + `Handler) update` + data.Name + `(ctx context.Context, dbConn *sql.DB, req *models.` + data.Name + `UpdateRequest) (*models.` + data.Name + `, error) { +func (h *` + data.Name + `Handler) update` + data.Name + `(ctx context.Context, dbConn *sql.DB, req *models` + data.NameLower + `.` + data.Name + `UpdateRequest) (*models` + data.NameLower + `.` + data.Name + `, error) { now := time.Now() - - query := "UPDATE ` + data.TableName + ` SET status = $2, date_updated = $3, name = $4 WHERE id = $1 AND status != 'deleted' RETURNING id, status, date_created, date_updated, name" + + query := "UPDATE ` + data.TableName + ` SET status = $2, date_updated = $3, name = $4 WHERE id = $1 AND status != 'deleted' RETURNING id, status, sort, user_created, date_created, user_updated, date_updated, name" row := dbConn.QueryRowContext(ctx, query, req.ID, req.Status, now, req.Name) - - var item models.` + data.Name + ` - err := row.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + + var item models` + data.NameLower + `.` + data.Name + ` + err := row.Scan(&item.ID, &item.Status, &item.Sort, &item.UserCreated, &item.DateCreated, &item.UserUpdated, &item.DateUpdated, &item.Name) if err != nil { return nil, fmt.Errorf("failed to update ` + data.NameLower + `: %w", err) } - + return &item, nil } func (h *` + data.Name + `Handler) delete` + data.Name + `(ctx context.Context, dbConn *sql.DB, id string) error { now := time.Now() - query := "UPDATE ` + data.TableName + ` SET status = 'deleted', date_updated = $2 WHERE id = $1 AND status != 'deleted'" + result, err := dbConn.ExecContext(ctx, query, id, now) if err != nil { return fmt.Errorf("failed to delete ` + data.NameLower + `: %w", err) } - + rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get affected rows: %w", err) } - + if rowsAffected == 0 { return sql.ErrNoRows } - + return nil } -func (h *` + data.Name + `Handler) fetch` + data.Name + `s(ctx context.Context, dbConn *sql.DB, filter models.` + data.Name + `Filter, limit, offset int) ([]models.` + data.Name + `, error) { +func (h *` + data.Name + `Handler) fetch` + data.Name + `s(ctx context.Context, dbConn *sql.DB, filter models` + data.NameLower + `.` + data.Name + `Filter, limit, offset int) ([]models` + data.NameLower + `.` + data.Name + `, error) { whereClause, args := h.buildWhereClause(filter) - query := fmt.Sprintf("SELECT id, status, date_created, date_updated, name FROM ` + data.TableName + ` WHERE %s ORDER BY date_created DESC NULLS LAST LIMIT $%d OFFSET $%d", whereClause, len(args)+1, len(args)+2) + query := fmt.Sprintf("SELECT id, status, sort, user_created, date_created, user_updated, date_updated, name FROM ` + data.TableName + ` WHERE %s ORDER BY date_created DESC NULLS LAST LIMIT $%d OFFSET $%d", whereClause, len(args)+1, len(args)+2) args = append(args, limit, offset) - + rows, err := dbConn.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("fetch ` + data.NamePlural + ` query failed: %w", err) } defer rows.Close() - - items := make([]models.` + data.Name + `, 0, limit) + + items := make([]models` + data.NameLower + `.` + data.Name + `, 0, limit) for rows.Next() { - var item models.` + data.Name + ` - err := rows.Scan(&item.ID, &item.Status, &item.DateCreated, &item.DateUpdated, &item.Name) + item, err := h.scan` + data.Name + `(rows) if err != nil { return nil, fmt.Errorf("scan ` + data.Name + ` failed: %w", err) } items = append(items, item) } - + if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows iteration error: %w", err) } - + + log.Printf("Successfully fetched %d ` + data.NamePlural + ` with filters applied", len(items)) return items, nil +}` + + // Add dynamic fetch method if needed + if data.HasDynamic { + helperMethods += ` + +// fetchRetribusisDynamic executes dynamic query +func (h *` + data.Name + `Handler) fetch` + data.Name + `sDynamic(ctx context.Context, dbConn *sql.DB, query utils.DynamicQuery) ([]models` + data.NameLower + `.` + data.Name + `, int, error) { + // Setup query builder + builder := utils.NewQueryBuilder("` + data.TableName + `"). + SetAllowedColumns([]string{ + "id", "status", "sort", "user_created", "date_created", + "user_updated", "date_updated", "name", + }) + + // Add default filter to exclude deleted records + query.Filters = append([]utils.FilterGroup{{ + Filters: []utils.DynamicFilter{{ + Column: "status", + Operator: utils.OpNotEqual, + Value: "deleted", + }}, + LogicOp: "AND", + }}, query.Filters...) + + // Execute concurrent queries + var ( + items []models` + data.NameLower + `.` + data.Name + ` + total int + wg sync.WaitGroup + errChan = make(chan error, 2) + mu sync.Mutex + ) + + // Fetch total count + wg.Add(1) + go func() { + defer wg.Done() + countQuery := query + countQuery.Limit = 0 + countQuery.Offset = 0 + countSQL, countArgs, err := builder.BuildCountQuery(countQuery) + if err != nil { + errChan <- fmt.Errorf("failed to build count query: %w", err) + return + } + if err := dbConn.QueryRowContext(ctx, countSQL, countArgs...).Scan(&total); err != nil { + errChan <- fmt.Errorf("failed to get total count: %w", err) + return + } + }() + + // Fetch main data + wg.Add(1) + go func() { + defer wg.Done() + mainSQL, mainArgs, err := builder.BuildQuery(query) + if err != nil { + errChan <- fmt.Errorf("failed to build main query: %w", err) + return + } + + rows, err := dbConn.QueryContext(ctx, mainSQL, mainArgs...) + if err != nil { + errChan <- fmt.Errorf("failed to execute main query: %w", err) + return + } + defer rows.Close() + + var results []models` + data.NameLower + `.` + data.Name + ` + for rows.Next() { + item, err := h.scan` + data.Name + `(rows) + if err != nil { + errChan <- fmt.Errorf("failed to scan ` + data.NameLower + `: %w", err) + return + } + results = append(results, item) + } + + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("rows iteration error: %w", err) + return + } + + mu.Lock() + items = results + mu.Unlock() + }() + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + return nil, 0, err + } + } + + return items, total, nil } -func (h *` + data.Name + `Handler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter models.` + data.Name + `Filter, total *int) error { +// Optimized scanning function yang menggunakan sql.Null* types langsung +func (h *` + data.Name + `Handler) scan` + data.Name + `(rows *sql.Rows) (models` + data.NameLower + `.` + data.Name + `, error) { + var item models` + data.NameLower + `.` + data.Name + ` + return item, rows.Scan( + &item.ID, &item.Status, &item.Sort, &item.UserCreated, + &item.DateCreated, &item.UserUpdated, &item.DateUpdated, &item.Name, + ) +}` + } + + helperMethods += ` + +func (h *` + data.Name + `Handler) getTotalCount(ctx context.Context, dbConn *sql.DB, filter models` + data.NameLower + `.` + data.Name + `Filter, total *int) error { whereClause, args := h.buildWhereClause(filter) countQuery := fmt.Sprintf("SELECT COUNT(*) FROM ` + data.TableName + ` WHERE %s", whereClause) - if err := dbConn.QueryRowContext(ctx, countQuery, args...).Scan(total); err != nil { return fmt.Errorf("total count query failed: %w", err) } - return nil } -func (h *` + data.Name + `Handler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter models.` + data.Name + `Filter) (*models.AggregateData, error) { +// Get comprehensive aggregate data dengan filter support +func (h *` + data.Name + `Handler) getAggregateData(ctx context.Context, dbConn *sql.DB, filter models` + data.NameLower + `.` + data.Name + `Filter) (*models.AggregateData, error) { aggregate := &models.AggregateData{ ByStatus: make(map[string]int), } - + + // Build where clause untuk filter whereClause, args := h.buildWhereClause(filter) - statusQuery := fmt.Sprintf("SELECT status, COUNT(*) FROM ` + data.TableName + ` WHERE %s GROUP BY status ORDER BY status", whereClause) + + // Use concurrent execution untuk performance + var wg sync.WaitGroup + var mu sync.Mutex + errChan := make(chan error, 4) - rows, err := dbConn.QueryContext(ctx, statusQuery, args...) - if err != nil { - return nil, fmt.Errorf("status query failed: %w", err) - } - defer rows.Close() + // 1. Count by status + wg.Add(1) + go func() { + defer wg.Done() + statusQuery := fmt.Sprintf("SELECT status, COUNT(*) FROM ` + data.TableName + ` WHERE %s GROUP BY status ORDER BY status", whereClause) + + rows, err := dbConn.QueryContext(ctx, statusQuery, args...) + if err != nil { + errChan <- fmt.Errorf("status query failed: %w", err) + return + } + defer rows.Close() + + mu.Lock() + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + mu.Unlock() + errChan <- fmt.Errorf("status scan failed: %w", err) + return + } + aggregate.ByStatus[status] = count + switch status { + case "active": + aggregate.TotalActive = count + case "draft": + aggregate.TotalDraft = count + case "inactive": + aggregate.TotalInactive = count + } + } + mu.Unlock() + + if err := rows.Err(); err != nil { + errChan <- fmt.Errorf("status iteration error: %w", err) + } + }() - for rows.Next() { - var status string - var count int - if err := rows.Scan(&status, &count); err != nil { - return nil, fmt.Errorf("status scan failed: %w", err) + // 2. Get last updated time dan today statistics + wg.Add(1) + go func() { + defer wg.Done() + + // Last updated + lastUpdatedQuery := fmt.Sprintf("SELECT MAX(date_updated) FROM ` + data.TableName + ` WHERE %s AND date_updated IS NOT NULL", whereClause) + var lastUpdated sql.NullTime + if err := dbConn.QueryRowContext(ctx, lastUpdatedQuery, args...).Scan(&lastUpdated); err != nil { + errChan <- fmt.Errorf("last updated query failed: %w", err) + return } - aggregate.ByStatus[status] = count - switch status { - case "active": - aggregate.TotalActive = count - case "draft": - aggregate.TotalDraft = count - case "inactive": - aggregate.TotalInactive = count + // Today statistics + today := time.Now().Format("2006-01-02") + todayStatsQuery := fmt.Sprintf(` + "`" + ` + SELECT + SUM(CASE WHEN DATE(date_created) = $%d THEN 1 ELSE 0 END) as created_today, + SUM(CASE WHEN DATE(date_updated) = $%d AND DATE(date_created) != $%d THEN 1 ELSE 0 END) as updated_today + FROM ` + data.TableName + ` + WHERE %s` + "`" + `, len(args)+1, len(args)+1, len(args)+1, whereClause) + + todayArgs := append(args, today) + var createdToday, updatedToday int + if err := dbConn.QueryRowContext(ctx, todayStatsQuery, todayArgs...).Scan(&createdToday, &updatedToday); err != nil { + errChan <- fmt.Errorf("today stats query failed: %w", err) + return + } + + mu.Lock() + if lastUpdated.Valid { + aggregate.LastUpdated = &lastUpdated.Time + } + aggregate.CreatedToday = createdToday + aggregate.UpdatedToday = updatedToday + mu.Unlock() + }() + + // Wait for all goroutines + wg.Wait() + close(errChan) + + // Check for errors + for err := range errChan { + if err != nil { + return nil, err } } - + return aggregate, nil } -// Helper methods +// Enhanced error handling func (h *` + data.Name + `Handler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) { log.Printf("[ERROR] %s: %v", message, err) h.respondError(c, message, err, statusCode) @@ -803,92 +1104,90 @@ func (h *` + data.Name + `Handler) respondError(c *gin.Context, message string, if gin.Mode() == gin.ReleaseMode { errorMessage = "Internal server error" } - + c.JSON(statusCode, models.ErrorResponse{ - Error: errorMessage, - Code: statusCode, - Message: err.Error(), + Error: errorMessage, + Code: statusCode, + Message: err.Error(), Timestamp: time.Now(), }) } +// Parse pagination parameters dengan validation yang lebih ketat func (h *` + data.Name + `Handler) parsePaginationParams(c *gin.Context) (int, int, error) { limit := 10 // Default limit offset := 0 // Default offset - + if limitStr := c.Query("limit"); limitStr != "" { parsedLimit, err := strconv.Atoi(limitStr) if err != nil { return 0, 0, fmt.Errorf("invalid limit parameter: %s", limitStr) } - if parsedLimit <= 0 { return 0, 0, fmt.Errorf("limit must be greater than 0") } - if parsedLimit > 100 { return 0, 0, fmt.Errorf("limit cannot exceed 100") } - limit = parsedLimit } - + if offsetStr := c.Query("offset"); offsetStr != "" { parsedOffset, err := strconv.Atoi(offsetStr) if err != nil { return 0, 0, fmt.Errorf("invalid offset parameter: %s", offsetStr) } - if parsedOffset < 0 { return 0, 0, fmt.Errorf("offset cannot be negative") } - offset = parsedOffset } - + + log.Printf("Pagination - Limit: %d, Offset: %d", limit, offset) return limit, offset, nil } -func (h *` + data.Name + `Handler) parseFilterParams(c *gin.Context) models.` + data.Name + `Filter { - filter := models.` + data.Name + `Filter{} - +func (h *` + data.Name + `Handler) parseFilterParams(c *gin.Context) models` + data.NameLower + `.` + data.Name + `Filter { + filter := models` + data.NameLower + `.` + data.Name + `Filter{} + if status := c.Query("status"); status != "" { if models.IsValidStatus(status) { filter.Status = &status } } - + if search := c.Query("search"); search != "" { filter.Search = &search } - + // Parse date filters if dateFromStr := c.Query("date_from"); dateFromStr != "" { if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil { filter.DateFrom = &dateFrom } } - + if dateToStr := c.Query("date_to"); dateToStr != "" { if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil { filter.DateTo = &dateTo } } - + return filter } -func (h *` + data.Name + `Handler) buildWhereClause(filter models.` + data.Name + `Filter) (string, []interface{}) { +// Build WHERE clause dengan filter parameters +func (h *` + data.Name + `Handler) buildWhereClause(filter models` + data.NameLower + `.` + data.Name + `Filter) (string, []interface{}) { conditions := []string{"status != 'deleted'"} args := []interface{}{} paramCount := 1 - + if filter.Status != nil { conditions = append(conditions, fmt.Sprintf("status = $%d", paramCount)) args = append(args, *filter.Status) paramCount++ } - + if filter.Search != nil { searchCondition := fmt.Sprintf("name ILIKE $%d", paramCount) conditions = append(conditions, searchCondition) @@ -896,108 +1195,152 @@ func (h *` + data.Name + `Handler) buildWhereClause(filter models.` + data.Name args = append(args, searchTerm) paramCount++ } - + if filter.DateFrom != nil { conditions = append(conditions, fmt.Sprintf("date_created >= $%d", paramCount)) args = append(args, *filter.DateFrom) paramCount++ } - + if filter.DateTo != nil { conditions = append(conditions, fmt.Sprintf("date_created <= $%d", paramCount)) args = append(args, filter.DateTo.Add(24*time.Hour-time.Nanosecond)) paramCount++ } - + return strings.Join(conditions, " AND "), args } func (h *` + data.Name + `Handler) calculateMeta(limit, offset, total int) models.MetaResponse { totalPages := 0 currentPage := 1 - if limit > 0 { totalPages = (total + limit - 1) / limit // Ceiling division currentPage = (offset / limit) + 1 } - + return models.MetaResponse{ - Limit: limit, - Offset: offset, - Total: total, - TotalPages: totalPages, + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, CurrentPage: currentPage, - HasNext: offset+limit < total, - HasPrev: offset > 0, + HasNext: offset+limit < total, + HasPrev: offset > 0, } -}` } +// validate` + data.Name + `Submission performs validation for duplicate entries and daily submission limits +func (h *` + data.Name + `Handler) validate` + data.Name + `Submission(ctx context.Context, dbConn *sql.DB, req *models` + data.NameLower + `.` + data.Name + `CreateRequest) error { + // Import the validation utility + validator := validation.NewDuplicateValidator(dbConn) + + // Use default configuration + config := validation.ValidationConfig{ + TableName: "` + data.TableName + `", + IDColumn: "id", + StatusColumn: "status", + DateColumn: "date_created", + ActiveStatuses: []string{"active", "draft"}, + } + + // Validate duplicate entries with active status for today + err := validator.ValidateDuplicate(ctx, config, "dummy_id") + if err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Validate once per day submission + err = validator.ValidateOncePerDay(ctx, "` + data.TableName + `", "id", "date_created", "daily_limit") + if err != nil { + return fmt.Errorf("daily submission limit exceeded: %w", err) + } + + return nil +} + +// Example usage of the validation utility with custom configuration +func (h *` + data.Name + `Handler) validateWithCustomConfig(ctx context.Context, dbConn *sql.DB, req *models` + data.NameLower + `.` + data.Name + `CreateRequest) error { + // Create validator instance + validator := validation.NewDuplicateValidator(dbConn) + + // Use custom configuration + config := validation.ValidationConfig{ + TableName: "` + data.TableName + `", + IDColumn: "id", + StatusColumn: "status", + DateColumn: "date_created", + ActiveStatuses: []string{"active", "draft"}, + AdditionalFields: map[string]interface{}{ + "name": req.Name, + }, + } + + // Validate with custom fields + fields := map[string]interface{}{ + "name": *req.Name, + } + + err := validator.ValidateDuplicateWithCustomFields(ctx, config, fields) + if err != nil { + return fmt.Errorf("custom validation failed: %w", err) + } + + return nil +} + +// GetLastSubmissionTime example +func (h *` + data.Name + `Handler) getLastSubmissionTimeExample(ctx context.Context, dbConn *sql.DB, identifier string) (*time.Time, error) { + validator := validation.NewDuplicateValidator(dbConn) + return validator.GetLastSubmissionTime(ctx, "` + data.TableName + `", "id", "date_created", identifier) +}` + + return helperMethods +} + +// Keep existing functions for model generation and routes... +// (The remaining functions stay the same as in the original file) + // ================= MODEL GENERATION ===================== func generateModelFile(data HandlerData, modelDir string) { modelContent := `package models import ( + "` + data.ModuleName + `/internal/models" "database/sql" - "database/sql/driver" "encoding/json" "time" ) -// NullableInt32 is a custom type to replace sql.NullInt32 for swagger compatibility -type NullableInt32 struct { - Int32 int32 ` + "`json:\"int32,omitempty\"`" + ` - Valid bool ` + "`json:\"valid\"`" + ` -} - -// Scan implements the sql.Scanner interface for NullableInt32 -func (n *NullableInt32) Scan(value interface{}) error { - var ni sql.NullInt32 - if err := ni.Scan(value); err != nil { - return err - } - - n.Int32 = ni.Int32 - n.Valid = ni.Valid - return nil -} - -// Value implements the driver.Valuer interface for NullableInt32 -func (n NullableInt32) Value() (driver.Value, error) { - if !n.Valid { - return nil, nil - } - - return n.Int32, nil -} - // ` + data.Name + ` represents the data structure for the ` + data.NameLower + ` table +// with proper null handling and optimized JSON marshaling type ` + data.Name + ` struct { - ID string ` + "`json:\"id\" db:\"id\"`" + ` - Status string ` + "`json:\"status\" db:\"status\"`" + ` - Sort NullableInt32 ` + "`json:\"sort,omitempty\" db:\"sort\"`" + ` - UserCreated sql.NullString ` + "`json:\"user_created,omitempty\" db:\"user_created\"`" + ` - DateCreated sql.NullTime ` + "`json:\"date_created,omitempty\" db:\"date_created\"`" + ` - UserUpdated sql.NullString ` + "`json:\"user_updated,omitempty\" db:\"user_updated\"`" + ` - DateUpdated sql.NullTime ` + "`json:\"date_updated,omitempty\" db:\"date_updated\"`" + ` - Name sql.NullString ` + "`json:\"name,omitempty\" db:\"name\"`" + ` + ID string ` + "`json:\"id\" db:\"id\"`" + ` + Status string ` + "`json:\"status\" db:\"status\"`" + ` + Sort models.NullableInt32 ` + "`json:\"sort,omitempty\" db:\"sort\"`" + ` + UserCreated sql.NullString ` + "`json:\"user_created,omitempty\" db:\"user_created\"`" + ` + DateCreated sql.NullTime ` + "`json:\"date_created,omitempty\" db:\"date_created\"`" + ` + UserUpdated sql.NullString ` + "`json:\"user_updated,omitempty\" db:\"user_updated\"`" + ` + DateUpdated sql.NullTime ` + "`json:\"date_updated,omitempty\" db:\"date_updated\"`" + ` + Name sql.NullString ` + "`json:\"name,omitempty\" db:\"name\"`" + ` } -// Custom JSON marshaling for ` + data.Name + ` +// Custom JSON marshaling untuk ` + data.Name + ` agar NULL values tidak muncul di response func (r ` + data.Name + `) MarshalJSON() ([]byte, error) { type Alias ` + data.Name + ` aux := &struct { - Sort *int ` + "`json:\"sort,omitempty\"`" + ` - UserCreated *string ` + "`json:\"user_created,omitempty\"`" + ` + Sort *int ` + "`json:\"sort,omitempty\"`" + ` + UserCreated *string ` + "`json:\"user_created,omitempty\"`" + ` DateCreated *time.Time ` + "`json:\"date_created,omitempty\"`" + ` - UserUpdated *string ` + "`json:\"user_updated,omitempty\"`" + ` + UserUpdated *string ` + "`json:\"user_updated,omitempty\"`" + ` DateUpdated *time.Time ` + "`json:\"date_updated,omitempty\"`" + ` - Name *string ` + "`json:\"name,omitempty\"`" + ` + Name *string ` + "`json:\"name,omitempty\"`" + ` *Alias }{ Alias: (*Alias)(&r), } + // Convert NullableInt32 to pointer if r.Sort.Valid { sort := int(r.Sort.Int32) aux.Sort = &sort @@ -1026,7 +1369,7 @@ func (r ` + data.Name + `) MarshalJSON() ([]byte, error) { return json.Marshal(aux) } -// Helper methods +// Helper methods untuk mendapatkan nilai yang aman func (r *` + data.Name + `) GetName() string { if r.Name.Valid { return r.Name.String @@ -1038,124 +1381,73 @@ func (r *` + data.Name + `) GetName() string { if data.HasGet { modelContent += ` -// Response struct for GET by ID +// Response struct untuk GET by ID - diperbaiki struktur type ` + data.Name + `GetByIDResponse struct { - Message string ` + "`json:\"message\"`" + ` - Data *` + data.Name + ` ` + "`json:\"data\"`" + ` + Message string ` + "`json:\"message\"`" + ` + Data *` + data.Name + ` ` + "`json:\"data\"`" + ` } -// Enhanced GET response with pagination and aggregation +// Enhanced GET response dengan pagination dan aggregation type ` + data.Name + `GetResponse struct { - Message string ` + "`json:\"message\"`" + ` - Data []` + data.Name + ` ` + "`json:\"data\"`" + ` - Meta MetaResponse ` + "`json:\"meta\"`" + ` - Summary *AggregateData ` + "`json:\"summary,omitempty\"`" + ` + Message string ` + "`json:\"message\"`" + ` + Data []` + data.Name + ` ` + "`json:\"data\"`" + ` + Meta models.MetaResponse ` + "`json:\"meta\"`" + ` + Summary *models.AggregateData ` + "`json:\"summary,omitempty\"`" + ` }` } if data.HasPost { modelContent += ` -// Request struct for create +// Request struct untuk create - dioptimalkan dengan validasi type ` + data.Name + `CreateRequest struct { - Status string ` + "`json:\"status\" validate:\"required,oneof=draft active inactive\"`" + ` - Name *string ` + "`json:\"name,omitempty\" validate:\"omitempty,min=1,max=255\"`" + ` + Status string ` + "`json:\"status\" validate:\"required,oneof=draft active inactive\"`" + ` + Name *string ` + "`json:\"name,omitempty\" validate:\"omitempty,min=1,max=255\"`" + ` } -// Response struct for create +// Response struct untuk create type ` + data.Name + `CreateResponse struct { - Message string ` + "`json:\"message\"`" + ` - Data *` + data.Name + ` ` + "`json:\"data\"`" + ` + Message string ` + "`json:\"message\"`" + ` + Data *` + data.Name + ` ` + "`json:\"data\"`" + ` }` } if data.HasPut { modelContent += ` -// Update request +// Update request - sama seperti create tapi dengan ID type ` + data.Name + `UpdateRequest struct { - ID string ` + "`json:\"-\" validate:\"required,uuid4\"`" + ` // ID dari URL path - Status string ` + "`json:\"status\" validate:\"required,oneof=draft active inactive\"`" + ` - Name *string ` + "`json:\"name,omitempty\" validate:\"omitempty,min=1,max=255\"`" + ` + ID string ` + "`json:\"-\" validate:\"required,uuid4\"`" + ` // ID dari URL path + Status string ` + "`json:\"status\" validate:\"required,oneof=draft active inactive\"`" + ` + Name *string ` + "`json:\"name,omitempty\" validate:\"omitempty,min=1,max=255\"`" + ` } -// Response struct for update +// Response struct untuk update type ` + data.Name + `UpdateResponse struct { - Message string ` + "`json:\"message\"`" + ` - Data *` + data.Name + ` ` + "`json:\"data\"`" + ` + Message string ` + "`json:\"message\"`" + ` + Data *` + data.Name + ` ` + "`json:\"data\"`" + ` }` } if data.HasDelete { modelContent += ` -// Response struct for delete +// Response struct untuk delete type ` + data.Name + `DeleteResponse struct { Message string ` + "`json:\"message\"`" + ` - ID string ` + "`json:\"id\"`" + ` + ID string ` + "`json:\"id\"`" + ` }` } - // Add common structs + // Add filter struct modelContent += ` -// Metadata for pagination -type MetaResponse struct { - Limit int ` + "`json:\"limit\"`" + ` - Offset int ` + "`json:\"offset\"`" + ` - Total int ` + "`json:\"total\"`" + ` - TotalPages int ` + "`json:\"total_pages\"`" + ` - CurrentPage int ` + "`json:\"current_page\"`" + ` - HasNext bool ` + "`json:\"has_next\"`" + ` - HasPrev bool ` + "`json:\"has_prev\"`" + ` -} - -// Aggregate data for summary -type AggregateData struct { - TotalActive int ` + "`json:\"total_active\"`" + ` - TotalDraft int ` + "`json:\"total_draft\"`" + ` - TotalInactive int ` + "`json:\"total_inactive\"`" + ` - ByStatus map[string]int ` + "`json:\"by_status\"`" + ` - LastUpdated *time.Time ` + "`json:\"last_updated,omitempty\"`" + ` - CreatedToday int ` + "`json:\"created_today\"`" + ` - UpdatedToday int ` + "`json:\"updated_today\"`" + ` -} - -// Error response -type ErrorResponse struct { - Error string ` + "`json:\"error\"`" + ` - Code int ` + "`json:\"code\"`" + ` - Message string ` + "`json:\"message\"`" + ` - Timestamp time.Time ` + "`json:\"timestamp\"`" + ` -} - -// Filter struct for query parameters +// Filter struct untuk query parameters type ` + data.Name + `Filter struct { - Status *string ` + "`json:\"status,omitempty\" form:\"status\"`" + ` - Search *string ` + "`json:\"search,omitempty\" form:\"search\"`" + ` + Status *string ` + "`json:\"status,omitempty\" form:\"status\"`" + ` + Search *string ` + "`json:\"search,omitempty\" form:\"search\"`" + ` DateFrom *time.Time ` + "`json:\"date_from,omitempty\" form:\"date_from\"`" + ` - DateTo *time.Time ` + "`json:\"date_to,omitempty\" form:\"date_to\"`" + ` -} - -// Validation constants -const ( - StatusDraft = "draft" - StatusActive = "active" - StatusInactive = "inactive" - StatusDeleted = "deleted" -) - -// ValidStatuses for validation -var ValidStatuses = []string{StatusDraft, StatusActive, StatusInactive} - -// IsValidStatus helper function -func IsValidStatus(status string) bool { - for _, validStatus := range ValidStatuses { - if status == validStatus { - return true - } - } - return false + DateTo *time.Time ` + "`json:\"date_to,omitempty\" form:\"date_to\"`" + ` }` writeFile(filepath.Join(modelDir, data.NameLower+".go"), modelContent) @@ -1174,17 +1466,17 @@ func updateRoutesFile(data HandlerData) { routesContent := string(content) - // Build import path - PERBAIKAN UTAMA + // Build import path var importPath, importAlias string if data.Category != "" { - importPath = fmt.Sprintf("%s/internal/handlers/%s", data.ModuleName, data.Category) + importPath = fmt.Sprintf("%s/internal/handlers", data.ModuleName) importAlias = data.NameLower + "Handlers" } else { importPath = fmt.Sprintf("%s/internal/handlers", data.ModuleName) importAlias = data.NameLower + "Handlers" } - // import + // Add import importPattern := fmt.Sprintf("%s \"%s\"", importAlias, importPath) if !strings.Contains(routesContent, importPattern) { importToAdd := fmt.Sprintf("\t%s \"%s\"", importAlias, importPath) @@ -1194,59 +1486,27 @@ func updateRoutesFile(data HandlerData) { } } - // Build route paths - var routesPath, singleRoutePath string - if data.Category != "" { - routesPath = data.Category + "/" + data.NamePlural - singleRoutePath = data.Category + "/" + data.NameLower - } else { - routesPath = data.NamePlural - singleRoutePath = data.NameLower - } + // Build new routes in protected group format + newRoutes := generateProtectedRouteBlock(data) - // routes - newRoutes := fmt.Sprintf("\t\t// %s endpoints\n", data.Name) - newRoutes += fmt.Sprintf("\t\t%sHandler := %s.New%sHandler()\n", - data.NameLower, importAlias, data.Name) - - if data.HasGet { - newRoutes += fmt.Sprintf("\t\tv1.GET(\"/%s\", %sHandler.Get%s)\n", - routesPath, data.NameLower, data.Name) - newRoutes += fmt.Sprintf("\t\tv1.GET(\"/%s/:id\", %sHandler.Get%sByID)\n", - singleRoutePath, data.NameLower, data.Name) - } - - if data.HasPost { - newRoutes += fmt.Sprintf("\t\tv1.POST(\"/%s\", %sHandler.Create%s)\n", - routesPath, data.NameLower, data.Name) - } - - if data.HasPut { - newRoutes += fmt.Sprintf("\t\tv1.PUT(\"/%s/:id\", %sHandler.Update%s)\n", - singleRoutePath, data.NameLower, data.Name) - } - - if data.HasDelete { - newRoutes += fmt.Sprintf("\t\tv1.DELETE(\"/%s/:id\", %sHandler.Delete%s)\n", - singleRoutePath, data.NameLower, data.Name) - } - - if data.HasStats { - newRoutes += fmt.Sprintf("\t\tv1.GET(\"/%s/stats\", %sHandler.Get%sStats)\n", - routesPath, data.NameLower, data.Name) - } - - newRoutes += "\n" - - insertMarker := "\t\tprotected := v1.Group(\"/\")" + // Insert above protected routes marker + insertMarker := "// =============================================================================" if strings.Contains(routesContent, insertMarker) { if !strings.Contains(routesContent, fmt.Sprintf("New%sHandler", data.Name)) { + // Insert before the marker routesContent = strings.Replace(routesContent, insertMarker, - newRoutes+insertMarker, 1) + newRoutes+"\n\t"+insertMarker, 1) } else { fmt.Printf("✅ Routes for %s already exist, skipping...\n", data.Name) return } + } else { + // Fallback: insert at end of setupV1Routes function + setupFuncEnd := "\treturn r" + if strings.Contains(routesContent, setupFuncEnd) { + routesContent = strings.Replace(routesContent, setupFuncEnd, + newRoutes+"\n\n\t"+setupFuncEnd, 1) + } } if err := os.WriteFile(routesFile, []byte(routesContent), 0644); err != nil { @@ -1257,49 +1517,65 @@ func updateRoutesFile(data HandlerData) { fmt.Printf("✅ Updated routes.go with %s endpoints\n", data.Name) } -func printRoutesSample(data HandlerData) { - var routesPath, singleRoutePath string - if data.Category != "" { - routesPath = data.Category + "/" + data.NamePlural - singleRoutePath = data.Category + "/" + data.NameLower - } else { - routesPath = data.NamePlural - singleRoutePath = data.NameLower +func generateProtectedRouteBlock(data HandlerData) string { + routes := fmt.Sprintf(` + // %s endpoints + %sHandler := %sHandlers.New%sHandler() + %sGroup := v1.Group("/%s") + { + %sGroup.GET("", %sHandler.Get%s)`, + strings.Title(data.NamePlural), data.NameLower, data.NameLower, data.Name, + data.NameLower, data.NameLower, + data.NameLower, data.NameLower, data.Name) + + if data.HasDynamic { + routes += fmt.Sprintf(` + %sGroup.GET("/dynamic", %sHandler.Get%sDynamic) // Route baru`, + data.NameLower, data.NameLower, data.Name) } - var importAlias string - if data.Category != "" { - importAlias = data.NameLower + "Handlers" - } else { - importAlias = data.NameLower + "Handlers" + if data.HasSearch { + routes += fmt.Sprintf(` + %sGroup.GET("/search", %sHandler.Search%sAdvanced) // Route pencarian`, + data.NameLower, data.NameLower, data.Name) } - fmt.Printf(` -// %s endpoints -%sHandler := %s.New%sHandler() -`, data.Name, data.NameLower, importAlias, data.Name) - - if data.HasGet { - fmt.Printf("\tv1.GET(\"/%s\", %sHandler.Get%s)\n", routesPath, data.NameLower, data.Name) - fmt.Printf("\tv1.GET(\"/%s/:id\", %sHandler.Get%sByID)\n", singleRoutePath, data.NameLower, data.Name) - } + routes += fmt.Sprintf(` + %sGroup.GET("/:id", %sHandler.Get%sByID)`, + data.NameLower, data.NameLower, data.Name) if data.HasPost { - fmt.Printf("\tv1.POST(\"/%s\", %sHandler.Create%s)\n", routesPath, data.NameLower, data.Name) + routes += fmt.Sprintf(` + %sGroup.POST("", %sHandler.Create%s)`, + data.NameLower, data.NameLower, data.Name) } if data.HasPut { - fmt.Printf("\tv1.PUT(\"/%s/:id\", %sHandler.Update%s)\n", singleRoutePath, data.NameLower, data.Name) + routes += fmt.Sprintf(` + %sGroup.PUT("/:id", %sHandler.Update%s)`, + data.NameLower, data.NameLower, data.Name) } if data.HasDelete { - fmt.Printf("\tv1.DELETE(\"/%s/:id\", %sHandler.Delete%s)\n", singleRoutePath, data.NameLower, data.Name) + routes += fmt.Sprintf(` + %sGroup.DELETE("/:id", %sHandler.Delete%s)`, + data.NameLower, data.NameLower, data.Name) } if data.HasStats { - fmt.Printf("\tv1.GET(\"/%s/stats\", %sHandler.Get%sStats)\n", routesPath, data.NameLower, data.Name) + routes += fmt.Sprintf(` + %sGroup.GET("/stats", %sHandler.Get%sStats)`, + data.NameLower, data.NameLower, data.Name) } + routes += ` + }` + + return routes +} + +func printRoutesSample(data HandlerData) { + fmt.Print(generateProtectedRouteBlock(data)) fmt.Println() }