Initial commit - Lost and Found Revisi

This commit is contained in:
bambang-code1
2025-12-20 00:01:08 +07:00
commit f4c838471c
210 changed files with 30706 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
// 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)
}
+249
View File
@@ -0,0 +1,249 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"lost-and-found/internal/utils"
"net/http"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type FrontendMessage struct {
Text string `json:"text"`
Sender string `json:"sender"`
}
type AIController struct {
DB *gorm.DB
}
func NewAIController(db *gorm.DB) *AIController {
return &AIController{DB: db}
}
// Groq API structures
type GroqMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type GroqRequest struct {
Model string `json:"model"`
Messages []GroqMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
}
type GroqResponse struct {
ID string `json:"id"`
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func (c *AIController) Chat(ctx *gin.Context) {
var request struct {
Message string `json:"message" binding:"required"`
History []FrontendMessage `json:"history"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request", err.Error())
return
}
// Get Groq API key from environment
apiKey := os.Getenv("GROQ_API_KEY")
if apiKey == "" {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Groq API key not configured", "")
return
}
// Get model from environment or use default
model := os.Getenv("GROQ_MODEL")
if model == "" {
model = "llama-3.3-70b-versatile" // Default to best model
}
// System prompt for Lost & Found context
systemPrompt := `Kamu adalah AI Assistant untuk sistem Lost & Found (Barang Hilang & Temuan) bernama "FindItBot".
Konteks Sistem:
- Sistem ini membantu mahasiswa dan staff melaporkan barang hilang dan menemukan barang
- User bisa melaporkan barang hilang mereka
- User bisa melaporkan barang yang mereka temukan
- User bisa klaim barang yang hilang
- Manager dan Admin memverifikasi klaim
Tugasmu:
1. 🔍 Jawab pertanyaan tentang cara menggunakan sistem
2. 📝 Bantu user memahami proses pelaporan dan klaim
3. ✅ Berikan informasi yang jelas dan membantu
4. 💬 Gunakan bahasa Indonesia yang ramah dan profesional
5. 🎯 Fokus pada solusi praktis
Panduan Respons:
- Gunakan emoji yang relevan
- Jawab dengan singkat tapi lengkap
- Jika user mencari barang, tanyakan detail spesifik
- Jika user ingin lapor kehilangan, tanyakan: nama barang, kategori, lokasi, tanggal hilang
- Jika user ingin klaim, jelaskan proses verifikasi
Jawab dengan helpful dan supportive!`
// Build messages array for Groq
groqMessages := []GroqMessage{
{
Role: "system",
Content: systemPrompt,
},
}
// Add conversation history
for _, msg := range request.History {
role := "user"
if msg.Sender == "ai" {
role = "assistant"
}
groqMessages = append(groqMessages, GroqMessage{
Role: role,
Content: msg.Text,
})
}
// Add current user message
groqMessages = append(groqMessages, GroqMessage{
Role: "user",
Content: request.Message,
})
// Prepare Groq request
groqReq := GroqRequest{
Model: model,
Messages: groqMessages,
Temperature: 0.7,
MaxTokens: 1024,
TopP: 0.95,
Stream: false,
}
// Make API call to Groq
groqURL := "https://api.groq.com/openai/v1/chat/completions"
jsonData, err := json.Marshal(groqReq)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to prepare request", err.Error())
return
}
req, err := http.NewRequest("POST", groqURL, bytes.NewBuffer(jsonData))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to create request", err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to call Groq API", err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to read response", err.Error())
return
}
if resp.StatusCode != http.StatusOK {
utils.ErrorResponse(ctx, http.StatusInternalServerError,
fmt.Sprintf("Groq API error (status %d)", resp.StatusCode), string(body))
return
}
var groqResp GroqResponse
if err := json.Unmarshal(body, &groqResp); err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to parse response", err.Error())
return
}
// Extract AI response
aiResponse := "Maaf, tidak dapat memproses permintaan Anda."
if len(groqResp.Choices) > 0 {
aiResponse = groqResp.Choices[0].Message.Content
}
response := AIMessage{
Role: "assistant",
Content: aiResponse,
}
utils.SuccessResponse(ctx, http.StatusOK, "AI response generated", gin.H{
"message": response,
"response": aiResponse,
"model": model,
"usage": gin.H{
"prompt_tokens": groqResp.Usage.PromptTokens,
"completion_tokens": groqResp.Usage.CompletionTokens,
"total_tokens": groqResp.Usage.TotalTokens,
},
})
}
func (c *AIController) GetHistory(ctx *gin.Context) {
userID, exists := ctx.Get("user_id")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not authenticated", "")
return
}
// TODO: Implement actual history retrieval from database
history := []AIMessage{}
utils.SuccessResponse(ctx, http.StatusOK, "Chat history retrieved", gin.H{
"user_id": userID,
"history": history,
"count": len(history),
})
}
func (c *AIController) ClearHistory(ctx *gin.Context) {
userID, exists := ctx.Get("user_id")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not authenticated", "")
return
}
// TODO: Implement actual history clearing from database
utils.SuccessResponse(ctx, http.StatusOK, "Chat history cleared", gin.H{
"user_id": userID,
"message": "History successfully deleted",
})
}
@@ -0,0 +1,69 @@
// internal/controllers/archive_controller.go
package controllers
import (
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ArchiveController struct {
archiveService *services.ArchiveService
}
func NewArchiveController(db *gorm.DB) *ArchiveController {
return &ArchiveController{
archiveService: services.NewArchiveService(db),
}
}
// GetAllArchives gets all archived items
// GET /api/archives
func (c *ArchiveController) GetAllArchives(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
reason := ctx.Query("reason")
search := ctx.Query("search")
archives, total, err := c.archiveService.GetAllArchives(page, limit, reason, search)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get archives", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Archives retrieved", archives, total, page, limit)
}
// GetArchiveByID gets archive by ID
// GET /api/archives/:id
func (c *ArchiveController) GetArchiveByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid archive ID", err.Error())
return
}
archive, err := c.archiveService.GetArchiveByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Archive not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Archive retrieved", archive.ToResponse())
}
// GetArchiveStats gets archive statistics
// GET /api/archives/stats
func (c *ArchiveController) GetArchiveStats(ctx *gin.Context) {
stats, err := c.archiveService.GetArchiveStats()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Archive stats retrieved", stats)
}
+104
View File
@@ -0,0 +1,104 @@
// internal/controllers/auth_controller.go
package controllers
import (
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type AuthController struct {
authService *services.AuthService
}
func NewAuthController(db *gorm.DB, logger *zap.Logger) *AuthController {
return &AuthController{
authService: services.NewAuthService(db, logger),
}
}
// Register handles user registration
// POST /api/register
func (c *AuthController) Register(ctx *gin.Context) {
var req services.RegisterRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Get IP and User-Agent
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Register user
result, err := c.authService.Register(req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Registration failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Registration successful", result)
}
// Login handles user login
// POST /api/login
func (c *AuthController) Login(ctx *gin.Context) {
var req services.LoginRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Get IP and User-Agent
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Login user
result, err := c.authService.Login(req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Login failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Login successful", result)
}
// RefreshToken handles token refresh
// POST /api/refresh-token
func (c *AuthController) RefreshToken(ctx *gin.Context) {
var req struct {
Token string `json:"token" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Refresh token
newToken, err := c.authService.RefreshToken(req.Token)
if err != nil {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Token refresh failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Token refreshed", gin.H{
"token": newToken,
})
}
// GetMe returns current user info
// GET /api/me
func (c *AuthController) GetMe(ctx *gin.Context) {
user, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User info retrieved", user)
}
+130
View File
@@ -0,0 +1,130 @@
// internal/controllers/category_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type CategoryController struct {
categoryService *services.CategoryService
}
func NewCategoryController(db *gorm.DB) *CategoryController {
return &CategoryController{
categoryService: services.NewCategoryService(db),
}
}
// GetAllCategories gets all categories
// GET /api/categories
func (c *CategoryController) GetAllCategories(ctx *gin.Context) {
categories, err := c.categoryService.GetAllCategories()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get categories", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Categories retrieved", categories)
}
// GetCategoryByID gets category by ID
// GET /api/categories/:id
func (c *CategoryController) GetCategoryByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
return
}
category, err := c.categoryService.GetCategoryByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Category not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Category retrieved", category.ToResponse())
}
// CreateCategory creates a new category (admin only)
// POST /api/categories
func (c *CategoryController) CreateCategory(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
var req services.CreateCategoryRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
category, err := c.categoryService.CreateCategory(admin.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create category", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Category created", category.ToResponse())
}
// UpdateCategory updates a category (admin only)
// PUT /api/categories/:id
func (c *CategoryController) UpdateCategory(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
return
}
var req services.UpdateCategoryRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
category, err := c.categoryService.UpdateCategory(admin.ID, uint(categoryID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update category", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Category updated", category.ToResponse())
}
// DeleteCategory deletes a category (admin only)
// DELETE /api/categories/:id
func (c *CategoryController) DeleteCategory(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.categoryService.DeleteCategory(admin.ID, uint(categoryID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete category", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Category deleted", nil)
}
+420
View File
@@ -0,0 +1,420 @@
// internal/controllers/claim_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ClaimController struct {
claimService *services.ClaimService
verificationService *services.VerificationService
}
func NewClaimController(db *gorm.DB) *ClaimController {
return &ClaimController{
claimService: services.NewClaimService(db),
verificationService: services.NewVerificationService(db),
}
}
// GetAllClaims gets all claims
// GET /api/claims
func (c *ClaimController) GetAllClaims(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
var itemID, userID *uint
if itemIDStr := ctx.Query("item_id"); itemIDStr != "" {
id, _ := strconv.ParseUint(itemIDStr, 10, 32)
itemID = new(uint)
*itemID = uint(id)
}
// If regular user, only show their claims
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
if user.IsUser() {
userID = &user.ID
}
}
claims, total, err := c.claimService.GetAllClaims(page, limit, status, itemID, userID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
return
}
// Pastikan claims selalu berupa array, bukan null
if claims == nil {
claims = []models.ClaimResponse{}
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
}
// POST /api/user/claims/:id/respond
func (c *ClaimController) UserApproveClaim(ctx *gin.Context) {
userObj, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Unauthorized", "User not found")
return
}
user := userObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req struct {
Action string `json:"action" binding:"required"` // 'approve' or 'reject'
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// Validasi Action
if req.Action != "approve" && req.Action != "reject" {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid action", "Action must be 'approve' or 'reject'")
return
}
// Logic approve/reject khusus user
err = c.claimService.ProcessUserDecision(user.ID, uint(claimID), req.Action)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Gagal memproses keputusan", err.Error())
return
}
message := "Klaim berhasil disetujui"
if req.Action == "reject" {
message = "Klaim berhasil ditolak"
}
utils.SuccessResponse(ctx, http.StatusOK, message, nil)
}
// GetClaimByID gets claim by ID
// GET /api/claims/:id
func (c *ClaimController) GetClaimByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
isManager := false
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
isManager = user.IsManager() || user.IsAdmin()
}
// Jika Manager, paksa hitung similarity dulu sebelum ambil data
if isManager {
_, err := c.verificationService.VerifyClaimDescription(uint(id))
if err != nil {
// Log error tapi jangan stop flow
}
}
claim, err := c.claimService.GetClaimByID(uint(id), isManager)
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Claim not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim retrieved", claim)
}
func (c *ClaimController) UserConfirmCompletion(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
claimID, _ := strconv.ParseUint(ctx.Param("id"), 10, 32)
err := c.claimService.UserConfirmCompletion(user.ID, uint(claimID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Gagal menyelesaikan kasus", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Kasus selesai. Barang telah diterima.", nil)
}
// CreateClaim creates a new claim
// POST /api/claims
func (c *ClaimController) CreateClaim(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
claim, err := c.claimService.CreateClaim(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Claim created", claim.ToResponse())
}
// VerifyClaim verifies a claim (manager only)
// POST /api/claims/:id/verify
func (c *ClaimController) VerifyClaim(ctx *gin.Context) {
managerObj, _ := ctx.Get("user")
manager := managerObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req services.VerifyClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Auto-verify description similarity
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Verify the claim
if err := c.claimService.VerifyClaim(
manager.ID,
uint(claimID),
req,
verification.SimilarityScore,
stringSliceToString(verification.MatchedKeywords),
ipAddress,
userAgent,
); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to verify claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim verified", gin.H{
"verification": verification,
})
}
// GetClaimVerification gets verification data for a claim
// GET /api/claims/:id/verification
func (c *ClaimController) GetClaimVerification(ctx *gin.Context) {
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Verification retrieved", verification)
}
// CancelClaimApproval handles cancelling an approved claim
// POST /api/claims/:id/cancel-approval
// ✅ RENAMED: CancelApproval -> CancelClaimApproval (Matches routes.go)
func (c *ClaimController) CancelClaimApproval(ctx *gin.Context) {
managerObj, _ := ctx.Get("user")
manager := managerObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
if err := c.claimService.CancelClaimApproval(manager.ID, uint(claimID)); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to cancel approval", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Approval cancelled successfully", nil)
}
// CloseCase handles case closure (Manager only)
// POST /api/claims/:id/close
// ✅ RENAMED: CloseClaim -> CloseCase (Matches routes.go)
func (c *ClaimController) CloseCase(ctx *gin.Context) {
// 1. Ambil User (Manager) dari context secara konsisten
managerObj, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Unauthorized", "User not found")
return
}
manager := managerObj.(*models.User)
// 2. Ambil ID Klaim dari URL
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid ID format", err.Error())
return
}
// 3. Parse Body Request
var req services.CloseCaseRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// 4. Ambil Info Audit
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// 5. Panggil Service
if err := c.claimService.CloseCase(manager.ID, uint(id), req, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to close case", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Case closed successfully", nil)
}
// ReopenCase handles reopening a closed case
// POST /api/claims/:id/reopen
// ✅ RENAMED: ReopenClaim -> ReopenCase (Matches routes.go)
func (c *ClaimController) ReopenCase(ctx *gin.Context) {
managerObj, _ := ctx.Get("user")
manager := managerObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req services.ReopenCaseRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.claimService.ReopenCase(manager.ID, uint(claimID), req, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to reopen case", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Case reopened successfully", nil)
}
// GetClaimsByUser gets claims by user
// GET /api/user/claims
func (c *ClaimController) GetClaimsByUser(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
claims, total, err := c.claimService.GetClaimsByUser(user.ID, page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
}
// UpdateClaim updates a claim
// PUT /api/claims/:id
func (c *ClaimController) UpdateClaim(ctx *gin.Context) {
// Bisa Admin atau User pemilik klaim (dicek di middleware/service)
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req services.UpdateClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
claim, err := c.claimService.UpdateClaim(user.ID, uint(claimID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim updated successfully", claim)
}
// DeleteClaim deletes a claim
// DELETE /api/claims/:id
func (c *ClaimController) DeleteClaim(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.claimService.DeleteClaim(user.ID, uint(claimID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim deleted", nil)
}
// Helper function to convert string slice to string
func stringSliceToString(slice []string) string {
if len(slice) == 0 {
return ""
}
result := ""
for i, s := range slice {
if i > 0 {
result += ", "
}
result += s
}
return result
}
+296
View File
@@ -0,0 +1,296 @@
// internal/controllers/item_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/repositories" // ✅ TAMBAHKAN INI
"lost-and-found/internal/utils"
"net/http"
"strconv"
"log"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ItemController struct {
itemService *services.ItemService
matchService *services.MatchService
itemRepo *repositories.ItemRepository // ✅ TAMBAHKAN INI
}
func NewItemController(db *gorm.DB) *ItemController {
return &ItemController{
itemService: services.NewItemService(db),
matchService: services.NewMatchService(db),
itemRepo: repositories.NewItemRepository(db), // ✅ INITIALIZE INI
}
}
// ✅ FIXED GetItemByID
// ✅ PASTIKAN response detail lengkap untuk manager
// ✅ FIXED GetItemByIDfunc (c *ItemController) GetItemByID(ctx *gin.Context) {
// ✅ FIXED GetItemByID - NOW RETURNS FULL DETAILS FOR MANAGER
func (c *ItemController) GetItemByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
// ✅ Check if user is manager or admin
isManager := false
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
isManager = user.IsManager() || user.IsAdmin()
}
// ✅ Get item DIRECTLY from repository with PRELOAD
item, err := c.itemRepo.FindByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Item not found", err.Error())
return
}
// ✅ LOG untuk debug
log.Printf("🔍 Controller GetItemByID - Item ID: %d", item.ID)
log.Printf(" 📝 Description: %s", item.Description)
log.Printf(" 🔒 SecretDetails: %s", item.SecretDetails)
log.Printf(" 👤 ReporterName: %s", item.ReporterName)
log.Printf(" 📞 ReporterContact: %s", item.ReporterContact)
// ✅ Return response based on role
if isManager {
// Manager/Admin gets FULL details
detailResponse := item.ToDetailResponse()
// ✅ LOG response yang akan dikirim
log.Printf("📤 Sending DetailResponse to Manager:")
log.Printf(" Description: %s", detailResponse.Description)
log.Printf(" SecretDetails: %s", detailResponse.SecretDetails)
log.Printf(" ReporterName: %s", detailResponse.ReporterName)
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", detailResponse)
} else {
// Regular user gets public view only
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", item.ToPublicResponse())
}
}
func (c *ItemController) ReportFoundItemLinked(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateFoundItemLinkedRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Data tidak valid", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
item, err := c.itemService.CreateFoundItemLinked(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Gagal membuat laporan", err.Error())
return
}
message := "Laporan berhasil dibuat. Menunggu verifikasi Manager."
if req.IsDirectToOwner {
message = "Laporan berhasil! Notifikasi langsung dikirim ke pemilik barang."
}
utils.SuccessResponse(ctx, http.StatusCreated, message, item)
}
// CreateItem creates a new item
// POST /api/items
func (c *ItemController) CreateItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
item, err := c.itemService.CreateItem(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create item", err.Error())
return
}
// Auto-match with lost items
go c.matchService.AutoMatchNewItem(item.ID)
utils.SuccessResponse(ctx, http.StatusCreated, "Item created", item.ToDetailResponse())
}
// UpdateItem updates an item
// PUT /api/items/:id
func (c *ItemController) UpdateItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
var req services.UpdateItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
item, err := c.itemService.UpdateItem(user.ID, uint(itemID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update item", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Item updated", item.ToDetailResponse())
}
// UpdateItemStatus updates item status
// PATCH /api/items/:id/status
func (c *ItemController) UpdateItemStatus(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.itemService.UpdateItemStatus(user.ID, uint(itemID), req.Status, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Item status updated", nil)
}
// DeleteItem deletes an item
// DELETE /api/items/:id
func (c *ItemController) DeleteItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.itemService.DeleteItem(user.ID, uint(itemID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete item", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Item deleted", nil)
}
// GetItemsByReporter gets items by reporter
// GET /api/user/items
func (c *ItemController) GetItemsByReporter(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
items, total, err := c.itemService.GetItemsByReporter(user.ID, page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
return
}
var responses []models.ItemDetailResponse
for _, item := range items {
responses = append(responses, item.ToDetailResponse())
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", responses, total, page, limit)
}
// GetItemRevisionHistory gets revision history for an item
// GET /api/items/:id/revisions
func (c *ItemController) GetItemRevisionHistory(ctx *gin.Context) {
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
revisions, total, err := c.itemService.GetItemRevisionHistory(uint(itemID), page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get revision history", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Revision history retrieved", revisions, total, page, limit)
}
func (c *ItemController) GetAllItems(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
category := ctx.Query("category")
search := ctx.Query("search")
// ✅ CHECK IF USER IS MANAGER/ADMIN
isManager := false
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
isManager = user.IsManager() || user.IsAdmin()
}
// ✅ FIXED: FORCE FILTER OUT EXPIRED for public users
if !isManager {
// 1. Jika user mencoba meminta status terlarang, paksa filter aman
if status == models.ItemStatusExpired || status == models.ItemStatusCaseClosed {
status = "!expired"
}
// 2. Jika status kosong (default), set ke !expired
if status == "" {
status = "!expired"
}
}
items, total, err := c.itemService.GetAllItems(page, limit, status, category, search)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", items, total, page, limit)
}
@@ -0,0 +1,230 @@
// internal/controllers/lost_item_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type LostItemController struct {
lostItemService *services.LostItemService
}
func NewLostItemController(db *gorm.DB) *LostItemController {
return &LostItemController{
lostItemService: services.NewLostItemService(db),
}
}
// GetAllLostItems gets all lost items
// GET /api/lost-items
func (c *LostItemController) GetAllLostItems(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
category := ctx.Query("category")
search := ctx.Query("search")
scope := ctx.Query("scope") // Tambahkan ini
var userID *uint
// Logic Baru: Defaultnya publik (userID = nil), kecuali minta 'mine'
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
// Hanya filter ID jika user meminta scope="mine"
if scope == "mine" {
userID = &user.ID
}
}
// Panggil service (userID akan nil jika melihat publik, terisi jika scope=mine)
lostItems, total, err := c.lostItemService.GetAllLostItems(page, limit, status, category, search, userID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
}
// GetLostItemByID gets lost item by ID
// GET /api/lost-items/:id
func (c *LostItemController) GetLostItemByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
lostItem, err := c.lostItemService.GetLostItemByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Lost item not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item retrieved", lostItem.ToResponse())
}
// CreateLostItem creates a new lost item report
// POST /api/lost-items
func (c *LostItemController) CreateLostItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateLostItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
lostItem, err := c.lostItemService.CreateLostItem(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create lost item report", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Lost item report created", lostItem.ToResponse())
}
// UpdateLostItem updates a lost item report
// PUT /api/lost-items/:id
func (c *LostItemController) UpdateLostItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
var req services.UpdateLostItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
lostItem, err := c.lostItemService.UpdateLostItem(user.ID, uint(lostItemID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update lost item report", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report updated", lostItem.ToResponse())
}
// UpdateLostItemStatus updates lost item status
// PATCH /api/lost-items/:id/status
func (c *LostItemController) UpdateLostItemStatus(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.lostItemService.UpdateLostItemStatus(user.ID, uint(lostItemID), req.Status, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item status updated", nil)
}
// DeleteLostItem deletes a lost item report
// DELETE /api/lost-items/:id
func (c *LostItemController) DeleteLostItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.lostItemService.DeleteLostItem(user.ID, uint(lostItemID), ipAddress, userAgent);
err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete lost item report", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report deleted", nil)
}
// GetLostItemsByUser gets lost items by user
// GET /api/user/lost-items
func (c *LostItemController) GetLostItemsByUser(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
lostItems, total, err := c.lostItemService.GetLostItemsByUser(user.ID, page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
}
func (c *LostItemController) DirectClaimToOwner(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
var req services.CreateLostItemClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// ✅ TAMBAHAN BARU: Ambil IP dan User Agent untuk Audit Log
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// ✅ UPDATE: Kirim ipAddress dan userAgent ke Service
claim, err := c.lostItemService.DirectClaimToOwner(user.ID, uint(lostItemID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to submit direct claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Klaim terkirim ke pemilik untuk persetujuan", claim.ToResponse())
}
@@ -0,0 +1,40 @@
// internal/controllers/manager_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ManagerController struct {
itemRepo *repositories.ItemRepository
claimRepo *repositories.ClaimRepository
}
func NewManagerController(db *gorm.DB) *ManagerController {
return &ManagerController{
itemRepo: repositories.NewItemRepository(db),
claimRepo: repositories.NewClaimRepository(db),
}
}
func (c *ManagerController) GetDashboardStats(ctx *gin.Context) {
totalItems, _ := c.itemRepo.CountAll()
pendingClaims, _ := c.claimRepo.CountByStatus(models.ClaimStatusPending)
verifiedItems, _ := c.itemRepo.CountByStatus(models.ItemStatusVerified)
expiredItems, _ := c.itemRepo.CountByStatus(models.ItemStatusExpired)
stats := map[string]interface{}{
"total_items": totalItems,
"pending_claims": pendingClaims,
"verified": verifiedItems,
"expired": expiredItems,
}
utils.SuccessResponse(ctx, http.StatusOK, "Manager dashboard stats", stats)
}
+87
View File
@@ -0,0 +1,87 @@
// internal/controllers/match_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type MatchController struct {
matchService *services.MatchService
}
func NewMatchController(db *gorm.DB) *MatchController {
return &MatchController{
matchService: services.NewMatchService(db),
}
}
// FindSimilarItems finds similar items for a lost item
// POST /api/lost-items/:id/find-similar
func (c *MatchController) FindSimilarItems(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
// Only allow managers or the owner to search
// Add ownership check here if needed
results, err := c.matchService.FindSimilarItems(uint(lostItemID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to find similar items", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Similar items found", gin.H{
"total": len(results),
"matches": results,
"user_id": user.ID,
})
}
// GetMatchesForLostItem gets all matches for a lost item
// GET /api/lost-items/:id/matches
func (c *MatchController) GetMatchesForLostItem(ctx *gin.Context) {
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
matches, err := c.matchService.GetMatchesForLostItem(uint(lostItemID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
}
// GetMatchesForItem gets all matches for an item
// GET /api/items/:id/matches
func (c *MatchController) GetMatchesForItem(ctx *gin.Context) {
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
matches, err := c.matchService.GetMatchesForItem(uint(itemID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
}
@@ -0,0 +1,82 @@
// internal/controllers/notification_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type NotificationController struct {
notificationService *services.NotificationService
}
func NewNotificationController(db *gorm.DB) *NotificationController {
return &NotificationController{
notificationService: services.NewNotificationService(db),
}
}
// GetUserNotifications gets notifications for current user
// GET /api/notifications
func (c *NotificationController) GetUserNotifications(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
onlyUnread := ctx.Query("unread") == "true"
notifications, total, err := c.notificationService.GetUserNotifications(user.ID, page, limit, onlyUnread)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get notifications", err.Error())
return
}
count, _ := c.notificationService.CountUnread(user.ID)
utils.SuccessResponse(ctx, http.StatusOK, "Notifications retrieved", gin.H{
"notifications": notifications,
"total": total,
"unread_count": count,
})
}
// MarkAsRead marks a notification as read
// PATCH /api/notifications/:id/read
func (c *NotificationController) MarkAsRead(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid ID", err.Error())
return
}
if err := c.notificationService.MarkAsRead(user.ID, uint(id)); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to mark as read", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Notification marked as read", nil)
}
// MarkAllAsRead marks all notifications as read
// PATCH /api/notifications/read-all
func (c *NotificationController) MarkAllAsRead(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
if err := c.notificationService.MarkAllAsRead(user.ID); err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to mark all as read", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "All notifications marked as read", nil)
}
+110
View File
@@ -0,0 +1,110 @@
// internal/controllers/report_controller.go
package controllers
import (
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ReportController struct {
exportService *services.ExportService
}
func NewReportController(db *gorm.DB) *ReportController {
return &ReportController{
exportService: services.NewExportService(db),
}
}
// ExportReport exports report based on request
// POST /api/reports/export
func (c *ReportController) ExportReport(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.ExportRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
var buffer *[]byte
var filename string
var contentType string
var err error
// Generate report based on type and format
switch req.Type {
case "items":
if req.Format == "pdf" {
buf, e := c.exportService.ExportItemsToPDF(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("items_report_%s.pdf", time.Now().Format("20060102"))
contentType = "application/pdf"
}
} else {
buf, e := c.exportService.ExportItemsToExcel(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("items_report_%s.xlsx", time.Now().Format("20060102"))
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
}
case "archives":
buf, e := c.exportService.ExportArchivesToPDF(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("archives_report_%s.pdf", time.Now().Format("20060102"))
contentType = "application/pdf"
}
case "claims":
buf, e := c.exportService.ExportClaimsToPDF(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("claims_report_%s.pdf", time.Now().Format("20060102"))
contentType = "application/pdf"
}
default:
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid report type", "")
return
}
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to generate report", err.Error())
return
}
// Set headers
ctx.Header("Content-Type", contentType)
ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
ctx.Header("Content-Length", fmt.Sprintf("%d", len(*buffer)))
// Send file
ctx.Data(http.StatusOK, contentType, *buffer)
}
+105
View File
@@ -0,0 +1,105 @@
package controllers
import (
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type RoleController struct {
roleService *services.RoleService
}
// NewRoleController initializes the role controller
func NewRoleController(db *gorm.DB) *RoleController {
return &RoleController{
roleService: services.NewRoleService(db),
}
}
// GetRoles gets all roles
// GET /api/admin/roles
func (c *RoleController) GetRoles(ctx *gin.Context) {
roles, err := c.roleService.GetAllRoles()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get roles", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Roles retrieved", roles)
}
// GetPermissions gets all permissions
// GET /api/admin/permissions
func (c *RoleController) GetPermissions(ctx *gin.Context) {
permissions, err := c.roleService.GetAllPermissions()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get permissions", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Permissions retrieved", permissions)
}
// CreateRole creates a new role
// POST /api/admin/roles
func (c *RoleController) CreateRole(ctx *gin.Context) {
var req services.CreateRoleRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
role, err := c.roleService.CreateRole(req)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Role created", role)
}
// UpdateRole updates a role
// PUT /api/admin/roles/:id
func (c *RoleController) UpdateRole(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid role ID", err.Error())
return
}
var req services.UpdateRoleRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
role, err := c.roleService.UpdateRole(uint(id), req)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Role updated", role)
}
// DeleteRole deletes a role
// DELETE /api/admin/roles/:id
func (c *RoleController) DeleteRole(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid role ID", err.Error())
return
}
if err := c.roleService.DeleteRole(uint(id)); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Role deleted", nil)
}
+284
View File
@@ -0,0 +1,284 @@
// internal/controllers/upload_controller.go
package controllers
import (
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/utils"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ✅ KRITERIA BACKEND: File Handling (5 Poin)
// - Validasi MIME type
// - Validasi Max size
// - Multiple file support
// - Secure filename handling
type UploadController struct {
imageHandler *utils.ImageHandler
db *gorm.DB
}
func NewUploadController(db *gorm.DB) *UploadController {
return &UploadController{
imageHandler: utils.NewImageHandler("./uploads"),
db: db,
}
}
// UploadItemImage uploads image untuk item
// POST /api/upload/item-image
func (c *UploadController) UploadItemImage(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
// ✅ VALIDASI: Get file from request
file, err := ctx.FormFile("image")
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "No file uploaded", err.Error())
return
}
// ✅ VALIDASI: Check MIME type (dilakukan di ImageHandler)
// ✅ VALIDASI: Check file size (dilakukan di ImageHandler)
// Upload dengan processing (resize, optimize)
relativePath, err := c.imageHandler.UploadImage(file, "items")
if err != nil {
// Error dari ImageHandler sudah mencakup validasi MIME type & size
utils.ErrorResponse(ctx, http.StatusBadRequest, "Upload failed", err.Error())
return
}
// Generate URL
imageURL := fmt.Sprintf("/uploads/%s", relativePath)
// Log audit
c.logAudit(user.ID, "upload", "item_image", nil,
fmt.Sprintf("Image uploaded: %s", file.Filename),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Image uploaded successfully", gin.H{
"url": imageURL,
"filename": filepath.Base(relativePath),
"size": file.Size,
})
}
// UploadClaimProof uploads bukti untuk claim
// POST /api/upload/claim-proof
func (c *UploadController) UploadClaimProof(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
file, err := ctx.FormFile("proof")
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "No file uploaded", err.Error())
return
}
// ✅ VALIDASI TAMBAHAN: Check file extension
ext := strings.ToLower(filepath.Ext(file.Filename))
allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}
isAllowed := false
for _, allowedExt := range allowedExts {
if ext == allowedExt {
isAllowed = true
break
}
}
if !isAllowed {
utils.ErrorResponse(ctx, http.StatusBadRequest,
"Invalid file extension",
fmt.Sprintf("Allowed: %v", allowedExts))
return
}
// Upload simple (tanpa resize untuk PDF)
var relativePath string
if ext == ".pdf" {
relativePath, err = c.imageHandler.UploadImageSimple(file, "proofs")
} else {
relativePath, err = c.imageHandler.UploadImage(file, "proofs")
}
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Upload failed", err.Error())
return
}
proofURL := fmt.Sprintf("/uploads/%s", relativePath)
c.logAudit(user.ID, "upload", "claim_proof", nil,
fmt.Sprintf("Proof uploaded: %s", file.Filename),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Proof uploaded successfully", gin.H{
"url": proofURL,
"filename": filepath.Base(relativePath),
"size": file.Size,
"type": ext,
})
}
// UploadMultipleImages uploads multiple images sekaligus
// POST /api/upload/multiple
func (c *UploadController) UploadMultipleImages(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
// ✅ VALIDASI: Parse multipart form
form, err := ctx.MultipartForm()
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid form data", err.Error())
return
}
files := form.File["images"]
if len(files) == 0 {
utils.ErrorResponse(ctx, http.StatusBadRequest, "No files uploaded", "")
return
}
// ✅ VALIDASI: Max 5 files per request
const maxFiles = 5
if len(files) > maxFiles {
utils.ErrorResponse(ctx, http.StatusBadRequest,
fmt.Sprintf("Too many files. Max %d files allowed", maxFiles), "")
return
}
var uploadedFiles []gin.H
var failedFiles []gin.H
for _, file := range files {
// Upload each file
relativePath, err := c.imageHandler.UploadImage(file, "items")
if err != nil {
failedFiles = append(failedFiles, gin.H{
"filename": file.Filename,
"error": err.Error(),
})
continue
}
uploadedFiles = append(uploadedFiles, gin.H{
"filename": filepath.Base(relativePath),
"url": fmt.Sprintf("/uploads/%s", relativePath),
"size": file.Size,
})
}
c.logAudit(user.ID, "upload", "multiple_images", nil,
fmt.Sprintf("Uploaded %d files, %d failed", len(uploadedFiles), len(failedFiles)),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Upload completed", gin.H{
"uploaded": uploadedFiles,
"failed": failedFiles,
"total": len(files),
"success": len(uploadedFiles),
})
}
// DeleteImage deletes uploaded image
// DELETE /api/upload/delete
func (c *UploadController) DeleteImage(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req struct {
ImageURL string `json:"image_url" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request", err.Error())
return
}
// Extract relative path from URL
relativePath := strings.TrimPrefix(req.ImageURL, "/uploads/")
// ✅ SECURITY: Validate path (prevent directory traversal)
if strings.Contains(relativePath, "..") {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid path", "Directory traversal detected")
return
}
// Delete file
if err := c.imageHandler.DeleteImage(relativePath); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Delete failed", err.Error())
return
}
c.logAudit(user.ID, "delete", "image", nil,
fmt.Sprintf("Image deleted: %s", relativePath),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Image deleted successfully", nil)
}
// GetImageInfo gets metadata tentang uploaded image
// GET /api/upload/info?url=/uploads/items/image.jpg
func (c *UploadController) GetImageInfo(ctx *gin.Context) {
imageURL := ctx.Query("url")
if imageURL == "" {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Image URL required", "")
return
}
relativePath := strings.TrimPrefix(imageURL, "/uploads/")
// Security check
if strings.Contains(relativePath, "..") {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid path", "")
return
}
fullPath := filepath.Join("./uploads", relativePath)
// Check if file exists
fileInfo, err := filepath.Abs(fullPath)
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "File not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Image info retrieved", gin.H{
"path": fileInfo,
"url": imageURL,
"filename": filepath.Base(relativePath),
"folder": filepath.Dir(relativePath),
})
}
// logAudit helper untuk logging
func (c *UploadController) logAudit(userID uint, action, entityType string, entityID *uint, details, ip, userAgent string) {
auditLog := &models.AuditLog{
UserID: &userID,
Action: action,
EntityType: entityType,
EntityID: entityID,
Details: details,
IPAddress: ip,
UserAgent: userAgent,
}
c.db.Create(auditLog)
}
// ✅ VALIDASI INFO
// Validasi yang diterapkan:
// 1. MIME type check (di ImageHandler.isAllowedType)
// 2. File size check (di ImageHandler dengan maxSize = 10MB)
// 3. File extension check (allowedExts)
// 4. Max files per request check (5 files)
// 5. Directory traversal prevention (.. check)
// 6. Image resize untuk optimize storage (di ImageHandler)
+238
View File
@@ -0,0 +1,238 @@
// internal/controllers/user_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type UserController struct {
userService *services.UserService
}
func NewUserController(db *gorm.DB) *UserController {
return &UserController{
userService: services.NewUserService(db),
}
}
// GetProfile gets user profile
// GET /api/user/profile
func (c *UserController) GetProfile(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
profile, err := c.userService.GetProfile(user.ID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Profile not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Profile retrieved", profile.ToResponse())
}
// UpdateProfile updates user profile
// PUT /api/user/profile
func (c *UserController) UpdateProfile(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.UpdateProfileRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
updatedUser, err := c.userService.UpdateProfile(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Update failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Profile updated", updatedUser.ToResponse())
}
// ChangePassword changes user password
// POST /api/user/change-password
func (c *UserController) ChangePassword(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.ChangePasswordRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.ChangePassword(user.ID, req, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Password change failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Password changed successfully", nil)
}
// GetStats gets user statistics
// GET /api/user/stats
func (c *UserController) GetStats(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
stats, err := c.userService.GetUserStats(user.ID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Stats retrieved", stats)
}
// GetAllUsers gets all users (admin only)
// GET /api/admin/users
func (c *UserController) GetAllUsers(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
users, total, err := c.userService.GetAllUsers(page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get users", err.Error())
return
}
var responses []models.UserResponse
for _, user := range users {
responses = append(responses, user.ToResponse())
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Users retrieved", responses, total, page, limit)
}
// GetUserByID gets user by ID (admin only)
// GET /api/admin/users/:id
func (c *UserController) GetUserByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
user, err := c.userService.GetUserByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "User not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User retrieved", user.ToResponse())
}
// UpdateUserRole updates user role (admin only)
// PATCH /api/admin/users/:id/role
func (c *UserController) UpdateUserRole(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
var req struct {
RoleID uint `json:"role_id" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.UpdateUserRole(admin.ID, uint(userID), req.RoleID, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User role updated", nil)
}
// BlockUser blocks a user (admin only)
// POST /api/admin/users/:id/block
func (c *UserController) BlockUser(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.BlockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to block user", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User blocked", nil)
}
// UnblockUser unblocks a user (admin only)
// POST /api/admin/users/:id/unblock
func (c *UserController) UnblockUser(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.UnblockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to unblock user", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User unblocked", nil)
}
// DeleteUser deletes a user (admin only)
// DELETE /api/admin/users/:id
func (c *UserController) DeleteUser(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.DeleteUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete user", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User deleted", nil)
}