// internal/controllers/admin_controller.go package controllers import ( "lost-and-found/internal/repositories" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type AdminController struct { DB *gorm.DB // ✅ DITAMBAHKAN: Untuk akses transaksi manual (Begin/Commit) userService *services.UserService itemRepo *repositories.ItemRepository claimRepo *repositories.ClaimRepository archiveService *services.ArchiveService // ✅ Service Archive auditService *services.AuditService dashboardService *services.DashboardService itemService *services.ItemService } func NewAdminController(db *gorm.DB) *AdminController { return &AdminController{ DB: db, // ✅ Simpan koneksi DB userService: services.NewUserService(db), itemRepo: repositories.NewItemRepository(db), claimRepo: repositories.NewClaimRepository(db), archiveService: services.NewArchiveService(db), auditService: services.NewAuditService(db), dashboardService: services.NewDashboardService(db), itemService: services.NewItemService(db), } } // GetDashboardStats - ✅ MENGGUNAKAN VIEW vw_dashboard_stats // GET /api/admin/dashboard func (c *AdminController) GetDashboardStats(ctx *gin.Context) { // 1. Ambil stats dasar dari View dashStats, err := c.dashboardService.GetDashboardStats() if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get dashboard stats", err.Error()) return } // 2. Hitung Total User allUsers, totalUsers, _ := c.userService.GetAllUsers(1, 10000) // 3. Hitung Total Items totalItems, _ := c.itemRepo.CountAll() // 4. Hitung Total Claims totalClaims, _ := c.claimRepo.CountAll() // 5. Ambil Statistik Arsip archiveStats, err := c.archiveService.GetArchiveStats() var totalArchives int64 = 0 // Type assertion yang aman if err == nil { if val, ok := archiveStats["total"]; ok { switch v := val.(type) { case int: totalArchives = int64(v) case int64: totalArchives = v case float64: totalArchives = int64(v) } } } // 6. Ambil Category Stats categoryStats, _ := c.dashboardService.GetCategoryStats() _, totalAuditLogs, _ := c.auditService.GetAllAuditLogs(1, 1, "", "", nil) // 7. Susun Response stats := map[string]interface{}{ "items": map[string]interface{}{ "total": totalItems, "unclaimed": dashStats.TotalUnclaimed, "verified": dashStats.TotalVerified, }, "claims": map[string]interface{}{ "total": totalClaims, "pending": dashStats.PendingClaims, }, "archives": map[string]interface{}{ "total": totalArchives, }, "lost_items": map[string]interface{}{ "total": dashStats.TotalLostReports, }, "matches": map[string]interface{}{ "unnotified": dashStats.UnnotifiedMatches, }, "users": map[string]interface{}{ "total": totalUsers, "count": len(allUsers), }, "categories": categoryStats, "audit_logs": map[string]interface{}{ "total": totalAuditLogs, }, } utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats retrieved", stats) } // GetAuditLogs gets audit logs (admin only) // GET /api/admin/audit-logs func (c *AdminController) GetAuditLogs(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) action := ctx.Query("action") entityType := ctx.Query("entity_type") var userID *uint if userIDStr := ctx.Query("user_id"); userIDStr != "" { id, _ := strconv.ParseUint(userIDStr, 10, 32) userID = new(uint) *userID = uint(id) } logs, total, err := c.auditService.GetAllAuditLogs(page, limit, action, entityType, userID) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get audit logs", err.Error()) return } utils.SendPaginatedResponse(ctx, http.StatusOK, "Audit logs retrieved", logs, total, page, limit) } // GetItemsDetail - Get Items with Details (PAKAI VIEW) // GET /api/admin/items-detail func (c *AdminController) GetItemsDetail(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) status := ctx.Query("status") items, total, err := c.dashboardService.GetItemsWithDetails(page, limit, status) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items detail", err.Error()) return } utils.SendPaginatedResponse(ctx, http.StatusOK, "Items detail retrieved", items, total, page, limit) } // ========================================================================= // OPSI 1: ARSIP VIA STORED PROCEDURE (Database Logic) // ========================================================================= // TriggerAutoArchive manually triggers the cleanup procedure // POST /api/admin/archive/trigger func (c *AdminController) TriggerAutoArchive(ctx *gin.Context) { ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() // Memanggil service yang menjalankan RAW SQL "CALL sp_archive_expired_items()" count, err := c.itemService.RunAutoArchive(ipAddress, userAgent) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to run archive procedure", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Auto-archive completed", gin.H{ "archived_count": count, "method": "Stored Procedure (Database)", }) } // ========================================================================= // OPSI 2: ARSIP (Golang Logic - Begin/Commit) // ========================================================================= // ArchiveExpired melakukan arsip menggunakan Begin/Commit/Rollback di Golang // POST /api/admin/archive/manual-transaction func (c *AdminController) ArchiveExpiredItemsManual(ctx *gin.Context) { // 1. BEGIN: Memulai Transaksi tx := c.DB.Begin() if tx.Error != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to start transaction", tx.Error.Error()) return } // Safety: Defer Rollback (Jaga-jaga jika aplikasi crash/panic) defer func() { if r := recover(); r != nil { tx.Rollback() } }() copyQuery := ` INSERT INTO archives (item_id, name, description, category, found_location, found_date, image_url, finder_id) SELECT id, name, description, category, found_location, found_date, image_url, user_id FROM items WHERE expires_at < NOW() AND status = 'unclaimed' ` if err := tx.Exec(copyQuery).Error; err != nil { tx.Rollback() // ❌ ROLLBACK JIKA GAGAL COPY utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not copy to archives", err.Error()) return } // 3. LANGKAH 2: Update status barang di tabel items updateQuery := ` UPDATE items SET status = 'expired' WHERE expires_at < NOW() AND status = 'unclaimed' ` result := tx.Exec(updateQuery) if result.Error != nil { tx.Rollback() // ❌ ROLLBACK JIKA GAGAL UPDATE (Data di archives juga akan batal masuk) utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not update item status", result.Error.Error()) return } // Hitung berapa baris yang berubah itemsArchived := result.RowsAffected // 4. COMMIT: Simpan Perubahan Permanen if err := tx.Commit().Error; err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not commit changes", err.Error()) return } // Sukses utils.SuccessResponse(ctx, http.StatusOK, "Manual Archive Transaction Successful", gin.H{ "archived_count": itemsArchived, "method": "Go Transaction (Begin/Commit/Rollback)", }) } // GetFastDashboardStats uses Stored Procedure instead of View/Count // GET /api/admin/dashboard/fast func (c *AdminController) GetFastDashboardStats(ctx *gin.Context) { stats, err := c.dashboardService.GetStatsFromSP() if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats from SP", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats (SP) retrieved", stats) } // GetClaimsDetail - Get Claims with Details (PAKAI VIEW) // GET /api/admin/claims-detail func (c *AdminController) GetClaimsDetail(ctx *gin.Context) { status := ctx.Query("status") claims, err := c.dashboardService.GetClaimsWithDetails(status) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims detail", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Claims detail retrieved", claims) } // GetMatchesDetail - Get Match Results with Details (PAKAI VIEW) // GET /api/admin/matches-detail func (c *AdminController) GetMatchesDetail(ctx *gin.Context) { minScore, _ := strconv.ParseFloat(ctx.DefaultQuery("min_score", "0"), 64) matches, err := c.dashboardService.GetMatchesWithDetails(minScore) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches detail", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Matches detail retrieved", matches) } // GetUserActivity - Get User Activity (PAKAI VIEW) // GET /api/admin/user-activity func (c *AdminController) GetUserActivity(ctx *gin.Context) { limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) activities, err := c.dashboardService.GetUserActivity(limit) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get user activity", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "User activity retrieved", activities) } // GetRecentActivities - Get Recent Activities (PAKAI VIEW vw_recent_activities) // GET /api/admin/recent-activities func (c *AdminController) GetRecentActivities(ctx *gin.Context) { activities, err := c.dashboardService.GetRecentActivities() if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get recent activities", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Recent activities retrieved", activities) }