perbaikan tool generete
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
@@ -70,9 +73,9 @@ 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
|
||||
@@ -99,7 +102,7 @@ func (h *RetribusiHandler) GetRetribusi(c *gin.Context) {
|
||||
|
||||
// Execute concurrent operations
|
||||
var (
|
||||
retribusis []models.Retribusi
|
||||
retribusis []modelsretribusi.Retribusi
|
||||
total int
|
||||
aggregateData *models.AggregateData
|
||||
wg sync.WaitGroup
|
||||
@@ -162,7 +165,7 @@ func (h *RetribusiHandler) GetRetribusi(c *gin.Context) {
|
||||
|
||||
// Build response
|
||||
meta := h.calculateMeta(limit, offset, total)
|
||||
response := models.RetribusiGetResponse{
|
||||
response := modelsretribusi.RetribusiGetResponse{
|
||||
Message: "Data retribusi berhasil diambil",
|
||||
Data: retribusis,
|
||||
Meta: meta,
|
||||
@@ -182,10 +185,10 @@ 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")
|
||||
@@ -215,7 +218,7 @@ func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response := models.RetribusiGetByIDResponse{
|
||||
response := modelsretribusi.RetribusiGetByIDResponse{
|
||||
Message: "Retribusi details retrieved successfully",
|
||||
Data: retribusi,
|
||||
}
|
||||
@@ -223,19 +226,286 @@ func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) {
|
||||
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
|
||||
// @Summary Create retribusi
|
||||
// @Description Creates a new retribusi record
|
||||
// @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
|
||||
var req modelsretribusi.RetribusiCreateRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
|
||||
@@ -257,13 +527,19 @@ func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Validate duplicate and daily submission
|
||||
if err := h.validateRetribusiSubmission(ctx, dbConn, &req); err != nil {
|
||||
h.respondError(c, "Validation failed", err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
retribusi, err := h.createRetribusi(ctx, dbConn, &req)
|
||||
if err != nil {
|
||||
h.logAndRespondError(c, "Failed to create retribusi", err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := models.RetribusiCreateResponse{
|
||||
response := modelsretribusi.RetribusiCreateResponse{
|
||||
Message: "Retribusi berhasil dibuat",
|
||||
Data: retribusi,
|
||||
}
|
||||
@@ -278,11 +554,11 @@ 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")
|
||||
@@ -293,7 +569,7 @@ func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req models.RetribusiUpdateRequest
|
||||
var req modelsretribusi.RetribusiUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
|
||||
return
|
||||
@@ -327,7 +603,7 @@ func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response := models.RetribusiUpdateResponse{
|
||||
response := modelsretribusi.RetribusiUpdateResponse{
|
||||
Message: "Retribusi berhasil diperbarui",
|
||||
Data: retribusi,
|
||||
}
|
||||
@@ -342,10 +618,10 @@ 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")
|
||||
@@ -375,7 +651,7 @@ func (h *RetribusiHandler) DeleteRetribusi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response := models.RetribusiDeleteResponse{
|
||||
response := modelsretribusi.RetribusiDeleteResponse{
|
||||
Message: "Retribusi berhasil dihapus",
|
||||
ID: id,
|
||||
}
|
||||
@@ -390,8 +666,8 @@ 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")
|
||||
@@ -417,7 +693,7 @@ func (h *RetribusiHandler) GetRetribusiStats(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Get retribusi by ID
|
||||
func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB, id string) (*models.Retribusi, error) {
|
||||
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,
|
||||
@@ -429,7 +705,7 @@ func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB,
|
||||
|
||||
row := dbConn.QueryRowContext(ctx, query, id)
|
||||
|
||||
var retribusi models.Retribusi
|
||||
var retribusi modelsretribusi.Retribusi
|
||||
err := row.Scan(
|
||||
&retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated,
|
||||
&retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated,
|
||||
@@ -447,7 +723,7 @@ func (h *RetribusiHandler) getRetribusiByID(ctx context.Context, dbConn *sql.DB,
|
||||
}
|
||||
|
||||
// Create retribusi
|
||||
func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, req *models.RetribusiCreateRequest) (*models.Retribusi, error) {
|
||||
func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB, req *modelsretribusi.RetribusiCreateRequest) (*modelsretribusi.Retribusi, error) {
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
@@ -471,7 +747,7 @@ func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB,
|
||||
req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3,
|
||||
)
|
||||
|
||||
var retribusi models.Retribusi
|
||||
var retribusi modelsretribusi.Retribusi
|
||||
err := row.Scan(
|
||||
&retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated,
|
||||
&retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated,
|
||||
@@ -489,7 +765,7 @@ func (h *RetribusiHandler) createRetribusi(ctx context.Context, dbConn *sql.DB,
|
||||
}
|
||||
|
||||
// Update retribusi
|
||||
func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, req *models.RetribusiUpdateRequest) (*models.Retribusi, error) {
|
||||
func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB, req *modelsretribusi.RetribusiUpdateRequest) (*modelsretribusi.Retribusi, error) {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
@@ -512,7 +788,7 @@ func (h *RetribusiHandler) updateRetribusi(ctx context.Context, dbConn *sql.DB,
|
||||
req.RekeningPokok, req.RekeningDenda, req.Uraian1, req.Uraian2, req.Uraian3,
|
||||
)
|
||||
|
||||
var retribusi models.Retribusi
|
||||
var retribusi modelsretribusi.Retribusi
|
||||
err := row.Scan(
|
||||
&retribusi.ID, &retribusi.Status, &retribusi.Sort, &retribusi.UserCreated,
|
||||
&retribusi.DateCreated, &retribusi.UserUpdated, &retribusi.DateUpdated,
|
||||
@@ -607,7 +883,7 @@ func (h *RetribusiHandler) parsePaginationParams(c *gin.Context) (int, int, erro
|
||||
}
|
||||
|
||||
// Build WHERE clause dengan filter parameters
|
||||
func (h *RetribusiHandler) buildWhereClause(filter models.RetribusiFilter) (string, []interface{}) {
|
||||
func (h *RetribusiHandler) buildWhereClause(filter modelsretribusi.RetribusiFilter) (string, []interface{}) {
|
||||
conditions := []string{"status != 'deleted'"}
|
||||
args := []interface{}{}
|
||||
paramCount := 1
|
||||
@@ -668,8 +944,8 @@ func (h *RetribusiHandler) buildWhereClause(filter models.RetribusiFilter) (stri
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -697,8 +973,8 @@ func (h *RetribusiHandler) scanRetribusi(rows *sql.Rows) (models.Retribusi, erro
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -737,8 +1013,9 @@ func (h *RetribusiHandler) parseFilterParams(c *gin.Context) models.RetribusiFil
|
||||
|
||||
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) {
|
||||
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),
|
||||
@@ -926,8 +1203,9 @@ func (h *RetribusiHandler) getAggregateData(ctx context.Context, dbConn *sql.DB,
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
@@ -937,8 +1215,9 @@ func (h *RetribusiHandler) getTotalCount(ctx context.Context, dbConn *sql.DB, fi
|
||||
|
||||
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) {
|
||||
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
|
||||
@@ -964,7 +1243,7 @@ func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB,
|
||||
defer rows.Close()
|
||||
|
||||
// Pre-allocate slice dengan kapasitas yang tepat
|
||||
retribusis := make([]models.Retribusi, 0, limit)
|
||||
retribusis := make([]modelsretribusi.Retribusi, 0, limit)
|
||||
|
||||
for rows.Next() {
|
||||
retribusi, err := h.scanRetribusi(rows)
|
||||
@@ -981,6 +1260,7 @@ func (h *RetribusiHandler) fetchRetribusis(ctx context.Context, dbConn *sql.DB,
|
||||
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
|
||||
@@ -1001,6 +1281,68 @@ func (h *RetribusiHandler) calculateMeta(limit, offset, total int) models.MetaRe
|
||||
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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
85
internal/models/models.go
Normal file
85
internal/models/models.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,43 +1,18 @@
|
||||
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"`
|
||||
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"`
|
||||
@@ -238,40 +213,8 @@ type RetribusiDeleteResponse struct {
|
||||
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"`
|
||||
Meta models.MetaResponse `json:"meta"`
|
||||
Summary *models.AggregateData `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// Filter struct untuk query parameters
|
||||
@@ -284,24 +227,3 @@ type RetribusiFilter struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
541
internal/utils/filters/dynamic_filter.go
Normal file
541
internal/utils/filters/dynamic_filter.go
Normal file
@@ -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
|
||||
}
|
||||
241
internal/utils/filters/query_parser.go
Normal file
241
internal/utils/filters/query_parser.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
if (!function_exists('formCreateData')) {
|
||||
function formCreateData($data = [])
|
||||
{
|
||||
return [
|
||||
"request" => [
|
||||
"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']
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
141
internal/utils/validation/duplicate_validator.go
Normal file
141
internal/utils/validation/duplicate_validator.go
Normal file
@@ -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"},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user