mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #65
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user