This commit is contained in:
Tomas Dvorak
2025-10-19 18:09:28 +02:00
parent abe127fb51
commit 9ccca365b3
40 changed files with 6885 additions and 20 deletions
@@ -0,0 +1,291 @@
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AuditLogController handles audit log operations
type AuditLogController struct {
DB *gorm.DB
}
// NewAuditLogController creates a new AuditLogController instance
func NewAuditLogController(db *gorm.DB) *AuditLogController {
return &AuditLogController{DB: db}
}
// AuditAction represents common audit actions
const (
AuditActionCreate = "CREATE"
AuditActionUpdate = "UPDATE"
AuditActionDelete = "DELETE"
AuditActionLogin = "LOGIN"
AuditActionLogout = "LOGOUT"
AuditActionView = "VIEW"
AuditActionExport = "EXPORT"
)
// LogEntry creates an audit log entry
func (alc *AuditLogController) LogEntry(c *gin.Context, action, entityType string, entityID *uint, description string, changes interface{}) error {
var userID *uint
if user, exists := c.Get("user"); exists {
if u, ok := user.(*models.User); ok && u != nil {
userID = &u.ID
}
}
// Serialize changes to JSON
var changesJSON string
if changes != nil {
if b, err := json.Marshal(changes); err == nil {
changesJSON = string(b)
}
}
log := models.AuditLog{
UserID: userID,
Action: action,
EntityType: entityType,
EntityID: entityID,
Description: description,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Changes: changesJSON,
CreatedAt: time.Now(),
}
if err := alc.DB.Create(&log).Error; err != nil {
logger.Error("Failed to create audit log: %v", err)
return err
}
return nil
}
// LogCreate logs a CREATE action
func (alc *AuditLogController) LogCreate(c *gin.Context, entityType string, entityID uint, description string) error {
id := entityID
return alc.LogEntry(c, AuditActionCreate, entityType, &id, description, nil)
}
// LogUpdate logs an UPDATE action with before/after changes
func (alc *AuditLogController) LogUpdate(c *gin.Context, entityType string, entityID uint, description string, before, after interface{}) error {
id := entityID
changes := map[string]interface{}{
"before": before,
"after": after,
}
return alc.LogEntry(c, AuditActionUpdate, entityType, &id, description, changes)
}
// LogDelete logs a DELETE action
func (alc *AuditLogController) LogDelete(c *gin.Context, entityType string, entityID uint, description string) error {
id := entityID
return alc.LogEntry(c, AuditActionDelete, entityType, &id, description, nil)
}
// LogLogin logs a login action
func (alc *AuditLogController) LogLogin(c *gin.Context, userID uint, success bool) error {
id := userID
description := "User logged in successfully"
if !success {
description = "Failed login attempt"
}
return alc.LogEntry(c, AuditActionLogin, "User", &id, description, nil)
}
// LogLogout logs a logout action
func (alc *AuditLogController) LogLogout(c *gin.Context, userID uint) error {
id := userID
return alc.LogEntry(c, AuditActionLogout, "User", &id, "User logged out", nil)
}
// GetAuditLogs retrieves audit logs with filtering and pagination (admin only)
func (alc *AuditLogController) GetAuditLogs(c *gin.Context) {
query := alc.DB.Model(&models.AuditLog{}).Preload("User")
// Apply filters
query = QueryParser.BuildQueryChain(c, query).
WithSearch("action", "entity_type", "description").
WithDateRange("created_at").
Build()
// Filter by action
if action := c.Query("action"); action != "" {
query = query.Where("action = ?", action)
}
// Filter by entity type
if entityType := c.Query("entity_type"); entityType != "" {
query = query.Where("entity_type = ?", entityType)
}
// Filter by user ID
if userID := c.Query("user_id"); userID != "" {
query = query.Where("user_id = ?", userID)
}
// Apply sorting (default: newest first)
query = QueryParser.ApplySortFromContext(c, query, "created_at", "desc")
// Paginate
var logs []models.AuditLog
meta, err := Paginator.Paginate(c, query, &logs)
if err != nil {
Respond.InternalError(c, "Failed to retrieve audit logs")
return
}
Respond.SuccessWithMeta(c, logs, meta, "Audit logs retrieved successfully")
}
// GetAuditLogByID retrieves a single audit log by ID (admin only)
func (alc *AuditLogController) GetAuditLogByID(c *gin.Context) {
id := c.Param("id")
var log models.AuditLog
if err := alc.DB.Preload("User").First(&log, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Audit log not found")
return
}
Respond.InternalError(c, "Failed to retrieve audit log")
return
}
Respond.Success(c, log, "Audit log retrieved successfully")
}
// GetEntityAuditHistory retrieves audit history for a specific entity
func (alc *AuditLogController) GetEntityAuditHistory(c *gin.Context) {
entityType := c.Param("entity_type")
entityID := c.Param("entity_id")
query := alc.DB.Model(&models.AuditLog{}).
Where("entity_type = ? AND entity_id = ?", entityType, entityID).
Preload("User").
Order("created_at DESC")
var logs []models.AuditLog
meta, err := Paginator.Paginate(c, query, &logs)
if err != nil {
Respond.InternalError(c, "Failed to retrieve audit history")
return
}
Respond.SuccessWithMeta(c, logs, meta, fmt.Sprintf("Audit history for %s #%s retrieved successfully", entityType, entityID))
}
// GetUserActivityLog retrieves activity log for a specific user
func (alc *AuditLogController) GetUserActivityLog(c *gin.Context) {
userID := c.Param("user_id")
query := alc.DB.Model(&models.AuditLog{}).
Where("user_id = ?", userID).
Preload("User").
Order("created_at DESC")
var logs []models.AuditLog
meta, err := Paginator.Paginate(c, query, &logs)
if err != nil {
Respond.InternalError(c, "Failed to retrieve user activity")
return
}
Respond.SuccessWithMeta(c, logs, meta, "User activity log retrieved successfully")
}
// GetAuditStats returns audit statistics (admin only)
func (alc *AuditLogController) GetAuditStats(c *gin.Context) {
var stats struct {
TotalLogs int64 `json:"total_logs"`
ActionCounts map[string]int64 `json:"action_counts"`
EntityCounts map[string]int64 `json:"entity_counts"`
RecentActions []models.AuditLog `json:"recent_actions"`
}
// Total logs
alc.DB.Model(&models.AuditLog{}).Count(&stats.TotalLogs)
// Action counts
stats.ActionCounts = make(map[string]int64)
var actionCounts []struct {
Action string `json:"action"`
Count int64 `json:"count"`
}
alc.DB.Model(&models.AuditLog{}).
Select("action, COUNT(*) as count").
Group("action").
Scan(&actionCounts)
for _, ac := range actionCounts {
stats.ActionCounts[ac.Action] = ac.Count
}
// Entity counts
stats.EntityCounts = make(map[string]int64)
var entityCounts []struct {
EntityType string `json:"entity_type"`
Count int64 `json:"count"`
}
alc.DB.Model(&models.AuditLog{}).
Select("entity_type, COUNT(*) as count").
Where("entity_type IS NOT NULL AND entity_type != ''").
Group("entity_type").
Scan(&entityCounts)
for _, ec := range entityCounts {
stats.EntityCounts[ec.EntityType] = ec.Count
}
// Recent actions
alc.DB.Model(&models.AuditLog{}).
Preload("User").
Order("created_at DESC").
Limit(10).
Find(&stats.RecentActions)
c.JSON(http.StatusOK, stats)
}
// CleanupOldLogs deletes audit logs older than specified days (admin only)
func (alc *AuditLogController) CleanupOldLogs(c *gin.Context) {
var req struct {
DaysOld int `json:"days_old" binding:"required,min=30"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: days_old must be at least 30")
return
}
cutoffDate := time.Now().AddDate(0, 0, -req.DaysOld)
result := alc.DB.Where("created_at < ?", cutoffDate).Delete(&models.AuditLog{})
if result.Error != nil {
logger.Error("Failed to cleanup old audit logs: %v", result.Error)
Respond.InternalError(c, "Failed to cleanup old audit logs")
return
}
logger.Info("Cleaned up %d audit logs older than %d days", result.RowsAffected, req.DaysOld)
Respond.Success(c, gin.H{
"deleted_count": result.RowsAffected,
"cutoff_date": cutoffDate,
}, fmt.Sprintf("Successfully deleted %d audit logs older than %d days", result.RowsAffected, req.DaysOld))
}
// Global AuditLogController instance (needs to be initialized with DB)
var AuditLogger *AuditLogController
// InitAuditLogger initializes the global audit logger
func InitAuditLogger(db *gorm.DB) {
AuditLogger = NewAuditLogController(db)
}
@@ -0,0 +1,296 @@
package controllers
import (
"fmt"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// BatchOperationsController handles bulk operations
type BatchOperationsController struct {
DB *gorm.DB
}
// NewBatchOperationsController creates a new BatchOperationsController instance
func NewBatchOperationsController(db *gorm.DB) *BatchOperationsController {
return &BatchOperationsController{DB: db}
}
// BatchDeleteRequest represents a batch delete request
type BatchDeleteRequest struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
// BatchUpdateRequest represents a batch update request
type BatchUpdateRequest struct {
IDs []uint `json:"ids" binding:"required,min=1"`
Fields map[string]interface{} `json:"fields" binding:"required"`
}
// BatchResult represents the result of a batch operation
type BatchResult struct {
Success bool `json:"success"`
TotalItems int `json:"total_items"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
Errors []string `json:"errors,omitempty"`
}
// BatchDelete performs a batch delete operation
func (boc *BatchOperationsController) BatchDelete(c *gin.Context, model interface{}, tableName string) {
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
Respond.BadRequest(c, "No IDs provided")
return
}
// Perform batch delete
result := boc.DB.Where("id IN ?", req.IDs).Delete(model)
if result.Error != nil {
logger.Error("Batch delete failed for %s: %v", tableName, result.Error)
Respond.InternalError(c, "Failed to perform batch delete")
return
}
batchResult := BatchResult{
Success: true,
TotalItems: len(req.IDs),
SuccessCount: int(result.RowsAffected),
FailureCount: len(req.IDs) - int(result.RowsAffected),
}
logger.Info("Batch deleted %d %s records", result.RowsAffected, tableName)
Respond.Success(c, batchResult, fmt.Sprintf("Successfully deleted %d items", result.RowsAffected))
}
// BatchUpdate performs a batch update operation
func (boc *BatchOperationsController) BatchUpdate(c *gin.Context, model interface{}, tableName string, allowedFields []string) {
var req BatchUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
Respond.BadRequest(c, "No IDs provided")
return
}
if len(req.Fields) == 0 {
Respond.BadRequest(c, "No fields to update")
return
}
// Validate fields
updates := make(map[string]interface{})
for field, value := range req.Fields {
allowed := false
for _, af := range allowedFields {
if field == af {
allowed = true
break
}
}
if !allowed {
Respond.BadRequest(c, fmt.Sprintf("Field '%s' is not allowed for batch update", field))
return
}
updates[field] = value
}
// Perform batch update
result := boc.DB.Model(model).Where("id IN ?", req.IDs).Updates(updates)
if result.Error != nil {
logger.Error("Batch update failed for %s: %v", tableName, result.Error)
Respond.InternalError(c, "Failed to perform batch update")
return
}
batchResult := BatchResult{
Success: true,
TotalItems: len(req.IDs),
SuccessCount: int(result.RowsAffected),
FailureCount: len(req.IDs) - int(result.RowsAffected),
}
logger.Info("Batch updated %d %s records", result.RowsAffected, tableName)
Respond.Success(c, batchResult, fmt.Sprintf("Successfully updated %d items", result.RowsAffected))
}
// BatchPublish publishes/unpublishes multiple items (for publishable entities)
func (boc *BatchOperationsController) BatchPublish(c *gin.Context, model interface{}, tableName string, publish bool) {
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
Respond.BadRequest(c, "No IDs provided")
return
}
// Update published status
result := boc.DB.Model(model).Where("id IN ?", req.IDs).Update("published", publish)
if result.Error != nil {
logger.Error("Batch publish failed for %s: %v", tableName, result.Error)
Respond.InternalError(c, "Failed to perform batch publish")
return
}
action := "published"
if !publish {
action = "unpublished"
}
batchResult := BatchResult{
Success: true,
TotalItems: len(req.IDs),
SuccessCount: int(result.RowsAffected),
FailureCount: len(req.IDs) - int(result.RowsAffected),
}
logger.Info("Batch %s %d %s records", action, result.RowsAffected, tableName)
Respond.Success(c, batchResult, fmt.Sprintf("Successfully %s %d items", action, result.RowsAffected))
}
// BatchArchive archives/unarchives multiple items (for archivable entities)
func (boc *BatchOperationsController) BatchArchive(c *gin.Context, model interface{}, tableName string, archive bool) {
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
Respond.BadRequest(c, "No IDs provided")
return
}
// Update archived status
result := boc.DB.Model(model).Where("id IN ?", req.IDs).Update("archived", archive)
if result.Error != nil {
logger.Error("Batch archive failed for %s: %v", tableName, result.Error)
Respond.InternalError(c, "Failed to perform batch archive")
return
}
action := "archived"
if !archive {
action = "unarchived"
}
batchResult := BatchResult{
Success: true,
TotalItems: len(req.IDs),
SuccessCount: int(result.RowsAffected),
FailureCount: len(req.IDs) - int(result.RowsAffected),
}
logger.Info("Batch %s %d %s records", action, result.RowsAffected, tableName)
Respond.Success(c, batchResult, fmt.Sprintf("Successfully %s %d items", action, result.RowsAffected))
}
// BatchReorder reorders items based on provided order
func (boc *BatchOperationsController) BatchReorder(c *gin.Context, model interface{}, tableName string) {
var req struct {
Orders []struct {
ID uint `json:"id" binding:"required"`
Order int `json:"order" binding:"required"`
} `json:"orders" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: "+err.Error())
return
}
// Update order for each item
successCount := 0
errors := []string{}
for _, item := range req.Orders {
result := boc.DB.Model(model).Where("id = ?", item.ID).Update("display_order", item.Order)
if result.Error != nil {
errors = append(errors, fmt.Sprintf("Failed to update order for ID %d: %v", item.ID, result.Error))
continue
}
successCount++
}
batchResult := BatchResult{
Success: len(errors) == 0,
TotalItems: len(req.Orders),
SuccessCount: successCount,
FailureCount: len(errors),
Errors: errors,
}
if len(errors) > 0 {
logger.Warn("Batch reorder partially failed for %s: %d successes, %d failures", tableName, successCount, len(errors))
Respond.Custom(c, 207, false, batchResult, "", "Batch reorder completed with errors") // 207 Multi-Status
return
}
logger.Info("Batch reordered %d %s records", successCount, tableName)
Respond.Success(c, batchResult, fmt.Sprintf("Successfully reordered %d items", successCount))
}
// BatchDuplicate duplicates multiple items
func (boc *BatchOperationsController) BatchDuplicate(c *gin.Context, tableName string, duplicateFunc func(id uint) error) {
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
Respond.BadRequest(c, "No IDs provided")
return
}
// Duplicate each item
successCount := 0
errors := []string{}
for _, id := range req.IDs {
if err := duplicateFunc(id); err != nil {
errors = append(errors, fmt.Sprintf("Failed to duplicate ID %d: %v", id, err))
continue
}
successCount++
}
batchResult := BatchResult{
Success: len(errors) == 0,
TotalItems: len(req.IDs),
SuccessCount: successCount,
FailureCount: len(errors),
Errors: errors,
}
if len(errors) > 0 {
logger.Warn("Batch duplicate partially failed for %s: %d successes, %d failures", tableName, successCount, len(errors))
Respond.Custom(c, 207, false, batchResult, "", "Batch duplicate completed with errors")
return
}
logger.Info("Batch duplicated %d %s records", successCount, tableName)
Respond.Success(c, batchResult, fmt.Sprintf("Successfully duplicated %d items", successCount))
}
// Global BatchOperationsController instance (needs to be initialized with DB)
var BatchOps *BatchOperationsController
// InitBatchOperations initializes the global batch operations controller
func InitBatchOperations(db *gorm.DB) {
BatchOps = NewBatchOperationsController(db)
}
@@ -0,0 +1,297 @@
package controllers
import (
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ExampleUsageController demonstrates how to use the new utility controllers
type ExampleUsageController struct {
DB *gorm.DB
}
// NewExampleUsageController creates a new ExampleUsageController instance
func NewExampleUsageController(db *gorm.DB) *ExampleUsageController {
return &ExampleUsageController{DB: db}
}
// ===== EXAMPLE 1: Simple List with Pagination =====
// GET /api/v1/articles?page=1&page_size=20
func (euc *ExampleUsageController) ListArticlesWithPagination(c *gin.Context) {
query := euc.DB.Model(&models.Article{}).Where("published = ?", true)
var articles []models.Article
meta, err := Paginator.Paginate(c, query, &articles)
if err != nil {
Respond.InternalError(c, "Failed to retrieve articles")
return
}
Respond.SuccessWithMeta(c, articles, meta, "Articles retrieved successfully")
}
// ===== EXAMPLE 2: List with Search, Sort, and Pagination =====
// GET /api/v1/articles?search=football&sort=created_at:desc&page=1&page_size=20
func (euc *ExampleUsageController) ListArticlesWithFilters(c *gin.Context) {
query := QueryParser.BuildQueryChain(c, euc.DB.Model(&models.Article{})).
WithSearch("title", "content").
WithSort("created_at", "desc").
Build()
var articles []models.Article
meta, err := Paginator.PaginateWithPreload(c, query, &articles, "Author", "Category")
if err != nil {
Respond.InternalError(c, "Failed to retrieve articles")
return
}
Respond.SuccessWithMeta(c, articles, meta, "Articles retrieved successfully")
}
// ===== EXAMPLE 3: Create with Validation =====
func (euc *ExampleUsageController) CreateArticleWithValidation(c *gin.Context) {
var req struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Content string `json:"content" validate:"required,min=10"`
Slug string `json:"slug" validate:"omitempty,slug"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON: "+err.Error())
return
}
// Validate using our helper
if !Validator.ValidateAndRespond(c, req) {
return // Response already sent
}
// Sanitize inputs
req.Title = Validator.SanitizeString(req.Title)
req.Slug = Validator.SanitizeSlug(req.Slug)
article := models.Article{
Title: req.Title,
Content: req.Content,
Slug: req.Slug,
}
if err := euc.DB.Create(&article).Error; err != nil {
Respond.InternalError(c, "Failed to create article")
return
}
// Log the creation
if AuditLogger != nil {
_ = AuditLogger.LogCreate(c, "Article", article.ID, "Article created: "+article.Title)
}
Respond.Created(c, article, "Article created successfully")
}
// ===== EXAMPLE 4: Update with Audit Logging =====
func (euc *ExampleUsageController) UpdateArticleWithAudit(c *gin.Context) {
id := c.Param("id")
var article models.Article
if err := euc.DB.First(&article, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Article not found")
return
}
Respond.InternalError(c, "Failed to retrieve article")
return
}
// Store old values for audit
oldTitle := article.Title
oldPublished := article.Published
var req struct {
Title string `json:"title" validate:"omitempty,min=3,max=200"`
Published *bool `json:"published"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON: "+err.Error())
return
}
if !Validator.ValidateAndRespond(c, req) {
return
}
// Apply updates
if req.Title != "" {
article.Title = Validator.SanitizeString(req.Title)
}
if req.Published != nil {
article.Published = *req.Published
}
if err := euc.DB.Save(&article).Error; err != nil {
Respond.InternalError(c, "Failed to update article")
return
}
// Log the update with changes
if AuditLogger != nil {
before := map[string]interface{}{
"title": oldTitle,
"published": oldPublished,
}
after := map[string]interface{}{
"title": article.Title,
"published": article.Published,
}
_ = AuditLogger.LogUpdate(c, "Article", article.ID, "Article updated", before, after)
}
Respond.Success(c, article, "Article updated successfully")
}
// ===== EXAMPLE 5: Batch Delete =====
// POST /api/v1/articles/batch-delete
// Body: {"ids": [1, 2, 3]}
func (euc *ExampleUsageController) BatchDeleteArticles(c *gin.Context) {
if BatchOps == nil {
BatchOps = NewBatchOperationsController(euc.DB)
}
BatchOps.BatchDelete(c, &models.Article{}, "articles")
}
// ===== EXAMPLE 6: Advanced Filtering =====
// GET /api/v1/articles?published=true&category_ids=1,2,3&from=2024-01-01&to=2024-12-31
func (euc *ExampleUsageController) ListArticlesAdvanced(c *gin.Context) {
query := QueryParser.BuildQueryChain(c, euc.DB.Model(&models.Article{})).
WithSearch("title", "content").
WithSort("created_at", "desc").
WithBoolFilter("published", "published").
WithBoolFilter("featured", "featured").
WithIDsFilter("category_ids", "category_id").
WithDateRange("created_at").
Build()
var articles []models.Article
meta, err := Paginator.PaginateWithPreload(c, query, &articles, "Author", "Category")
if err != nil {
Respond.InternalError(c, "Failed to retrieve articles")
return
}
Respond.SuccessWithMeta(c, articles, meta, "Articles retrieved successfully")
}
// ===== EXAMPLE 7: Batch Publish/Unpublish =====
// POST /api/v1/articles/batch-publish
// Body: {"ids": [1, 2, 3]}
func (euc *ExampleUsageController) BatchPublishArticles(c *gin.Context) {
if BatchOps == nil {
BatchOps = NewBatchOperationsController(euc.DB)
}
BatchOps.BatchPublish(c, &models.Article{}, "articles", true)
}
func (euc *ExampleUsageController) BatchUnpublishArticles(c *gin.Context) {
if BatchOps == nil {
BatchOps = NewBatchOperationsController(euc.DB)
}
BatchOps.BatchPublish(c, &models.Article{}, "articles", false)
}
// ===== EXAMPLE 8: Get Entity with Standard Response =====
func (euc *ExampleUsageController) GetArticle(c *gin.Context) {
id := c.Param("id")
var article models.Article
if err := euc.DB.Preload("Author").Preload("Category").First(&article, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Article not found")
return
}
Respond.InternalError(c, "Failed to retrieve article")
return
}
Respond.Success(c, article, "Article retrieved successfully")
}
// ===== EXAMPLE 9: Delete with Audit =====
func (euc *ExampleUsageController) DeleteArticleWithAudit(c *gin.Context) {
id := c.Param("id")
var article models.Article
if err := euc.DB.First(&article, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Article not found")
return
}
Respond.InternalError(c, "Failed to retrieve article")
return
}
title := article.Title
articleID := article.ID
if err := euc.DB.Delete(&article).Error; err != nil {
Respond.InternalError(c, "Failed to delete article")
return
}
// Log the deletion
if AuditLogger != nil {
_ = AuditLogger.LogDelete(c, "Article", articleID, "Article deleted: "+title)
}
Respond.NoContent(c)
}
// ===== EXAMPLE 10: Complex Query with Multiple Filters =====
func (euc *ExampleUsageController) SearchArticlesComplex(c *gin.Context) {
// Start with base query
query := euc.DB.Model(&models.Article{})
// Get search term
searchTerm := QueryParser.GetSearchQuery(c)
if searchTerm != "" {
query = QueryParser.ApplySearch(query, searchTerm, "title", "content", "seo_description")
}
// Apply boolean filters
if published := QueryParser.GetBoolQuery(c, "published"); published != nil {
query = query.Where("published = ?", *published)
}
if featured := QueryParser.GetBoolQuery(c, "featured"); featured != nil {
query = query.Where("featured = ?", *featured)
}
// Apply category filter
if categoryID := c.Query("category_id"); categoryID != "" {
query = query.Where("category_id = ?", categoryID)
}
// Apply date range
from, to := QueryParser.GetDateRange(c)
if from != "" {
query = query.Where("created_at >= ?", from)
}
if to != "" {
query = query.Where("created_at <= ?", to)
}
// Apply sorting
query = QueryParser.ApplySortFromContext(c, query, "created_at", "desc")
// Execute with pagination
var articles []models.Article
meta, err := Paginator.PaginateWithPreload(c, query, &articles, "Author", "Category")
if err != nil {
Respond.InternalError(c, "Failed to search articles")
return
}
Respond.SuccessWithMeta(c, articles, meta, "Search completed successfully")
}
+137
View File
@@ -0,0 +1,137 @@
package controllers
import (
"encoding/csv"
"encoding/json"
"fmt"
"reflect"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ExportHelper provides export utilities for CSV, JSON, etc.
type ExportHelper struct{}
// NewExportHelper creates a new ExportHelper instance
func NewExportHelper() *ExportHelper {
return &ExportHelper{}
}
// ExportToCSV exports data to CSV format
func (eh *ExportHelper) ExportToCSV(c *gin.Context, data interface{}, filename string, headers []string) error {
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
writer := csv.NewWriter(c.Writer)
defer writer.Flush()
// Write headers
if err := writer.Write(headers); err != nil {
return err
}
// Convert data to slice of records
records, err := eh.convertToCSVRecords(data)
if err != nil {
return err
}
// Write records
for _, record := range records {
if err := writer.Write(record); err != nil {
return err
}
}
return nil
}
// ExportToJSON exports data to JSON format
func (eh *ExportHelper) ExportToJSON(c *gin.Context, data interface{}, filename string) error {
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
return json.NewEncoder(c.Writer).Encode(data)
}
// convertToCSVRecords converts a slice of structs to CSV records
func (eh *ExportHelper) convertToCSVRecords(data interface{}) ([][]string, error) {
v := reflect.ValueOf(data)
if v.Kind() != reflect.Slice {
return nil, fmt.Errorf("data must be a slice")
}
records := make([][]string, 0, v.Len())
for i := 0; i < v.Len(); i++ {
item := v.Index(i)
if item.Kind() == reflect.Ptr {
item = item.Elem()
}
record := make([]string, 0, item.NumField())
for j := 0; j < item.NumField(); j++ {
field := item.Field(j)
record = append(record, eh.formatFieldValue(field))
}
records = append(records, record)
}
return records, nil
}
// formatFieldValue formats a field value for CSV export
func (eh *ExportHelper) formatFieldValue(v reflect.Value) string {
switch v.Kind() {
case reflect.String:
return v.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(v.Uint(), 10)
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(v.Float(), 'f', -1, 64)
case reflect.Bool:
return strconv.FormatBool(v.Bool())
case reflect.Struct:
// Handle time.Time
if t, ok := v.Interface().(time.Time); ok {
return t.Format(time.RFC3339)
}
return fmt.Sprintf("%v", v.Interface())
case reflect.Ptr:
if v.IsNil() {
return ""
}
return eh.formatFieldValue(v.Elem())
default:
return fmt.Sprintf("%v", v.Interface())
}
}
// ImportFromCSV imports data from CSV file
func (eh *ExportHelper) ImportFromCSV(c *gin.Context, formFieldName string) ([][]string, error) {
file, err := c.FormFile(formFieldName)
if err != nil {
return nil, err
}
f, err := file.Open()
if err != nil {
return nil, err
}
defer f.Close()
reader := csv.NewReader(f)
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
return records, nil
}
// Global ExportHelper instance
var Exporter = NewExportHelper()
+121
View File
@@ -0,0 +1,121 @@
package controllers
import (
"math"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PaginationHelper provides pagination utilities
type PaginationHelper struct{}
// PaginationParams represents pagination parameters
type PaginationParams struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Offset int `json:"-"`
}
// PaginationMeta represents pagination metadata
type PaginationMeta struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
// NewPaginationHelper creates a new PaginationHelper instance
func NewPaginationHelper() *PaginationHelper {
return &PaginationHelper{}
}
// GetPaginationParams extracts pagination parameters from the request
// Defaults: page=1, pageSize=20
func (ph *PaginationHelper) GetPaginationParams(c *gin.Context) PaginationParams {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
// Validate and constrain values
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100 // Max page size to prevent abuse
}
offset := (page - 1) * pageSize
return PaginationParams{
Page: page,
PageSize: pageSize,
Offset: offset,
}
}
// BuildPaginationMeta creates pagination metadata
func (ph *PaginationHelper) BuildPaginationMeta(params PaginationParams, total int64) PaginationMeta {
totalPages := int(math.Ceil(float64(total) / float64(params.PageSize)))
return PaginationMeta{
Page: params.Page,
PageSize: params.PageSize,
Total: total,
TotalPages: totalPages,
HasNext: params.Page < totalPages,
HasPrev: params.Page > 1,
}
}
// Paginate applies pagination to a GORM query and returns the paginated results with metadata
func (ph *PaginationHelper) Paginate(c *gin.Context, db *gorm.DB, dest interface{}) (PaginationMeta, error) {
params := ph.GetPaginationParams(c)
// Count total records
var total int64
if err := db.Count(&total).Error; err != nil {
return PaginationMeta{}, err
}
// Apply pagination
if err := db.Offset(params.Offset).Limit(params.PageSize).Find(dest).Error; err != nil {
return PaginationMeta{}, err
}
meta := ph.BuildPaginationMeta(params, total)
return meta, nil
}
// PaginateWithPreload applies pagination with preloading associations
func (ph *PaginationHelper) PaginateWithPreload(c *gin.Context, db *gorm.DB, dest interface{}, preloads ...string) (PaginationMeta, error) {
params := ph.GetPaginationParams(c)
// Count total records (without preload)
var total int64
if err := db.Count(&total).Error; err != nil {
return PaginationMeta{}, err
}
// Apply preloads
query := db
for _, preload := range preloads {
query = query.Preload(preload)
}
// Apply pagination
if err := query.Offset(params.Offset).Limit(params.PageSize).Find(dest).Error; err != nil {
return PaginationMeta{}, err
}
meta := ph.BuildPaginationMeta(params, total)
return meta, nil
}
// Global PaginationHelper instance for convenience
var Paginator = NewPaginationHelper()
@@ -0,0 +1,425 @@
package controllers
import (
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// PollControllerRefactored demonstrates refactored poll controller using utility helpers
// This is an example showing how to use the new utilities
type PollControllerRefactored struct {
DB *gorm.DB
}
// NewPollControllerRefactored creates a new refactored poll controller
func NewPollControllerRefactored(db *gorm.DB) *PollControllerRefactored {
return &PollControllerRefactored{DB: db}
}
// GetPolls returns paginated list of polls with filtering and search
// GET /api/v1/polls?search=election&active=true&sort=created_at:desc&page=1&page_size=20
func (pcr *PollControllerRefactored) GetPolls(c *gin.Context) {
// Build query with search, filter, and sort
query := QueryParser.BuildQueryChain(c, pcr.DB.Model(&models.Poll{})).
WithSearch("title", "description").
WithSort("created_at", "desc").
WithBoolFilter("active", "active").
Build()
// Paginate and preload relationships
var polls []models.Poll
meta, err := Paginator.PaginateWithPreload(c, query, &polls, "Options")
if err != nil {
Respond.InternalError(c, "Failed to retrieve polls")
return
}
Respond.SuccessWithMeta(c, polls, meta, "Polls retrieved successfully")
}
// GetPoll returns a single poll by ID
// GET /api/v1/polls/:id
func (pcr *PollControllerRefactored) GetPoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pcr.DB.Preload("Options").First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Poll not found")
return
}
Respond.InternalError(c, "Failed to retrieve poll")
return
}
Respond.Success(c, poll, "Poll retrieved successfully")
}
// CreatePoll creates a new poll with validation
// POST /api/v1/polls
func (pcr *PollControllerRefactored) CreatePoll(c *gin.Context) {
type CreatePollRequest struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Description string `json:"description" validate:"omitempty,max=500"`
Active *bool `json:"active"`
MultiChoice *bool `json:"multi_choice"`
Options []string `json:"options" validate:"required,min=2"`
}
var req CreatePollRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON: "+err.Error())
return
}
// Validate request
if !Validator.ValidateAndRespond(c, req) {
return
}
// Sanitize inputs
req.Title = Validator.SanitizeString(req.Title)
req.Description = Validator.SanitizeString(req.Description)
// Set defaults
active := true
if req.Active != nil {
active = *req.Active
}
multiChoice := false
if req.MultiChoice != nil {
multiChoice = *req.MultiChoice
}
status := "draft"
if active {
status = "active"
}
maxChoices := 1
if multiChoice {
maxChoices = len(req.Options)
if maxChoices == 0 {
maxChoices = 1
}
}
// Create poll
poll := models.Poll{
Title: req.Title,
Description: req.Description,
Status: status,
AllowMultiple: multiChoice,
MaxChoices: maxChoices,
}
if err := pcr.DB.Create(&poll).Error; err != nil {
Respond.InternalError(c, "Failed to create poll")
return
}
// Create options
for _, optionText := range req.Options {
option := models.PollOption{
PollID: poll.ID,
Text: Validator.SanitizeString(optionText),
}
if err := pcr.DB.Create(&option).Error; err != nil {
Respond.InternalError(c, "Failed to create poll option")
return
}
}
// Reload with options
pcr.DB.Preload("Options").First(&poll, poll.ID)
// Log creation
if AuditLogger != nil {
_ = AuditLogger.LogCreate(c, "Poll", poll.ID, "Poll created: "+poll.Title)
}
Respond.Created(c, poll, "Poll created successfully")
}
// UpdatePoll updates an existing poll
// PUT /api/v1/polls/:id
func (pcr *PollControllerRefactored) UpdatePoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pcr.DB.First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Poll not found")
return
}
Respond.InternalError(c, "Failed to retrieve poll")
return
}
// Store old values for audit
oldTitle := poll.Title
oldStatus := poll.Status
type UpdatePollRequest struct {
Title string `json:"title" validate:"omitempty,min=3,max=200"`
Description string `json:"description" validate:"omitempty,max=500"`
Active *bool `json:"active"`
MultiChoice *bool `json:"multi_choice"`
}
var req UpdatePollRequest
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON: "+err.Error())
return
}
if !Validator.ValidateAndRespond(c, req) {
return
}
// Apply updates
if req.Title != "" {
poll.Title = Validator.SanitizeString(req.Title)
}
if req.Description != "" {
poll.Description = Validator.SanitizeString(req.Description)
}
if req.Active != nil {
if *req.Active {
poll.Status = "active"
} else {
poll.Status = "draft"
}
}
if req.MultiChoice != nil {
poll.AllowMultiple = *req.MultiChoice
if poll.AllowMultiple && poll.MaxChoices < 1 {
poll.MaxChoices = len(poll.Options)
if poll.MaxChoices == 0 {
poll.MaxChoices = 1
}
}
if !poll.AllowMultiple {
poll.MaxChoices = 1
}
}
if err := pcr.DB.Save(&poll).Error; err != nil {
Respond.InternalError(c, "Failed to update poll")
return
}
// Log update
if AuditLogger != nil {
before := map[string]interface{}{
"title": oldTitle,
"status": oldStatus,
}
after := map[string]interface{}{
"title": poll.Title,
"status": poll.Status,
}
_ = AuditLogger.LogUpdate(c, "Poll", poll.ID, "Poll updated", before, after)
}
Respond.Success(c, poll, "Poll updated successfully")
}
// DeletePoll deletes a poll
// DELETE /api/v1/polls/:id
func (pcr *PollControllerRefactored) DeletePoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pcr.DB.First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Poll not found")
return
}
Respond.InternalError(c, "Failed to retrieve poll")
return
}
title := poll.Title
pollID := poll.ID
// Delete associated options first
if err := pcr.DB.Where("poll_id = ?", poll.ID).Delete(&models.PollOption{}).Error; err != nil {
Respond.InternalError(c, "Failed to delete poll options")
return
}
// Delete poll
if err := pcr.DB.Delete(&poll).Error; err != nil {
Respond.InternalError(c, "Failed to delete poll")
return
}
// Log deletion
if AuditLogger != nil {
_ = AuditLogger.LogDelete(c, "Poll", pollID, "Poll deleted: "+title)
}
Respond.NoContent(c)
}
// BatchDeletePolls deletes multiple polls
// POST /api/v1/polls/batch-delete
// Body: {"ids": [1, 2, 3]}
func (pcr *PollControllerRefactored) BatchDeletePolls(c *gin.Context) {
if BatchOps == nil {
BatchOps = NewBatchOperationsController(pcr.DB)
}
BatchOps.BatchDelete(c, &models.Poll{}, "polls")
}
// BatchActivatePolls activates multiple polls
// POST /api/v1/polls/batch-activate
// Body: {"ids": [1, 2, 3]}
func (pcr *PollControllerRefactored) BatchActivatePolls(c *gin.Context) {
if BatchOps == nil {
BatchOps = NewBatchOperationsController(pcr.DB)
}
BatchOps.BatchUpdate(c, &models.Poll{}, "polls", []string{"active"})
}
// VotePoll records a vote on a poll
// POST /api/v1/polls/:id/vote
// Body: {"option_id": 1} or {"option_ids": [1, 2]} for multi-choice
func (pcr *PollControllerRefactored) VotePoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pcr.DB.Preload("Options").First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Poll not found")
return
}
Respond.InternalError(c, "Failed to retrieve poll")
return
}
if !poll.IsActive() {
Respond.BadRequest(c, "This poll is not active")
return
}
var req struct {
OptionID *uint `json:"option_id"`
OptionIDs []uint `json:"option_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON: "+err.Error())
return
}
// Determine which options to vote for
var optionIDs []uint
if req.OptionID != nil {
optionIDs = []uint{*req.OptionID}
} else if len(req.OptionIDs) > 0 {
optionIDs = req.OptionIDs
} else {
Respond.BadRequest(c, "No option selected")
return
}
// Validate multi-choice
if !poll.AllowMultiple && len(optionIDs) > 1 {
Respond.BadRequest(c, "This poll does not allow multiple choices")
return
}
if poll.AllowMultiple && poll.MaxChoices > 0 && len(optionIDs) > poll.MaxChoices {
Respond.BadRequest(c, "You have selected too many options")
return
}
// Get user ID (if authenticated)
var userID *uint
if user, exists := c.Get("user"); exists {
if u, ok := user.(*models.User); ok && u != nil {
userID = &u.ID
}
}
// Check if already voted (if user is authenticated)
if userID != nil {
var existingVote models.PollVote
if err := pcr.DB.Where("poll_id = ? AND user_id = ?", poll.ID, userID).First(&existingVote).Error; err == nil {
Respond.Conflict(c, "You have already voted on this poll")
return
}
}
// Record votes
for _, optionID := range optionIDs {
vote := models.PollVote{
PollID: poll.ID,
OptionID: optionID,
UserID: userID,
}
if err := pcr.DB.Create(&vote).Error; err != nil {
Respond.InternalError(c, "Failed to record vote")
return
}
}
// Log vote
if AuditLogger != nil {
_ = AuditLogger.LogEntry(c, "VOTE", "Poll", &poll.ID, "Vote recorded on poll: "+poll.Title, nil)
}
Respond.Success(c, gin.H{"voted": true}, "Vote recorded successfully")
}
// GetPollResults returns poll results
// GET /api/v1/polls/:id/results
func (pcr *PollControllerRefactored) GetPollResults(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pcr.DB.Preload("Options").First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Poll not found")
return
}
Respond.InternalError(c, "Failed to retrieve poll")
return
}
// Count votes for each option
type OptionResult struct {
OptionID uint `json:"option_id"`
OptionText string `json:"option_text"`
VoteCount int64 `json:"vote_count"`
}
results := make([]OptionResult, 0, len(poll.Options))
var totalVotes int64
for _, option := range poll.Options {
var count int64
pcr.DB.Model(&models.PollVote{}).Where("option_id = ?", option.ID).Count(&count)
results = append(results, OptionResult{
OptionID: option.ID,
OptionText: option.Text,
VoteCount: count,
})
totalVotes += count
}
response := gin.H{
"poll_id": poll.ID,
"poll_title": poll.Title,
"total_votes": totalVotes,
"results": results,
}
Respond.Success(c, response, "Poll results retrieved successfully")
}
+237
View File
@@ -0,0 +1,237 @@
package controllers
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// QueryHelper provides query parameter parsing and filtering utilities
type QueryHelper struct{}
// SortParams represents sorting parameters
type SortParams struct {
Field string
Order string // "asc" or "desc"
}
// FilterParams represents generic filter parameters
type FilterParams map[string]interface{}
// NewQueryHelper creates a new QueryHelper instance
func NewQueryHelper() *QueryHelper {
return &QueryHelper{}
}
// GetSortParams extracts sort parameters from the request
// Expects: ?sort=field:order (e.g., ?sort=created_at:desc)
func (qh *QueryHelper) GetSortParams(c *gin.Context, defaultField, defaultOrder string) SortParams {
sortQuery := c.Query("sort")
if sortQuery == "" {
return SortParams{
Field: defaultField,
Order: defaultOrder,
}
}
parts := strings.Split(sortQuery, ":")
field := parts[0]
order := defaultOrder
if len(parts) > 1 {
order = strings.ToLower(parts[1])
if order != "asc" && order != "desc" {
order = defaultOrder
}
}
return SortParams{
Field: field,
Order: order,
}
}
// ApplySort applies sorting to a GORM query
func (qh *QueryHelper) ApplySort(db *gorm.DB, params SortParams) *gorm.DB {
if params.Field != "" {
return db.Order(params.Field + " " + params.Order)
}
return db
}
// ApplySortFromContext extracts sort params from context and applies them
func (qh *QueryHelper) ApplySortFromContext(c *gin.Context, db *gorm.DB, defaultField, defaultOrder string) *gorm.DB {
params := qh.GetSortParams(c, defaultField, defaultOrder)
return qh.ApplySort(db, params)
}
// GetSearchQuery extracts search query from request
// Expects: ?search=term or ?q=term
func (qh *QueryHelper) GetSearchQuery(c *gin.Context) string {
search := c.Query("search")
if search == "" {
search = c.Query("q")
}
return strings.TrimSpace(search)
}
// ApplySearch applies search to specified fields using LIKE
func (qh *QueryHelper) ApplySearch(db *gorm.DB, searchTerm string, fields ...string) *gorm.DB {
if searchTerm == "" || len(fields) == 0 {
return db
}
searchPattern := "%" + searchTerm + "%"
query := db
// Build OR conditions for each field
for i, field := range fields {
if i == 0 {
query = query.Where(field+" LIKE ?", searchPattern)
} else {
query = query.Or(field+" LIKE ?", searchPattern)
}
}
return query
}
// ApplySearchFromContext extracts search query and applies it
func (qh *QueryHelper) ApplySearchFromContext(c *gin.Context, db *gorm.DB, fields ...string) *gorm.DB {
searchTerm := qh.GetSearchQuery(c)
return qh.ApplySearch(db, searchTerm, fields...)
}
// GetBoolQuery extracts a boolean query parameter
func (qh *QueryHelper) GetBoolQuery(c *gin.Context, key string) *bool {
value := c.Query(key)
if value == "" {
return nil
}
boolValue := strings.ToLower(value) == "true" || value == "1"
return &boolValue
}
// ApplyBoolFilter applies a boolean filter if the parameter exists
func (qh *QueryHelper) ApplyBoolFilter(db *gorm.DB, c *gin.Context, paramKey, dbField string) *gorm.DB {
value := qh.GetBoolQuery(c, paramKey)
if value != nil {
return db.Where(dbField+" = ?", *value)
}
return db
}
// GetIDsFromQuery extracts comma-separated IDs from query parameter
// Expects: ?ids=1,2,3,4
func (qh *QueryHelper) GetIDsFromQuery(c *gin.Context, key string) []uint {
idsStr := c.Query(key)
if idsStr == "" {
return nil
}
parts := strings.Split(idsStr, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Parse as uint
var id uint
if _, err := fmt.Sscanf(part, "%d", &id); err == nil {
ids = append(ids, id)
}
}
return ids
}
// ApplyIDsFilter applies IN filter for IDs
func (qh *QueryHelper) ApplyIDsFilter(db *gorm.DB, c *gin.Context, paramKey, dbField string) *gorm.DB {
ids := qh.GetIDsFromQuery(c, paramKey)
if len(ids) > 0 {
return db.Where(dbField+" IN ?", ids)
}
return db
}
// GetDateRange extracts date range from query parameters
// Expects: ?from=2024-01-01&to=2024-12-31
func (qh *QueryHelper) GetDateRange(c *gin.Context) (from, to string) {
from = c.Query("from")
to = c.Query("to")
return
}
// ApplyDateRangeFilter applies date range filter
func (qh *QueryHelper) ApplyDateRangeFilter(db *gorm.DB, c *gin.Context, dbField string) *gorm.DB {
from, to := qh.GetDateRange(c)
if from != "" {
db = db.Where(dbField+" >= ?", from)
}
if to != "" {
db = db.Where(dbField+" <= ?", to)
}
return db
}
// BuildQueryChain combines multiple query operations
func (qh *QueryHelper) BuildQueryChain(c *gin.Context, db *gorm.DB) *QueryChainBuilder {
return &QueryChainBuilder{
ctx: c,
query: db,
qh: qh,
}
}
// QueryChainBuilder provides a fluent interface for building queries
type QueryChainBuilder struct {
ctx *gin.Context
query *gorm.DB
qh *QueryHelper
}
// WithSort adds sorting
func (qcb *QueryChainBuilder) WithSort(defaultField, defaultOrder string) *QueryChainBuilder {
qcb.query = qcb.qh.ApplySortFromContext(qcb.ctx, qcb.query, defaultField, defaultOrder)
return qcb
}
// WithSearch adds search across fields
func (qcb *QueryChainBuilder) WithSearch(fields ...string) *QueryChainBuilder {
qcb.query = qcb.qh.ApplySearchFromContext(qcb.ctx, qcb.query, fields...)
return qcb
}
// WithBoolFilter adds a boolean filter
func (qcb *QueryChainBuilder) WithBoolFilter(paramKey, dbField string) *QueryChainBuilder {
qcb.query = qcb.qh.ApplyBoolFilter(qcb.query, qcb.ctx, paramKey, dbField)
return qcb
}
// WithIDsFilter adds an IDs filter
func (qcb *QueryChainBuilder) WithIDsFilter(paramKey, dbField string) *QueryChainBuilder {
qcb.query = qcb.qh.ApplyIDsFilter(qcb.query, qcb.ctx, paramKey, dbField)
return qcb
}
// WithDateRange adds date range filter
func (qcb *QueryChainBuilder) WithDateRange(dbField string) *QueryChainBuilder {
qcb.query = qcb.qh.ApplyDateRangeFilter(qcb.query, qcb.ctx, dbField)
return qcb
}
// Build returns the final query
func (qcb *QueryChainBuilder) Build() *gorm.DB {
return qcb.query
}
// Global QueryHelper instance for convenience
var QueryParser = NewQueryHelper()
+132
View File
@@ -0,0 +1,132 @@
package controllers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// ResponseHelper provides standardized API response methods
type ResponseHelper struct{}
// Response represents a standard API response
type Response struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Meta interface{} `json:"meta,omitempty"`
}
// NewResponseHelper creates a new ResponseHelper instance
func NewResponseHelper() *ResponseHelper {
return &ResponseHelper{}
}
// Success sends a successful response
func (rh *ResponseHelper) Success(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusOK, Response{
Success: true,
Message: message,
Data: data,
})
}
// SuccessWithMeta sends a successful response with metadata (e.g., pagination)
func (rh *ResponseHelper) SuccessWithMeta(c *gin.Context, data interface{}, meta interface{}, message string) {
c.JSON(http.StatusOK, Response{
Success: true,
Message: message,
Data: data,
Meta: meta,
})
}
// Created sends a 201 Created response
func (rh *ResponseHelper) Created(c *gin.Context, data interface{}, message string) {
c.JSON(http.StatusCreated, Response{
Success: true,
Message: message,
Data: data,
})
}
// NoContent sends a 204 No Content response
func (rh *ResponseHelper) NoContent(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// BadRequest sends a 400 Bad Request response
func (rh *ResponseHelper) BadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, Response{
Success: false,
Error: message,
})
}
// Unauthorized sends a 401 Unauthorized response
func (rh *ResponseHelper) Unauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, Response{
Success: false,
Error: message,
})
}
// Forbidden sends a 403 Forbidden response
func (rh *ResponseHelper) Forbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, Response{
Success: false,
Error: message,
})
}
// NotFound sends a 404 Not Found response
func (rh *ResponseHelper) NotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, Response{
Success: false,
Error: message,
})
}
// Conflict sends a 409 Conflict response
func (rh *ResponseHelper) Conflict(c *gin.Context, message string) {
c.JSON(http.StatusConflict, Response{
Success: false,
Error: message,
})
}
// InternalError sends a 500 Internal Server Error response
func (rh *ResponseHelper) InternalError(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, Response{
Success: false,
Error: message,
})
}
// ValidationError sends a 422 Unprocessable Entity response
func (rh *ResponseHelper) ValidationError(c *gin.Context, errors interface{}) {
c.JSON(http.StatusUnprocessableEntity, Response{
Success: false,
Error: "Validation failed",
Data: errors,
})
}
// Custom sends a custom status code response
func (rh *ResponseHelper) Custom(c *gin.Context, statusCode int, success bool, data interface{}, message string, errMsg string) {
response := Response{
Success: success,
Data: data,
}
if message != "" {
response.Message = message
}
if errMsg != "" {
response.Error = errMsg
}
c.JSON(statusCode, response)
}
// Global ResponseHelper instance for convenience
var Respond = NewResponseHelper()
+171
View File
@@ -0,0 +1,171 @@
package controllers
import (
"fmt"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// ValidationHelper provides validation utilities
type ValidationHelper struct {
validator *validator.Validate
}
// ValidationError represents a validation error
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
Tag string `json:"tag"`
}
// NewValidationHelper creates a new ValidationHelper instance
func NewValidationHelper() *ValidationHelper {
v := validator.New()
// Register custom validators
_ = v.RegisterValidation("slug", validateSlug)
_ = v.RegisterValidation("color", validateColor)
return &ValidationHelper{
validator: v,
}
}
// Validate validates a struct and returns user-friendly errors
func (vh *ValidationHelper) Validate(data interface{}) []ValidationError {
err := vh.validator.Struct(data)
if err == nil {
return nil
}
var validationErrors []ValidationError
for _, err := range err.(validator.ValidationErrors) {
validationErrors = append(validationErrors, ValidationError{
Field: strings.ToLower(err.Field()),
Message: vh.getErrorMessage(err),
Tag: err.Tag(),
})
}
return validationErrors
}
// ValidateAndRespond validates data and sends error response if invalid
// Returns true if valid, false if invalid (and response already sent)
func (vh *ValidationHelper) ValidateAndRespond(c *gin.Context, data interface{}) bool {
errors := vh.Validate(data)
if len(errors) > 0 {
Respond.ValidationError(c, errors)
return false
}
return true
}
// getErrorMessage returns a user-friendly error message for a validation error
func (vh *ValidationHelper) getErrorMessage(err validator.FieldError) string {
field := err.Field()
switch err.Tag() {
case "required":
return fmt.Sprintf("%s is required", field)
case "email":
return fmt.Sprintf("%s must be a valid email address", field)
case "min":
return fmt.Sprintf("%s must be at least %s characters", field, err.Param())
case "max":
return fmt.Sprintf("%s must be at most %s characters", field, err.Param())
case "len":
return fmt.Sprintf("%s must be exactly %s characters", field, err.Param())
case "url":
return fmt.Sprintf("%s must be a valid URL", field)
case "slug":
return fmt.Sprintf("%s must be a valid slug (lowercase, alphanumeric, hyphens)", field)
case "color":
return fmt.Sprintf("%s must be a valid hex color code", field)
case "oneof":
return fmt.Sprintf("%s must be one of: %s", field, err.Param())
case "gte":
return fmt.Sprintf("%s must be greater than or equal to %s", field, err.Param())
case "lte":
return fmt.Sprintf("%s must be less than or equal to %s", field, err.Param())
case "gt":
return fmt.Sprintf("%s must be greater than %s", field, err.Param())
case "lt":
return fmt.Sprintf("%s must be less than %s", field, err.Param())
default:
return fmt.Sprintf("%s is invalid", field)
}
}
// IsValidEmail checks if a string is a valid email
func (vh *ValidationHelper) IsValidEmail(email string) bool {
return vh.validator.Var(email, "required,email") == nil
}
// IsValidURL checks if a string is a valid URL
func (vh *ValidationHelper) IsValidURL(url string) bool {
return vh.validator.Var(url, "required,url") == nil
}
// IsValidSlug checks if a string is a valid slug
func (vh *ValidationHelper) IsValidSlug(slug string) bool {
return vh.validator.Var(slug, "required,slug") == nil
}
// SanitizeString removes leading/trailing whitespace and normalizes spaces
func (vh *ValidationHelper) SanitizeString(s string) string {
// Trim whitespace
s = strings.TrimSpace(s)
// Replace multiple spaces with single space
s = regexp.MustCompile(`\s+`).ReplaceAllString(s, " ")
return s
}
// SanitizeEmail normalizes an email address
func (vh *ValidationHelper) SanitizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// SanitizeSlug normalizes a slug
func (vh *ValidationHelper) SanitizeSlug(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
// Replace spaces with hyphens
slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-")
// Remove non-alphanumeric characters except hyphens
slug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "")
// Replace multiple hyphens with single hyphen
slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
// Remove leading/trailing hyphens
slug = strings.Trim(slug, "-")
return slug
}
// Custom validator functions
// validateSlug validates that a string is a valid slug
func validateSlug(fl validator.FieldLevel) bool {
slug := fl.Field().String()
if slug == "" {
return true // Let 'required' handle empty values
}
// Valid slug: lowercase alphanumeric and hyphens, no leading/trailing hyphens
matched, _ := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, slug)
return matched
}
// validateColor validates that a string is a valid hex color code
func validateColor(fl validator.FieldLevel) bool {
color := fl.Field().String()
if color == "" {
return true // Let 'required' handle empty values
}
// Valid color: #RGB, #RRGGBB, #RGBA, #RRGGBBAA
matched, _ := regexp.MatchString(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{8})$`, color)
return matched
}
// Global ValidationHelper instance for convenience
var Validator = NewValidationHelper()