Files
api_antrean/internal/handlers/retribusi/retribusi.go
T
2025-11-03 05:56:41 +00:00

1421 lines
48 KiB
Go

package handlers
import (
"api-service/internal/config"
"api-service/internal/database"
"api-service/internal/models"
"api-service/internal/models/retribusi"
queryUtils "api-service/internal/utils/query"
"api-service/internal/utils/validation"
"api-service/pkg/logger"
"context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
// =============================================================================
// GLOBAL INITIALIZATION & VALIDATION
// =============================================================================
var (
db database.Service
once sync.Once
validate *validator.Validate
)
// Initialize the database connection and validator once
func init() {
once.Do(func() {
db = database.New(config.LoadConfig())
validate = validator.New()
validate.RegisterValidation("retribusi_status", validateRetribusiStatus)
if db == nil {
logger.Fatal("Failed to initialize database connection")
}
})
}
// Custom validation for retribusi status
func validateRetribusiStatus(fl validator.FieldLevel) bool {
return models.IsValidStatus(fl.Field().String())
}
// =============================================================================
// CACHE IMPLEMENTATION
// =============================================================================
// CacheEntry represents an entry in the cache
type CacheEntry struct {
Data interface{}
ExpiresAt time.Time
}
// IsExpired checks if the cache entry has expired
func (e *CacheEntry) IsExpired() bool {
return time.Now().After(e.ExpiresAt)
}
// InMemoryCache implements a simple in-memory cache with TTL
type InMemoryCache struct {
items sync.Map
mu sync.RWMutex
}
// NewInMemoryCache creates a new in-memory cache
func NewInMemoryCache() *InMemoryCache {
return &InMemoryCache{}
}
// Get retrieves an item from the cache
func (c *InMemoryCache) Get(key string) (interface{}, bool) {
val, ok := c.items.Load(key)
if !ok {
return nil, false
}
entry, ok := val.(*CacheEntry)
if !ok || entry.IsExpired() {
c.items.Delete(key)
return nil, false
}
return entry.Data, true
}
// Set stores an item in the cache with a TTL
func (c *InMemoryCache) Set(key string, value interface{}, ttl time.Duration) {
entry := &CacheEntry{
Data: value,
ExpiresAt: time.Now().Add(ttl),
}
c.items.Store(key, entry)
}
// Delete removes an item from the cache
func (c *InMemoryCache) Delete(key string) {
c.items.Delete(key)
}
// DeleteByPrefix removes all items with a specific prefix
func (c *InMemoryCache) DeleteByPrefix(prefix string) {
c.items.Range(func(key, value interface{}) bool {
if keyStr, ok := key.(string); ok && strings.HasPrefix(keyStr, prefix) {
c.items.Delete(key)
}
return true
})
}
// =============================================================================
// RETRIBUSI HANDLER STRUCT
// =============================================================================
// RetribusiHandler handles retribusi services
type RetribusiHandler struct {
db database.Service
queryBuilder *queryUtils.QueryBuilder
validator *validation.DynamicValidator
cache *InMemoryCache // Tambahkan cache in-memory
}
// NewRetribusiHandler creates a new RetribusiHandler with a pre-configured QueryBuilder
func NewRetribusiHandler() *RetribusiHandler {
// CHANGE: Initialize QueryBuilder with allowed columns list for security.
queryBuilder := queryUtils.NewQueryBuilder(queryUtils.DBTypePostgreSQL).
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",
})
return &RetribusiHandler{
db: db,
queryBuilder: queryBuilder,
validator: validation.NewDynamicValidator(queryBuilder),
cache: NewInMemoryCache(), // Initialize in-memory cache
}
}
// =============================================================================
// HANDLER ENDPOINTS
// =============================================================================
// GetRetribusi godoc
// @Summary Get retribusi with pagination and optional aggregation
// @Description Returns a paginated list of retribusis with optional summary statistics
// @Tags Retribusi
// @Accept json
// @Produce json
// @Param limit query int false "Limit (max 100)" default(10)
// @Param offset query int false "Offset" default(0)
// @Param include_summary query bool false "Include aggregation summary" default(false)
// @Param status query string false "Filter by status"
// @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} retribusi.RetribusiGetResponse "Success response"
// @Failure 400 {object} models.ErrorResponse "Bad request"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /api/v1/retribusis [get]
func (h *RetribusiHandler) GetRetribusi(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
// CHANGE: Increase timeout for complex queries
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
// CHANGE: Use the core fetchRetribusisDynamic function for all data retrieval logic.
// We only need to build DynamicQuery from simple parameters.
query := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: "id"},
{Expression: "status"},
{Expression: "sort"},
{Expression: "user_created"},
{Expression: "date_created"},
{Expression: "user_updated"},
{Expression: "date_updated"},
{Expression: "Jenis"},
{Expression: "Pelayanan"},
{Expression: "Dinas"},
{Expression: "Kelompok_obyek"},
{Expression: "Kode_tarif"},
{Expression: "Tarif"},
{Expression: "Satuan"},
{Expression: "Tarif_overtime"},
{Expression: "Satuan_overtime"},
{Expression: "Rekening_pokok"},
{Expression: "Rekening_denda"},
{Expression: "Uraian_1"},
{Expression: "Uraian_2"},
{Expression: "Uraian_3"},
},
Sort: []queryUtils.SortField{{Column: "date_created", Order: "DESC"}},
}
// Parse pagination
if limit, err := strconv.Atoi(c.DefaultQuery("limit", "10")); err == nil && limit > 0 && limit <= 100 {
query.Limit = limit
}
if offset, err := strconv.Atoi(c.DefaultQuery("offset", "0")); err == nil && offset >= 0 {
query.Offset = offset
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err := h.db.GetSQLXDB("postgres_satudata")
if err != nil {
h.logAndRespondError(c, "Database connection failed", err, http.StatusInternalServerError)
return
}
// Parse simple filters
var filters []queryUtils.DynamicFilter
if status := c.Query("status"); status != "" && models.IsValidStatus(status) {
filters = append(filters, queryUtils.DynamicFilter{Column: "status", Operator: queryUtils.OpEqual, Value: status})
}
if jenis := c.Query("jenis"); jenis != "" {
filters = append(filters, queryUtils.DynamicFilter{Column: "Jenis", Operator: queryUtils.OpILike, Value: "%" + jenis + "%"})
}
if dinas := c.Query("dinas"); dinas != "" {
filters = append(filters, queryUtils.DynamicFilter{Column: "Dinas", Operator: queryUtils.OpILike, Value: "%" + dinas + "%"})
}
// CHANGE: Optimize query search dengan caching
search := c.Query("search")
var searchFilters []queryUtils.DynamicFilter
var cacheKey string
var useCache bool
// FIX: Initialize searchFilters before using it in the cache hit section
if search != "" {
// Batasi panjang search untuk mencegah query terlalu lambat
if len(search) > 50 {
search = search[:50]
}
// Generate cache key untuk search
cacheKey = fmt.Sprintf("retribusi:search:%s:%d:%d", search, query.Limit, query.Offset)
// Initialize searchFilters here
searchFilters = []queryUtils.DynamicFilter{
{Column: "Jenis", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
{Column: "Pelayanan", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
{Column: "Dinas", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
{Column: "Kode_tarif", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
{Column: "Uraian_1", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
}
// Try ambil dari cache terlebih dahulu
if cachedData, found := h.cache.Get(cacheKey); found {
logger.Info("Cache hit for search", map[string]interface{}{"search": search, "cache_key": cacheKey})
// Konversi dari interface{} ke tipe yang diharapkan
retribusis, ok := cachedData.([]retribusi.Retribusi)
if !ok {
logger.Error("Failed to convert cached data", map[string]interface{}{"cache_key": cacheKey})
} else {
// Jika diminta, ambil data agregasi
var aggregateData *models.AggregateData
if c.Query("include_summary") == "true" {
// CHANGE: parseFilterParams dihapus, We gunakan filter yang sudah dibuat.
// Build full filter groups for aggregate data (including search filters)
fullFilterGroups := []queryUtils.FilterGroup{
{Filters: searchFilters, LogicOp: "OR"},
}
if len(filters) > 0 {
fullFilterGroups = append(fullFilterGroups, queryUtils.FilterGroup{Filters: filters, LogicOp: "AND"})
}
aggregateData, err = h.getAggregateData(ctx, dbConn, fullFilterGroups)
if err != nil {
h.logAndRespondError(c, "Failed to get aggregate data", err, http.StatusInternalServerError)
return
}
}
// Bangun respons
meta := h.calculateMeta(query.Limit, query.Offset, len(retribusis))
response := retribusi.RetribusiGetResponse{
Message: "Data retribusi berhasil diambil (dari cache)",
Data: retribusis,
Meta: meta,
}
if aggregateData != nil {
response.Summary = aggregateData
}
c.JSON(http.StatusOK, response)
return
}
}
// Jika tidak ada di cache, tandai untuk disimpan setelah query
useCache = true
// Jika ada search, buat grup filter OR
query.Filters = append(query.Filters, queryUtils.FilterGroup{Filters: searchFilters, LogicOp: "OR"})
}
// Tambahkan filter lainnya (jika ada) sebagai grup AND
if len(filters) > 0 {
query.Filters = append(query.Filters, queryUtils.FilterGroup{Filters: filters, LogicOp: "AND"})
}
retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, query)
if err != nil {
h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError)
return
}
// CHANGE: Save hasil search ke cache jika ada parameter search
if useCache && len(retribusis) > 0 {
h.cache.Set(cacheKey, retribusis, 15*time.Minute) // Cache selama 15 menit
logger.Info("Cached search results", map[string]interface{}{"search": search, "cache_key": cacheKey, "count": len(retribusis)})
}
// Jika diminta, ambil data agregasi
var aggregateData *models.AggregateData
if c.Query("include_summary") == "true" {
// CHANGE: parseFilterParams dihapus, We gunakan filter yang sudah dibuat.
aggregateData, err = h.getAggregateData(ctx, dbConn, query.Filters)
if err != nil {
h.logAndRespondError(c, "Failed to get aggregate data", err, http.StatusInternalServerError)
return
}
}
// Bangun respons
meta := h.calculateMeta(query.Limit, query.Offset, total)
response := retribusi.RetribusiGetResponse{
Message: "Data retribusi berhasil diambil",
Data: retribusis,
Meta: meta,
}
if aggregateData != nil {
response.Summary = aggregateData
}
c.JSON(http.StatusOK, response)
}
// GetRetribusiByID godoc
// @Summary Get Retribusi by ID
// @Description Returns a single retribusi by ID
// @Tags Retribusi
// @Accept json
// @Produce json
// @Param id path string true "Retribusi ID (UUID)"
// @Success 200 {object} retribusi.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"
// @Router /api/v1/retribusi/{id} [get]
func (h *RetribusiHandler) GetRetribusiByID(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
h.respondError(c, "Invalid ID format", err, http.StatusBadRequest)
return
}
// CHANGE: Try ambil dari cache terlebih dahulu
cacheKey := fmt.Sprintf("retribusi:id:%s", id)
if cachedData, found := h.cache.Get(cacheKey); found {
logger.Info("Cache hit for ID", map[string]interface{}{"id": id, "cache_key": cacheKey})
// Konversi dari interface{} ke tipe yang diharapkan
if cachedRetribusi, ok := cachedData.(retribusi.Retribusi); ok {
response := retribusi.RetribusiGetByIDResponse{
Message: "Retribusi details retrieved successfully (dari cache)",
Data: &cachedRetribusi,
}
c.JSON(http.StatusOK, response)
return
}
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err := h.db.GetSQLXDB("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()
dynamicQuery := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: "id"},
{Expression: "status"},
{Expression: "sort"},
{Expression: "user_created"},
{Expression: "date_created"},
{Expression: "user_updated"},
{Expression: "date_updated"},
{Expression: "Jenis"},
{Expression: "Pelayanan"},
{Expression: "Dinas"},
{Expression: "Kelompok_obyek"},
{Expression: "Kode_tarif"},
{Expression: "Tarif"},
{Expression: "Satuan"},
{Expression: "Tarif_overtime"},
{Expression: "Satuan_overtime"},
{Expression: "Rekening_pokok"},
{Expression: "Rekening_denda"},
{Expression: "Uraian_1"},
{Expression: "Uraian_2"},
{Expression: "Uraian_3"},
},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: id},
{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"},
},
LogicOp: "AND",
}},
Limit: 1,
}
var dataretribusi retribusi.Retribusi
err = h.queryBuilder.ExecuteQueryRow(ctx, dbConn, dynamicQuery, &dataretribusi)
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
}
// CHANGE: Save ke cache
h.cache.Set(cacheKey, dataretribusi, 30*time.Minute) // Cache selama 30 menit
response := retribusi.RetribusiGetByIDResponse{
Message: "Retribusi details retrieved successfully",
Data: &dataretribusi,
}
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} retribusi.RetribusiGetResponse "Success response"
// @Failure 400 {object} models.ErrorResponse "Bad request"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /api/v1/retribusis/dynamic [get]
func (h *RetribusiHandler) GetRetribusiDynamic(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
parser := queryUtils.NewQueryParser().SetLimits(10, 100)
dynamicQuery, err := parser.ParseQuery(c.Request.URL.Query(), "data_retribusi")
if err != nil {
h.respondError(c, "Invalid query parameters", err, http.StatusBadRequest)
return
}
// Add default filter to exclude deleted records
dynamicQuery.Filters = append([]queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"}},
LogicOp: "AND",
}}, dynamicQuery.Filters...)
// CHANGE: Try ambil dari cache terlebih dahulu
// Buat cache key dari query string
cacheKey := fmt.Sprintf("retribusi:dynamic:%s", c.Request.URL.RawQuery)
if cachedData, found := h.cache.Get(cacheKey); found {
logger.Info("Cache hit for dynamic query", map[string]interface{}{"cache_key": cacheKey})
// Konversi dari interface{} ke tipe yang diharapkan
if retribusis, ok := cachedData.([]retribusi.Retribusi); ok {
meta := h.calculateMeta(dynamicQuery.Limit, dynamicQuery.Offset, len(retribusis))
response := retribusi.RetribusiGetResponse{
Message: "Data retribusi berhasil diambil (dari cache)",
Data: retribusis,
Meta: meta,
}
c.JSON(http.StatusOK, response)
return
}
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err := h.db.GetSQLXDB("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()
retribusis, total, err := h.fetchRetribusisDynamic(ctx, dbConn, dynamicQuery)
if err != nil {
h.logAndRespondError(c, "Failed to fetch data", err, http.StatusInternalServerError)
return
}
// CHANGE: Save ke cache
h.cache.Set(cacheKey, retribusis, 10*time.Minute) // Cache selama 10 menit
meta := h.calculateMeta(dynamicQuery.Limit, dynamicQuery.Offset, total)
response := retribusi.RetribusiGetResponse{
Message: "Data retribusi berhasil diambil",
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 retribusi.RetribusiCreateRequest true "Retribusi creation request"
// @Success 201 {object} retribusi.RetribusiCreateResponse "Retribusi created successfully"
// @Failure 400 {object} models.ErrorResponse "Bad request or validation error"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
// @Router /api/v1/retribusis [post]
func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
var req retribusi.RetribusiCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
return
}
if err := validate.Struct(&req); err != nil {
h.respondError(c, "Validation failed", err, http.StatusBadRequest)
return
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err := h.db.GetSQLXDB("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()
// CHANGE: Validate KodeTarif harus unik
if req.KodeTarif != nil && *req.KodeTarif != "" {
rule := validation.NewUniqueFieldRule(
"data_retribusi", // Nama tabel
"Kode_tarif", // Kolom yang harus unik
queryUtils.DynamicFilter{ // Kondisi tambahan
Column: "status",
Operator: queryUtils.OpNotEqual,
Value: "deleted",
},
)
// Siapkan data dari request untuk divalidasi
dataToValidate := map[string]interface{}{
"Kode_tarif": *req.KodeTarif,
}
// Eksekusi validasi
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
if err != nil {
h.logAndRespondError(c, "Failed to validate Kode Tarif", err, http.StatusInternalServerError)
return
}
if isDuplicate {
h.respondError(c, "Kode Tarif already exists", fmt.Errorf("duplicate Kode Tarif: %s", *req.KodeTarif), http.StatusConflict)
return
}
}
data := queryUtils.InsertData{
Columns: []string{
"id", "status", "date_created", "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",
},
Values: []interface{}{
uuid.New().String(), req.Status, time.Now(), time.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,
},
}
returningCols := []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",
}
sql, args, err := h.queryBuilder.BuildInsertQuery("data_retribusi", data, returningCols...)
if err != nil {
h.logAndRespondError(c, "Failed to build insert query", err, http.StatusInternalServerError)
return
}
var dataretribusi retribusi.Retribusi
err = dbConn.GetContext(ctx, &dataretribusi, sql, args...)
if err != nil {
h.logAndRespondError(c, "Failed to create retribusi", err, http.StatusInternalServerError)
return
}
// CHANGE: Invalidate cache yang mungkin terpengaruh
h.invalidateRelatedCache(dataretribusi.Jenis.String, dataretribusi.Dinas.String, dataretribusi.KodeTarif.String)
response := retribusi.RetribusiCreateResponse{Message: "Retribusi berhasil dibuat", Data: &dataretribusi}
c.JSON(http.StatusCreated, response)
}
// UpdateRetribusi godoc
// @Summary Update retribusi
// @Description Updates an existing retribusi record
// @Tags Retribusi
// @Accept json
// @Produce json
// @Param id path string true "Retribusi ID (UUID)"
// @Param request body retribusi.RetribusiUpdateRequest true "Retribusi update request"
// @Success 200 {object} retribusi.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"
// @Router /api/v1/retribusi/{id} [put]
func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
h.respondError(c, "Invalid ID format", err, http.StatusBadRequest)
return
}
var req retribusi.RetribusiUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.respondError(c, "Invalid request body", err, http.StatusBadRequest)
return
}
req.ID = id
if err := validate.Struct(&req); err != nil {
h.respondError(c, "Validation failed", err, http.StatusBadRequest)
return
}
// CHANGE: Try ambil data lama untuk cache invalidation
var oldData retribusi.Retribusi
dbConn, err := h.db.GetSQLXDB("postgres_satudata")
if err == nil {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
dynamicQuery := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{{Expression: "*"}},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: id},
},
LogicOp: "AND",
}},
Limit: 1,
}
err = h.queryBuilder.ExecuteQueryRow(ctx, dbConn, dynamicQuery, &oldData)
if err != nil {
logger.Error("Failed to fetch old data for cache invalidation", map[string]interface{}{"error": err.Error(), "id": id})
}
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err = h.db.GetSQLXDB("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()
// CHANGE: Validate KodeTarif harus unik, kecuali untuk record dengan ID ini
if req.KodeTarif != nil && *req.KodeTarif != "" {
rule := validation.ValidationRule{
TableName: "data_retribusi",
UniqueColumns: []string{"Kode_tarif"},
Conditions: []queryUtils.DynamicFilter{
{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"},
},
ExcludeIDColumn: "id", // Kecualikan berdasarkan kolom 'id'
ExcludeIDValue: id, // ...dengan nilai ID dari parameter
}
dataToValidate := map[string]interface{}{
"Kode_tarif": *req.KodeTarif,
}
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
if err != nil {
h.logAndRespondError(c, "Failed to validate Kode Tarif", err, http.StatusInternalServerError)
return
}
if isDuplicate {
h.respondError(c, "Kode Tarif already exists", fmt.Errorf("duplicate Kode Tarif: %s", *req.KodeTarif), http.StatusConflict)
return
}
}
updateData := queryUtils.UpdateData{
Columns: []string{
"status", "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",
},
Values: []interface{}{
req.Status, time.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,
},
}
filters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: req.ID},
{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"},
},
LogicOp: "AND",
}}
returningCols := []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",
}
sql, args, err := h.queryBuilder.BuildUpdateQuery("data_retribusi", updateData, filters, returningCols...)
if err != nil {
h.logAndRespondError(c, "Failed to build update query", err, http.StatusInternalServerError)
return
}
var dataretribusi retribusi.Retribusi
err = dbConn.GetContext(ctx, &dataretribusi, sql, args...)
if err != nil {
if err.Error() == "sql: no rows in result set" {
h.respondError(c, "Retribusi not found", err, http.StatusNotFound)
} else {
h.logAndRespondError(c, "Failed to update retribusi", err, http.StatusInternalServerError)
}
return
}
// CHANGE: Invalidate cache yang mungkin terpengaruh
// Invalidate cache untuk ID yang diupdate
cacheKey := fmt.Sprintf("retribusi:id:%s", id)
h.cache.Delete(cacheKey)
// Invalidate cache untuk data lama dan baru
if oldData.ID != "" {
h.invalidateRelatedCache(oldData.Jenis.String, oldData.Dinas.String, oldData.KodeTarif.String)
}
h.invalidateRelatedCache(dataretribusi.Jenis.String, dataretribusi.Dinas.String, dataretribusi.KodeTarif.String)
response := retribusi.RetribusiUpdateResponse{Message: "Retribusi berhasil diperbarui", Data: &dataretribusi}
c.JSON(http.StatusOK, response)
}
// DeleteRetribusi godoc
// @Summary Delete retribusi
// @Description Soft deletes a retribusi by setting status to 'deleted'
// @Tags Retribusi
// @Accept json
// @Produce json
// @Param id path string true "Retribusi ID (UUID)"
// @Success 200 {object} retribusi.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"
// @Router /api/v1/retribusi/{id} [delete]
func (h *RetribusiHandler) DeleteRetribusi(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
h.respondError(c, "Invalid ID format", err, http.StatusBadRequest)
return
}
// CHANGE: Try ambil data untuk cache invalidation
var dataToDelete retribusi.Retribusi
dbConn, err := h.db.GetSQLXDB("postgres_satudata")
if err == nil {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
dynamicQuery := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{{Expression: "*"}},
Filters: []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: id},
},
LogicOp: "AND",
}},
Limit: 1,
}
err = h.queryBuilder.ExecuteQueryRow(ctx, dbConn, dynamicQuery, &dataToDelete)
if err != nil {
logger.Error("Failed to fetch data for cache invalidation", map[string]interface{}{"error": err.Error(), "id": id})
}
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err = h.db.GetSQLXDB("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()
// CHANGE: Use ExecuteUpdate untuk soft delete dengan mengubah status
updateData := queryUtils.UpdateData{
Columns: []string{"status", "date_updated"},
Values: []interface{}{"deleted", time.Now()},
}
filters := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "id", Operator: queryUtils.OpEqual, Value: id},
{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"},
},
LogicOp: "AND",
}}
// CHANGE: Use ExecuteUpdate alih-alih ExecuteDelete
result, err := h.queryBuilder.ExecuteUpdate(ctx, dbConn, "data_retribusi", updateData, filters)
if err != nil {
h.logAndRespondError(c, "Failed to delete retribusi", err, http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
h.logAndRespondError(c, "Failed to get affected rows", err, http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
h.respondError(c, "Retribusi not found", sql.ErrNoRows, http.StatusNotFound)
return
}
// CHANGE: Invalidate cache yang mungkin terpengaruh
// Invalidate cache untuk ID yang dihapus
cacheKey := fmt.Sprintf("retribusi:id:%s", id)
h.cache.Delete(cacheKey)
// Invalidate cache untuk data yang dihapus
if dataToDelete.ID != "" {
h.invalidateRelatedCache(dataToDelete.Jenis.String, dataToDelete.Dinas.String, dataToDelete.KodeTarif.String)
}
response := retribusi.RetribusiDeleteResponse{Message: "Retribusi berhasil dihapus", ID: id}
c.JSON(http.StatusOK, response)
}
// GetRetribusiStats godoc
// @Summary Get retribusi statistics
// @Description Returns comprehensive statistics about retribusi data
// @Tags Retribusi
// @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"
// @Router /api/v1/retribusis/stats [get]
func (h *RetribusiHandler) GetRetribusiStats(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
// CHANGE: Try ambil dari cache terlebih dahulu
cacheKey := fmt.Sprintf("retribusi:stats:%s", c.Query("status"))
if cachedData, found := h.cache.Get(cacheKey); found {
logger.Info("Cache hit for stats", map[string]interface{}{"cache_key": cacheKey})
// Konversi dari interface{} ke tipe yang diharapkan
if aggregateData, ok := cachedData.(*models.AggregateData); ok {
c.JSON(http.StatusOK, gin.H{
"message": "Statistik retribusi berhasil diambil (dari cache)",
"data": aggregateData,
})
return
}
}
// CHANGE: Use GetSQLXDB untuk mendapatkan koneksi sqlx.DB
dbConn, err := h.db.GetSQLXDB("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()
// CHANGE: Kita tidak lagi parseFilterParams, We bisa menggunakan QueryParser di sini juga jika perlu
// atau membangun filter secara manual seperti di GetRetribusi.
// Untuk contoh, We asumsikan tidak ada filter selain default.
filterGroups := []queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"}},
LogicOp: "AND",
}}
// Tambahkan filter status jika ada
if status := c.Query("status"); status != "" && models.IsValidStatus(status) {
filterGroups = append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{{Column: "status", Operator: queryUtils.OpEqual, Value: status}},
LogicOp: "AND",
})
}
aggregateData, err := h.getAggregateData(ctx, dbConn, filterGroups)
if err != nil {
h.logAndRespondError(c, "Failed to get statistics", err, http.StatusInternalServerError)
return
}
// CHANGE: Save ke cache
h.cache.Set(cacheKey, aggregateData, 5*time.Minute) // Cache stats selama 5 menit
c.JSON(http.StatusOK, gin.H{
"message": "Statistik retribusi berhasil diambil",
"data": aggregateData,
})
}
// GetWelcome godoc
// @Summary Get welcome message
// @Description Returns a welcome message and logs the request
// @Tags Retribusi
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string "Welcome message"
// @Router /api/v1/retribusis/welcome [get]
func (h *RetribusiHandler) GetWelcome(c *gin.Context) {
logger.Info("Request received", map[string]interface{}{"method": c.Request.Method, "path": c.Request.URL.Path})
c.JSON(http.StatusOK, gin.H{"message": "Welcome to the Retribusi API Service!"})
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
// CHANGE: Fungsi untuk invalidate cache yang terkait dengan data yang diubah
func (h *RetribusiHandler) invalidateRelatedCache(jenis, dinas, kodeTarif string) {
// Invalidate cache untuk search yang mungkin terpengaruh
h.cache.DeleteByPrefix("retribusi:search:")
h.cache.DeleteByPrefix("retribusi:dynamic:")
h.cache.DeleteByPrefix("retribusi:stats:")
// Invalidate cache untuk filter spesifik
if jenis != "" {
cacheKey := fmt.Sprintf("retribusi:filter:jenis:%s", jenis)
h.cache.Delete(cacheKey)
}
if dinas != "" {
cacheKey := fmt.Sprintf("retribusi:filter:dinas:%s", dinas)
h.cache.Delete(cacheKey)
}
if kodeTarif != "" {
cacheKey := fmt.Sprintf("retribusi:filter:kode_tarif:%s", kodeTarif)
h.cache.Delete(cacheKey)
}
}
// CHANGE: Optimize fungsi fetchRetribusisDynamic untuk menangani timeout
// CHANGE: Optimize fungsi fetchRetribusisDynamic untuk menangani timeout
func (h *RetribusiHandler) fetchRetribusisDynamic(ctx context.Context, dbConn *sqlx.DB, query queryUtils.DynamicQuery) ([]retribusi.Retribusi, int, error) {
logger.Info("Starting fetchRetribusisDynamic", map[string]interface{}{
"limit": query.Limit,
"offset": query.Offset,
"from": query.From,
})
var total int
var retribusis []retribusi.Retribusi
// CHANGE: Untuk query dengan search, gunakan pendekatan yang berbeda
hasSearch := false
for _, filterGroup := range query.Filters {
for _, filter := range filterGroup.Filters {
if filter.Operator == queryUtils.OpILike {
hasSearch = true
break
}
}
if hasSearch {
break
}
}
logger.Info("Query analysis", map[string]interface{}{
"hasSearch": hasSearch,
"totalFilters": len(query.Filters),
})
// CHANGE: Optimize untuk mencegah timeout pada query search
// Use context yang lebih pendek untuk query search dan count
queryCtx, queryCancel := context.WithTimeout(ctx, 30*time.Second)
defer queryCancel()
logger.Info("Context setup", map[string]interface{}{
"contextTimeout": "30s",
"hasSearch": hasSearch,
})
// Untuk query dengan search, batasi limit dan gunakan estimasi total
if hasSearch {
search := getSearchTerm(query)
logger.Info("Executing search query with timeout context", map[string]interface{}{"search_term": search})
// CHANGE: Untuk search, batasi limit maksimum untuk mencegah timeout
maxSearchLimit := 50
if query.Limit > maxSearchLimit {
query.Limit = maxSearchLimit
logger.Info("Reduced search limit to prevent timeout", map[string]interface{}{
"original_limit": query.Limit,
"new_limit": maxSearchLimit,
})
}
// Eksekusi query search
err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &retribusis)
if err != nil {
// Check if it's a PostgreSQL statement timeout error
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "57014" {
logger.Warn("Search query timed out, trying fallback strategy", map[string]interface{}{
"search_term": search,
})
// Fallback: Search only in the most relevant column (e.g., 'Uraian_1')
// We need to rebuild the filters for the fallback
var fallbackFilters []queryUtils.FilterGroup
// Add other non-search filters back (e.g., status, dinas)
for _, fg := range query.Filters {
if fg.LogicOp == "AND" {
fallbackFilters = append(fallbackFilters, fg)
}
}
// Add the single, more specific search filter
fallbackFilters = append([]queryUtils.FilterGroup{{
Filters: []queryUtils.DynamicFilter{
{Column: "Uraian_1", Operator: queryUtils.OpILike, Value: "%" + search + "%"},
},
LogicOp: "AND",
}}, fallbackFilters...)
fallbackQuery := query
fallbackQuery.Filters = fallbackFilters
// Execute the fallback query with a shorter timeout
fallbackCtx, fallbackCancel := context.WithTimeout(ctx, 10*time.Second)
defer fallbackCancel()
err = h.queryBuilder.ExecuteQuery(fallbackCtx, dbConn, fallbackQuery, &retribusis)
if err != nil {
logger.Error("Fallback search query also failed", map[string]interface{}{
"error": err.Error(),
"query": fallbackQuery,
})
// Return a more user-friendly error
return nil, 0, fmt.Errorf("search timed out. The search term '%s' is too general. Please try a more specific term", search)
}
logger.Info("Fallback search query successful", map[string]interface{}{
"recordsFetched": len(retribusis),
})
} else {
// It's a different error, handle it as before
logger.Error("Failed to execute search query", map[string]interface{}{
"error": err.Error(),
"query": query,
})
return nil, 0, fmt.Errorf("failed to execute search query: %w", err)
}
}
// Estimasi total untuk search query (tidak hitung exact count untuk performa)
total = len(retribusis)
if len(retribusis) == query.Limit {
// Jika mencapai limit, estimasi ada lebih banyak data
total = query.Offset + query.Limit + 100
} else {
total = query.Offset + len(retribusis)
}
} else {
logger.Info("Executing regular query without search")
// Untuk query tanpa search, hitung total count dengan timeout yang lebih pendek
countCtx, countCancel := context.WithTimeout(ctx, 15*time.Second)
defer countCancel()
count, err := h.queryBuilder.ExecuteCount(countCtx, dbConn, query)
if err != nil {
// Jika count gagal, fallback ke estimasi atau return error
logger.Warn("Failed to get exact count, using estimation", map[string]interface{}{"error": err.Error()})
// Untuk query tanpa search, We bisa estimasi berdasarkan limit
total = query.Offset + query.Limit + 100 // Estimasi konservatif
} else {
total = int(count)
}
logger.Info("Count query successful", map[string]interface{}{
"count": total,
})
// Eksekusi query data utama
err = h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &retribusis)
if err != nil {
logger.Error("Failed to execute main query", map[string]interface{}{
"error": err.Error(),
"query": query,
})
return nil, 0, fmt.Errorf("failed to execute main query: %w", err)
}
logger.Info("Data query successful", map[string]interface{}{
"recordsFetched": len(retribusis),
})
}
logger.Info("Query execution completed", map[string]interface{}{
"totalRecords": total,
"returnedRecords": len(retribusis),
"hasSearch": hasSearch,
})
return retribusis, total, nil
}
// getSearchTerm extracts the search term from a DynamicQuery object.
// It assumes the search is the first filter group with an "OR" logic operator.
func getSearchTerm(query queryUtils.DynamicQuery) string {
for _, filterGroup := range query.Filters {
if filterGroup.LogicOp == "OR" && len(filterGroup.Filters) > 0 {
if valueStr, ok := filterGroup.Filters[0].Value.(string); ok {
return strings.Trim(valueStr, "%")
}
}
}
return ""
}
// CHANGE: Ubah tipe parameter dari *sql.DB ke *sqlx.DB
// CHANGE: Fungsi agregasi sekarang sepenuhnya menggunakan QueryBuilder dan menghilangkan SQL manual.
func (h *RetribusiHandler) getAggregateData(ctx context.Context, dbConn *sqlx.DB, filterGroups []queryUtils.FilterGroup) (*models.AggregateData, error) {
aggregate := &models.AggregateData{
ByStatus: make(map[string]int),
ByDinas: make(map[string]int),
ByJenis: make(map[string]int),
}
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()
// CHANGE: Use context dengan timeout yang lebih pendek
queryCtx, queryCancel := context.WithTimeout(ctx, 20*time.Second)
defer queryCancel()
query := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: "status"},
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: filterGroups,
GroupBy: []string{"status"},
}
var results []struct {
Status string `db:"status"`
Count int `db:"count"`
}
err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &results)
if err != nil {
errChan <- fmt.Errorf("status query failed: %w", err)
return
}
mu.Lock()
for _, result := range results {
aggregate.ByStatus[result.Status] = result.Count
switch result.Status {
case "active":
aggregate.TotalActive = result.Count
case "draft":
aggregate.TotalDraft = result.Count
case "inactive":
aggregate.TotalInactive = result.Count
}
}
mu.Unlock()
}()
// 2. Count by Dinas
wg.Add(1)
go func() {
defer wg.Done()
// CHANGE: Use context dengan timeout yang lebih pendek
queryCtx, queryCancel := context.WithTimeout(ctx, 20*time.Second)
defer queryCancel()
query := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: `COALESCE("Dinas", 'Unknown')`, Alias: "dinas"},
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "Dinas", Operator: queryUtils.OpNotNull},
{Column: "Dinas", Operator: queryUtils.OpNotEqual, Value: ""},
},
LogicOp: "AND",
}),
GroupBy: []string{`COALESCE("Dinas", 'Unknown')`},
Sort: []queryUtils.SortField{{Column: "count", Order: "DESC"}},
Limit: 10,
}
var results []struct {
Dinas string `db:"dinas"`
Count int `db:"count"`
}
err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &results)
if err != nil {
errChan <- fmt.Errorf("dinas query failed: %w", err)
return
}
mu.Lock()
for _, result := range results {
aggregate.ByDinas[result.Dinas] = result.Count
}
mu.Unlock()
}()
// 3. Count by Jenis
wg.Add(1)
go func() {
defer wg.Done()
// CHANGE: Use context dengan timeout yang lebih pendek
queryCtx, queryCancel := context.WithTimeout(ctx, 20*time.Second)
defer queryCancel()
query := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: `COALESCE("Jenis", 'Unknown')`, Alias: "jenis"},
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "Jenis", Operator: queryUtils.OpNotNull},
{Column: "Jenis", Operator: queryUtils.OpNotEqual, Value: ""},
},
LogicOp: "AND",
}),
GroupBy: []string{`COALESCE("Jenis", 'Unknown')`},
Sort: []queryUtils.SortField{{Column: "count", Order: "DESC"}},
Limit: 10,
}
var results []struct {
Jenis string `db:"jenis"`
Count int `db:"count"`
}
err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &results)
if err != nil {
errChan <- fmt.Errorf("jenis query failed: %w", err)
return
}
mu.Lock()
for _, result := range results {
aggregate.ByJenis[result.Jenis] = result.Count
}
mu.Unlock()
}()
// 4. Get last updated and today's stats
wg.Add(1)
go func() {
defer wg.Done()
// CHANGE: Use context dengan timeout yang lebih pendek
queryCtx, queryCancel := context.WithTimeout(ctx, 20*time.Second)
defer queryCancel()
// Last updated
query1 := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{{Expression: "MAX(date_updated)"}},
Filters: filterGroups,
}
var lastUpdated sql.NullTime
err := h.queryBuilder.ExecuteQueryRow(queryCtx, dbConn, query1, &lastUpdated)
if err != nil {
errChan <- fmt.Errorf("last updated query failed: %w", err)
return
}
// CHANGE: Menggunakan QueryBuilder untuk query statistik hari ini
today := time.Now().Format("2006-01-02")
// Query untuk created_today
createdTodayQuery := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "DATE(date_created)", Operator: queryUtils.OpEqual, Value: today},
},
LogicOp: "AND",
}),
}
var createdToday int
err = h.queryBuilder.ExecuteQueryRow(queryCtx, dbConn, createdTodayQuery, &createdToday)
if err != nil {
errChan <- fmt.Errorf("created today query failed: %w", err)
return
}
// Query untuk updated_today (diupdate hari ini tetapi tidak dibuat hari ini)
updatedTodayQuery := queryUtils.DynamicQuery{
From: "data_retribusi",
Fields: []queryUtils.SelectField{
{Expression: "COUNT(*)", Alias: "count"},
},
Filters: append(filterGroups, queryUtils.FilterGroup{
Filters: []queryUtils.DynamicFilter{
{Column: "DATE(date_updated)", Operator: queryUtils.OpEqual, Value: today},
{Column: "DATE(date_created)", Operator: queryUtils.OpNotEqual, Value: today},
},
LogicOp: "AND",
}),
}
var updatedToday int
err = h.queryBuilder.ExecuteQueryRow(queryCtx, dbConn, updatedTodayQuery, &updatedToday)
if err != nil {
errChan <- fmt.Errorf("updated today query failed: %w", err)
return
}
mu.Lock()
if lastUpdated.Valid {
aggregate.LastUpdated = &lastUpdated.Time
}
aggregate.CreatedToday = createdToday
aggregate.UpdatedToday = updatedToday
mu.Unlock()
}()
wg.Wait()
close(errChan)
for err := range errChan {
if err != nil {
return nil, err
}
}
return aggregate, nil
}
// logAndRespondError logs an error and sends a JSON response
func (h *RetribusiHandler) logAndRespondError(c *gin.Context, message string, err error, statusCode int) {
logger.Error(message, map[string]interface{}{"error": err.Error(), "status_code": statusCode})
h.respondError(c, message, err, statusCode)
}
// respondError sends a standardized JSON error response
func (h *RetribusiHandler) respondError(c *gin.Context, message string, err error, statusCode int) {
errorMessage := message
if gin.Mode() == gin.ReleaseMode {
errorMessage = "Internal server error"
}
c.JSON(statusCode, models.ErrorResponse{Error: errorMessage, Code: statusCode, Message: err.Error(), Timestamp: time.Now()})
}
// calculateMeta creates pagination metadata
func (h *RetribusiHandler) calculateMeta(limit, offset, total int) models.MetaResponse {
totalPages, currentPage := 0, 1
if limit > 0 {
totalPages = (total + limit - 1) / limit
currentPage = (offset / limit) + 1
}
return models.MetaResponse{
Limit: limit, Offset: offset, Total: total, TotalPages: totalPages,
CurrentPage: currentPage, HasNext: offset+limit < total, HasPrev: offset > 0,
}
}