// internal/services/item_service.go - FIXED VERSION package services import ( "context" "errors" "fmt" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "time" "log" "gorm.io/gorm" "gorm.io/gorm/clause" ) type ItemService struct { itemRepo *repositories.ItemRepository categoryRepo *repositories.CategoryRepository auditLogRepo *repositories.AuditLogRepository revisionRepo *repositories.RevisionLogRepository db *gorm.DB lostItemRepo *repositories.LostItemRepository // FIXED: pointer type notificationRepo *repositories.NotificationRepository // FIXED: pointer type matchRepo *repositories.MatchResultRepository // TAMBAHAN } func NewItemService(db *gorm.DB) *ItemService { itemRepo := repositories.NewItemRepository(db) lostItemRepo := repositories.NewLostItemRepository(db) notifRepo := repositories.NewNotificationRepository(db) matchRepo := repositories.NewMatchResultRepository(db) return &ItemService{ db: db, itemRepo: itemRepo, categoryRepo: repositories.NewCategoryRepository(db), auditLogRepo: repositories.NewAuditLogRepository(db), revisionRepo: repositories.NewRevisionLogRepository(db), lostItemRepo: lostItemRepo, notificationRepo: notifRepo, matchRepo: matchRepo, } } func (s *ItemService) CreateFoundItemLinked(reporterID uint, req CreateFoundItemLinkedRequest, ipAddress, userAgent string) (*models.Item, error) { tx := s.db.Begin() if tx.Error != nil { return nil, tx.Error } // 1. Buat Item Awal (Status Default: unclaimed) item := &models.Item{ Name: req.Name, CategoryID: req.CategoryID, Location: req.Location, DateFound: time.Now(), Description: req.Description, ReporterID: reporterID, ReporterName: req.ReporterName, ReporterContact: req.ReporterContact, Status: models.ItemStatusUnclaimed, // Default } if req.PhotoURL != "" { item.PhotoURL = req.PhotoURL } if err := tx.Create(item).Error; err != nil { tx.Rollback() return nil, err } // 2. Logika "Langsung ke Pemilik" // Pastikan req.LostItemID & req.IsDirectToOwner terisi (Cek tag JSON struct!) if req.IsDirectToOwner && req.LostItemID != 0 { var lostItem models.LostItem // Gunakan Preload agar data User tidak nil saat dipakai nanti if err := tx.Preload("User").First(&lostItem, req.LostItemID).Error; err != nil { tx.Rollback() return nil, errors.New("Laporan kehilangan tidak ditemukan") } // A. Update Status Lost Item jadi "claimed" if err := tx.Model(&lostItem).Update("status", "claimed").Error; err != nil { tx.Rollback() return nil, err } // B. Update Status Item Temuan jadi "waiting_owner" if err := tx.Model(item).Update("status", "waiting_owner").Error; err != nil { tx.Rollback() return nil, err } // ✅ PENTING: Update struct di memori supaya response API benar itemID := item.ID newClaim := &models.Claim{ ItemID: &itemID, // PENTING: Harus pointer UserID: lostItem.UserID, Description: "Auto-match: Ditemukan dan diserahkan langsung ke pemilik.", Contact: lostItem.User.Phone, Status: models.ClaimStatusWaitingOwner, } if err := tx.Create(newClaim).Error; err != nil { tx.Rollback() return nil, err } } if err := tx.Commit().Error; err != nil { return nil, err } return item, nil } type CreateItemRequest struct { Name string `json:"name" binding:"required"` CategoryID uint `json:"category_id" binding:"required"` PhotoURL string `json:"photo_url"` Location string `json:"location" binding:"required"` Description string `json:"description" binding:"required"` SecretDetails string `json:"secret_details" binding:"required"` DateFound time.Time `json:"date_found" binding:"required"` ReporterName string `json:"reporter_name" binding:"required"` ReporterContact string `json:"reporter_contact" binding:"required"` } type UpdateItemRequest struct { Name string `json:"name"` CategoryID uint `json:"category_id"` PhotoURL string `json:"photo_url"` Location string `json:"location"` Description string `json:"description"` SecretDetails string `json:"secret_details"` DateFound time.Time `json:"date_found"` ReporterName string `json:"reporter_name"` ReporterContact string `json:"reporter_contact"` Status string `json:"status"` Reason string `json:"reason"` } type CreateFoundItemLinkedRequest struct { CreateItemRequest LostItemID uint `json:"lost_item_id" binding:"required"` IsDirectToOwner bool `json:"is_direct_to_owner"` } // GetAllItems gets all items with CONTEXT TIMEOUT func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() txRepo := repositories.NewItemRepository(s.db.WithContext(ctx)) items, total, err := txRepo.FindAll(page, limit, status, category, search) if err != nil { if ctx.Err() == context.DeadlineExceeded { return nil, 0, errors.New("request timeout: query took too long") } return nil, 0, err } var responses []models.ItemPublicResponse for _, item := range items { responses = append(responses, item.ToPublicResponse()) } return responses, total, nil } func (s *ItemService) RunAutoArchive(ipAddress, userAgent string) (int, error) { // Panggil Repository count, err := s.itemRepo.CallArchiveExpiredProcedure() if err != nil { return 0, err } // Log Audit jika ada yang diarsip if count > 0 { details := fmt.Sprintf("Auto-archived %d expired items using Stored Procedure", count) // Gunakan ID 0 atau nil untuk system action s.auditLogRepo.Log(nil, "auto_archive", "system", nil, details, ipAddress, userAgent) } return count, nil } // ✅ FIXED: CreateItem - NOW INCLUDES SecretDetails func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var item *models.Item // ✅ TRANSACTION untuk create item + audit log err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. Verify category exists var category models.Category if err := tx.Where("id = ? AND deleted_at IS NULL", req.CategoryID). First(&category).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("invalid category") } return fmt.Errorf("failed to verify category: %w", err) } // 2. Create item - ✅ SEKARANG INCLUDE SecretDetails item = &models.Item{ Name: req.Name, CategoryID: req.CategoryID, PhotoURL: req.PhotoURL, Location: req.Location, Description: req.Description, SecretDetails: req.SecretDetails, // ✅ TAMBAHKAN INI DateFound: req.DateFound, Status: models.ItemStatusUnclaimed, ReporterID: reporterID, ReporterName: req.ReporterName, ReporterContact: req.ReporterContact, } if err := tx.Create(item).Error; err != nil { return fmt.Errorf("failed to create item: %w", err) } // 3. Create audit log auditLog := &models.AuditLog{ UserID: &reporterID, Action: models.ActionCreate, EntityType: models.EntityItem, EntityID: &item.ID, Details: fmt.Sprintf("Item created: %s", item.Name), IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return fmt.Errorf("failed to create audit log: %w", err) } return nil }) if err != nil { return nil, err } return item, nil } // ✅ FIXED: UpdateItem - NOW HANDLES SecretDetails // internal/services/item_service.go // UpdateItem updates an item with transaction, locking, and status change support func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) { // ✅ Tambahkan logging log.Printf("🔍 UpdateItem Request:") log.Printf(" ItemID: %d", itemID) log.Printf(" Name: %s", req.Name) log.Printf(" CategoryID: %d", req.CategoryID) log.Printf(" Status: %s", req.Status) log.Printf(" Location: %s", req.Location) log.Printf(" Description: %s", req.Description) log.Printf(" SecretDetails: %s", req.SecretDetails) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var updatedItem *models.Item // ✅ TRANSACTION + LOCKING untuk update item err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var item models.Item // 1. Ambil data User beserta Role-nya (FIXED: Preload Role) var user models.User if err := tx.Preload("Role").First(&user, userID).Error; err != nil { return fmt.Errorf("failed to get user: %w", err) } // 2. Lock item untuk mencegah race condition saat update if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Preload("Category"). Where("id = ? AND deleted_at IS NULL", itemID). First(&item).Error; err != nil { return errors.New("item not found") } // 3. Cek Permission: Izinkan jika User adalah Owner ATAU Admin/Manager isOwner := item.ReporterID == userID isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager" if !isOwner && !isManagerOrAdmin { return errors.New("unauthorized to edit this item") } // 4. Validasi Status Item // - Case Closed tetap permanen (tidak bisa diedit siapapun) if item.Status == models.ItemStatusCaseClosed { return errors.New("cannot edit item with status: " + item.Status) } // - Expired hanya bisa diedit oleh Manager/Admin if !isManagerOrAdmin && item.IsExpired() { return errors.New("cannot edit expired item") } // Track changes for revision log revisionCreated := false // --- Update Fields Logics --- if req.Name != "" && req.Name != item.Name { revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "name", OldValue: item.Name, NewValue: req.Name, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } item.Name = req.Name revisionCreated = true } if req.CategoryID != 0 && req.CategoryID != item.CategoryID { // Verify new category exists var newCategory models.Category if err := tx.Where("id = ?", req.CategoryID).First(&newCategory).Error; err != nil { return errors.New("invalid category") } oldCatName := item.Category.Name revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "category", OldValue: oldCatName, NewValue: newCategory.Name, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } item.CategoryID = req.CategoryID revisionCreated = true } if req.Location != "" && req.Location != item.Location { revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "location", OldValue: item.Location, NewValue: req.Location, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } item.Location = req.Location revisionCreated = true } if req.Description != "" && req.Description != item.Description { revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "description", OldValue: item.Description, NewValue: req.Description, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } item.Description = req.Description revisionCreated = true } // Handle SecretDetails update if req.SecretDetails != "" && req.SecretDetails != item.SecretDetails { revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "secret_details", OldValue: item.SecretDetails, NewValue: req.SecretDetails, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } item.SecretDetails = req.SecretDetails revisionCreated = true } if req.PhotoURL != "" && req.PhotoURL != item.PhotoURL { revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "photo_url", OldValue: item.PhotoURL, NewValue: req.PhotoURL, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } item.PhotoURL = req.PhotoURL revisionCreated = true } // ✅ NEW: Handle Status Update (Khusus Manager/Admin) if req.Status != "" && req.Status != item.Status { // Validasi status yang diperbolehkan untuk di-set manual validStatuses := map[string]bool{ models.ItemStatusUnclaimed: true, models.ItemStatusVerified: true, models.ItemStatusExpired: true, models.ItemStatusCaseClosed: true, } if !validStatuses[req.Status] { return errors.New("invalid status value") } revLog := &models.RevisionLog{ ItemID: itemID, UserID: userID, FieldName: "status", OldValue: item.Status, NewValue: req.Status, Reason: req.Reason, } if err := tx.Create(revLog).Error; err != nil { return fmt.Errorf("failed to create revision log for status: %w", err) } item.Status = req.Status revisionCreated = true } if !revisionCreated { return errors.New("no changes detected") } // Save updated item if err := tx.Save(&item).Error; err != nil { return fmt.Errorf("failed to update item: %w", err) } // Create audit log auditLog := &models.AuditLog{ UserID: &userID, Action: models.ActionUpdate, EntityType: models.EntityItem, EntityID: &itemID, Details: fmt.Sprintf("Item updated: %s (Reason: %s)", item.Name, req.Reason), IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return fmt.Errorf("failed to create audit log: %w", err) } updatedItem = &item return nil }) if err != nil { return nil, err } return updatedItem, nil } // UpdateItemStatus updates item status with TRANSACTION func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Lock item var item models.Item if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", itemID). First(&item).Error; err != nil { return fmt.Errorf("failed to lock item: %w", err) } // Update status if err := tx.Model(&item).Update("status", status).Error; err != nil { return fmt.Errorf("failed to update status: %w", err) } // Create audit log auditLog := &models.AuditLog{ UserID: &userID, Action: models.ActionUpdate, EntityType: models.EntityItem, EntityID: &itemID, Details: fmt.Sprintf("Item status updated to: %s", status), IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return fmt.Errorf("failed to create audit log: %w", err) } return nil }) } // DeleteItem deletes an item with TRANSACTION func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. Ambil data User yang sedang request (untuk cek Role) var user models.User if err := tx.Preload("Role").First(&user, userID).Error; err != nil { return errors.New("user not found") } // 2. Lock item var item models.Item if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", itemID). First(&item).Error; err != nil { return fmt.Errorf("failed to lock item: %w", err) } // 3. PERMISSION CHECK: Izinkan jika Owner ATAU Manager/Admin isOwner := item.ReporterID == userID isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager" if !isOwner && !isManagerOrAdmin { return errors.New("unauthorized to delete this item") } // 4. ✅ (BARU) Validasi Active Claims - PENGGANTI TRIGGER trg_items_before_delete // Cek apakah ada claims dengan status 'pending' untuk item ini var activeClaims int64 if err := tx.Model(&models.Claim{}). Where("item_id = ? AND status IN ? AND deleted_at IS NULL", itemID, []string{models.ClaimStatusPending, models.ClaimStatusWaitingOwner}). Count(&activeClaims).Error; err != nil { return fmt.Errorf("failed to check active claims: %w", err) } if activeClaims > 0 { return errors.New("cannot delete item with active claims (pending or waiting owner)") } if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed { return errors.New("cannot delete item with status: " + item.Status) } if err := tx.Delete(&item).Error; err != nil { return fmt.Errorf("failed to delete item: %w", err) } // 7. Create audit log auditLog := &models.AuditLog{ UserID: &userID, Action: models.ActionDelete, EntityType: models.EntityItem, EntityID: &itemID, Details: fmt.Sprintf("Item deleted: %s", item.Name), IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return fmt.Errorf("failed to create audit log: %w", err) } return nil }) } // GetItemsByReporter gets items by reporter with CONTEXT func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() txRepo := repositories.NewItemRepository(s.db.WithContext(ctx)) return txRepo.FindByReporter(reporterID, page, limit) } // GetItemRevisionHistory gets revision history with CONTEXT func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() txRepo := repositories.NewRevisionLogRepository(s.db.WithContext(ctx)) logs, total, err := txRepo.FindByItem(itemID, page, limit) if err != nil { if ctx.Err() == context.DeadlineExceeded { return nil, 0, errors.New("request timeout") } return nil, 0, err } var responses []models.RevisionLogResponse for _, log := range logs { responses = append(responses, log.ToResponse()) } return responses, total, nil }