1421 lines
48 KiB
Go
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,
|
|
}
|
|
}
|