// internal/services/claim_service.go package services import ( "context" "errors" "fmt" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "time" "gorm.io/gorm" "gorm.io/gorm/clause" ) type CloseCaseRequest struct { BeritaAcaraNo string `json:"berita_acara_no" binding:"required"` BuktiSerahTerima string `json:"bukti_serah_terima"` // Optional file URL Notes string `json:"notes"` } type ReopenCaseRequest struct { Reason string `json:"reason" binding:"required"` } type ClaimService struct { db *gorm.DB claimRepo *repositories.ClaimRepository itemRepo *repositories.ItemRepository verificationRepo *repositories.ClaimVerificationRepository notificationRepo *repositories.NotificationRepository auditLogRepo *repositories.AuditLogRepository } func NewClaimService(db *gorm.DB) *ClaimService { return &ClaimService{ db: db, claimRepo: repositories.NewClaimRepository(db), itemRepo: repositories.NewItemRepository(db), verificationRepo: repositories.NewClaimVerificationRepository(db), notificationRepo: repositories.NewNotificationRepository(db), auditLogRepo: repositories.NewAuditLogRepository(db), } } // CreateClaimRequest represents claim creation data type CreateClaimRequest struct { ItemID uint `json:"item_id" binding:"required"` UserID *uint `json:"user_id"` // Optional: if admin creates for specific user Description string `json:"description" binding:"required"` Contact string `json:"contact" binding:"required"` ProofURL string `json:"proof_url"` AdminNotes string `json:"admin_notes"` // Optional: admin's internal notes } // VerifyClaimRequest represents claim verification data type VerifyClaimRequest struct { Status string `json:"status" binding:"required"` // approved or rejected Approved *bool `json:"approved"` Notes string `json:"notes"` } type UpdateClaimRequest struct { Description string `json:"description" binding:"required"` Contact string `json:"contact" binding:"required"` Reason string `json:"reason" binding:"required"` // Why admin is updating } // ✅ FIXED: UpdateClaim with correct types and fields func (s *ClaimService) UpdateClaim(adminID, claimID uint, req UpdateClaimRequest, ipAddress, userAgent string) (*models.ClaimResponse, error) { // Get existing claim var claim models.Claim if err := s.db.Preload("Item").Preload("User").First(&claim, claimID).Error; err != nil { return nil, errors.New("claim not found") } // Only allow updating pending claims if claim.Status != models.ClaimStatusPending { return nil, errors.New("can only update pending claims") } // Store old values for audit oldDescription := claim.Description oldContact := claim.Contact // Update claim claim.Description = req.Description claim.Contact = req.Contact if err := s.db.Save(&claim).Error; err != nil { return nil, err } // ✅ FIX: Create audit log with correct field adminIDPtr := &adminID claimIDPtr := &claimID audit := models.AuditLog{ UserID: adminIDPtr, Action: "update_claim", EntityType: "claims", EntityID: claimIDPtr, IPAddress: ipAddress, UserAgent: userAgent, Details: fmt.Sprintf("Updated claim #%d. Reason: %s. Changes: description='%s'->'%s', contact='%s'->'%s'", claimID, req.Reason, oldDescription, req.Description, oldContact, req.Contact), } s.db.Create(&audit) // Get updated claim with all relations var updatedClaim models.Claim if err := s.db. Preload("Item"). Preload("User"). Preload("VerifiedBy"). First(&updatedClaim, claimID).Error; err != nil { return nil, err } // ✅ FIX: Return pointer to ClaimResponse response := updatedClaim.ToResponse() return &response, nil } func (s *ClaimService) ProcessUserDecision(userID uint, claimID uint, action string) error { var claim models.Claim // Preload LostItem untuk memastikan relasi benar if err := s.db.Preload("Item").Preload("LostItem").First(&claim, claimID).Error; err != nil { return errors.New("klaim tidak ditemukan") } // Validasi: Apakah ini Direct Claim? if claim.LostItemID == nil { return errors.New("ini bukan direct claim, user tidak bisa approve manual (harus manager)") } // Cek apakah User yang me-request adalah pemilik Lost Item if claim.LostItem.UserID != userID { return errors.New("anda tidak memiliki akses ke laporan kehilangan ini") } // Cek Status Klaim if claim.Status != models.ClaimStatusWaitingOwner { return errors.New("klaim ini tidak dalam status menunggu persetujuan anda") } tx := s.db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() if action == "approve" { // Owner approve -> status jadi VERIFIED (Agar muncul tombol Case Closed) claim.Status = models.ClaimStatusVerified claim.Notes = "Disetujui langsung oleh pemilik (Direct Match)" now := time.Now() claim.VerifiedAt = &now claim.VerifiedBy = &userID // Pemilik bertindak sebagai verifikator // Update lost item status to found if err := tx.Model(&models.LostItem{}).Where("id = ?", *claim.LostItemID). Updates(map[string]interface{}{ "status": models.LostItemStatusFound, "matched_at": now, }).Error; err != nil { tx.Rollback() return err } // Notifikasi ke Penemu s.notificationRepo.Notify( claim.UserID, "direct_claim_approved", "Klaim Disetujui Pemilik!", fmt.Sprintf("Pemilik telah menyetujui bahwa Anda menemukan barang '%s'. Silakan hubungi untuk koordinasi pengambilan.", claim.LostItem.Name), "claim", &claim.ID, ) s.auditLogRepo.Log(&userID, "approve_direct_claim", "claim", &claim.ID, "Owner approved direct claim", "", "") } else if action == "reject" { // Owner reject claim.Status = models.ClaimStatusRejected claim.Notes = "Ditolak oleh pemilik (Direct Match)" now := time.Now() claim.VerifiedAt = &now claim.VerifiedBy = &userID // Reset lost item status back to active agar bisa dicari lagi if err := tx.Model(&models.LostItem{}).Where("id = ?", *claim.LostItemID). Updates(map[string]interface{}{ "status": models.LostItemStatusActive, "direct_claim_id": gorm.Expr("NULL"), // Hapus link ke claim ini }).Error; err != nil { tx.Rollback() return err } // Notifikasi ke Penemu s.notificationRepo.Notify( claim.UserID, "direct_claim_rejected", "Klaim Ditolak Pemilik", fmt.Sprintf("Pemilik menolak klaim Anda untuk '%s'. Barang kembali dalam status pencarian.", claim.LostItem.Name), "claim", &claim.ID, ) s.auditLogRepo.Log(&userID, "reject_direct_claim", "claim", &claim.ID, "Owner rejected direct claim", "", "") } if err := tx.Save(&claim).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error } func (s *ClaimService) UserConfirmCompletion(userID uint, claimID uint) error { return s.db.Transaction(func(tx *gorm.DB) error { var claim models.Claim if err := tx.Preload("LostItem").Preload("Item").First(&claim, claimID).Error; err != nil { return err } // 🔥 PERBAIKAN: Cek apakah ini direct claim (dari lost_item) atau regular claim (dari item) if claim.LostItemID != nil { // ✅ Ini direct claim - update lost_item var lostItem models.LostItem if err := tx.First(&lostItem, *claim.LostItemID).Error; err != nil { return errors.New("lost item tidak ditemukan") } if lostItem.UserID != userID { return errors.New("unauthorized: anda bukan pemilik barang hilang ini") } now := time.Now() lostItem.Status = models.LostItemStatusCompleted lostItem.MatchedAt = &now if err := tx.Save(&lostItem).Error; err != nil { return err } // ✅ TAMBAHAN: Update claim status jadi "completed" claim.Status = "completed" claim.Notes = claim.Notes + " [Case Closed by Owner Confirmation]" if err := tx.Save(&claim).Error; err != nil { return err } } else if claim.ItemID != nil { // ✅ PERBAIKAN BARU: Ini regular claim dari "Barang yang Saya Temukan" // User yang MENEMUKAN barang mengonfirmasi sudah menyerahkan ke pemilik var item models.Item if err := tx.First(&item, *claim.ItemID).Error; err != nil { return errors.New("item tidak ditemukan") } // Cek: Apakah user ini adalah PENEMU (reporter)? if item.ReporterID != userID { return errors.New("unauthorized: anda bukan penemu barang ini") } // ✅ Update item status menjadi "case_closed" atau "completed" now := time.Now() item.Status = models.ItemStatusCaseClosed item.CaseClosedAt = &now item.CaseClosedBy = &userID item.CaseClosedNotes = "Barang telah diserahkan ke pemilik (dikonfirmasi oleh penemu)" if err := tx.Save(&item).Error; err != nil { return err } // ✅ Update claim status claim.Status = "completed" claim.Notes = claim.Notes + " [Confirmed: Item handed to owner]" if err := tx.Save(&claim).Error; err != nil { return err } // ✅ Arsipkan item archive := &models.Archive{ ItemID: item.ID, Name: item.Name, CategoryID: item.CategoryID, PhotoURL: item.PhotoURL, Location: item.Location, Description: item.Description, DateFound: item.DateFound, Status: models.ItemStatusCaseClosed, ReporterName: item.ReporterName, ReporterContact: item.ReporterContact, ArchivedReason: "completed_by_finder", ClaimedBy: &claim.UserID, // pemilik yang mengklaim ArchivedAt: now, } if err := tx.Create(archive).Error; err != nil { return err } } else { return errors.New("invalid claim: no item or lost_item attached") } // Notifikasi s.notificationRepo.Notify( claim.UserID, "case_completed", "Kasus Selesai!", "Barang telah dikembalikan. Terima kasih atas partisipasi Anda!", "claim", &claim.ID, ) // Audit log s.auditLogRepo.Log( &userID, "complete_case", "claim", &claim.ID, "User confirmed case completion", "", "", ) return nil }) } // ✅ [MANUAL TRANSACTION EXAMPLE] // VerifyClaimManual adalah versi manual dari VerifyClaim menggunakan Begin, Commit, Rollback func (s *ClaimService) VerifyClaimManual(managerID, claimID uint, req VerifyClaimRequest, similarityScore float64, matchedKeywords string, ipAddress, userAgent string) error { // 1. BEGIN TRANSACTION // Memulai transaksi dan mendapatkan object 'tx' (database handle khusus transaksi ini) tx := s.db.Begin() // Cek apakah begin berhasil if tx.Error != nil { return tx.Error } // 2. DEFER ROLLBACK // Pastikan Rollback dipanggil jika fungsi berhenti tiba-tiba (panic atau return error sebelum commit) defer func() { if r := recover(); r != nil { tx.Rollback() } }() // --- Mulai Logic Bisnis (Gunakan 'tx' bukan 's.db') --- // Lock claim var claim models.Claim if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Preload("Item"). First(&claim, claimID).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return errors.New("claim not found or locked") } if !claim.IsPending() { tx.Rollback() // ❌ Manual Rollback return errors.New("claim is not pending") } // Update Verification Data var verification models.ClaimVerification if err := tx.Where("claim_id = ?", claimID).First(&verification).Error; err == nil { // Update existing verification.VerificationNotes = req.Notes if err := tx.Save(&verification).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return err } } else { // Create new newVerification := models.ClaimVerification{ ClaimID: claimID, SimilarityScore: similarityScore, VerificationNotes: req.Notes, } if err := tx.Create(&newVerification).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return err } } // Update Claim & Item Status now := time.Now() if req.Status == models.ClaimStatusApproved { // Update Claim claim.Status = models.ClaimStatusApproved claim.VerifiedBy = &managerID claim.VerifiedAt = &now if err := tx.Save(&claim).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return err } // Update Item if err := tx.Model(&models.Item{}). Where("id = ?", claim.ItemID). Update("status", models.ItemStatusVerified).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return err } } else if req.Status == models.ClaimStatusRejected { claim.Status = models.ClaimStatusRejected claim.VerifiedBy = &managerID claim.VerifiedAt = &now if err := tx.Save(&claim).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return err } } // Log Audit auditLog := &models.AuditLog{ UserID: &managerID, Action: "verify_manual", EntityType: "claim", EntityID: &claimID, Details: "Verified using manual transaction", IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { tx.Rollback() // ❌ Manual Rollback return err } // 3. COMMIT TRANSACTION // Jika semua logic di atas sukses tanpa return error, lakukan Commit permanen if err := tx.Commit().Error; err != nil { return err } return nil } // ✅ FIXED: CreateClaim with proper error handling func (s *ClaimService) CreateClaim(userID uint, req CreateClaimRequest, ipAddress, userAgent string) (*models.Claim, error) { // If admin provides a different user_id, use that actualUserID := userID if req.UserID != nil && *req.UserID > 0 { // Verify user exists var targetUser models.User if err := s.db.First(&targetUser, *req.UserID).Error; err != nil { return nil, errors.New("target user not found") } actualUserID = *req.UserID } // Get item var item models.Item if err := s.db.First(&item, req.ItemID).Error; err != nil { return nil, errors.New("item not found") } // Check if item can be claimed if item.Status != models.ItemStatusUnclaimed && item.Status != models.ItemStatusPendingClaim { return nil, errors.New("item cannot be claimed") } // Prevent claiming own reported items if item.ReporterID == actualUserID { return nil, errors.New("you cannot claim your own reported item") } // Check for duplicate claims var existingClaim models.Claim err := s.db.Where("item_id = ? AND user_id = ? AND status = ?", req.ItemID, actualUserID, models.ClaimStatusPending). First(&existingClaim).Error if err == nil { return nil, errors.New("you already have a pending claim for this item") } // ✅ FIX: Create claim without MatchPercentage if it doesn't exist // Check if your Claim model has MatchPercentage field itemID := req.ItemID claim := models.Claim{ ItemID: &itemID, // Set ItemID pointer UserID: actualUserID, Description: req.Description, Contact: req.Contact, ProofURL: req.ProofURL, Status: models.ClaimStatusPending, } if err := s.db.Create(&claim).Error; err != nil { return nil, err } // Update item status item.Status = models.ItemStatusPendingClaim s.db.Save(&item) // ✅ FIX: Create audit log with correct fields userIDPtr := &userID claimIDPtr := &claim.ID description := fmt.Sprintf("Created claim for item #%d", req.ItemID) if req.AdminNotes != "" { description += fmt.Sprintf(" (Admin notes: %s)", req.AdminNotes) } audit := models.AuditLog{ UserID: userIDPtr, // The creator (might be admin) Action: "create_claim", EntityType: "claims", EntityID: claimIDPtr, IPAddress: ipAddress, UserAgent: userAgent, Details: description, } s.db.Create(&audit) // Reload with relations s.db.Preload("Item").Preload("User").First(&claim, claim.ID) return &claim, nil } // GetAllClaims gets all claims with CONTEXT TIMEOUT func (s *ClaimService) GetAllClaims(page, limit int, status string, itemID, userID *uint) ([]models.ClaimResponse, int64, error) { // ✅ 1. Definisikan context dan timeout di sini ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // ✅ 2. Gunakan ctx saat inisialisasi repository txRepo := repositories.NewClaimRepository(s.db.WithContext(ctx)) // 3. Panggil repository claims, total, err := txRepo.FindAll(page, limit, status, itemID, userID) if err != nil { return nil, 0, err } // 4. Konversi ke response responses := make([]models.ClaimResponse, 0) for _, claim := range claims { responses = append(responses, claim.ToResponse()) } return responses, total, nil } // GetClaimByID gets claim by ID with CONTEXT func (s *ClaimService) GetClaimByID(id uint, isManager bool) (interface{}, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var claim models.Claim query := s.db.WithContext(ctx). Preload("Item").Preload("Item.Category"). Preload("LostItem"). Preload("User").Preload("User.Role"). Preload("Verifier").Preload("Verifier.Role"). Preload("Verification") if err := query.First(&claim, id).Error; err != nil { if ctx.Err() == context.DeadlineExceeded { return nil, errors.New("request timeout") } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("claim not found") } return nil, err } if isManager { return claim.ToDetailResponse(), nil } return claim.ToResponse(), nil } // GetClaimsByUser gets claims by user with CONTEXT func (s *ClaimService) GetClaimsByUser(userID uint, page, limit int) ([]models.ClaimResponse, int64, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() txRepo := repositories.NewClaimRepository(s.db.WithContext(ctx)) claims, total, err := txRepo.FindByUser(userID, page, limit) if err != nil { if ctx.Err() == context.DeadlineExceeded { return nil, 0, errors.New("request timeout") } return nil, 0, err } var responses []models.ClaimResponse for _, claim := range claims { responses = append(responses, claim.ToResponse()) } return responses, total, nil } // VerifyClaim verifies a claim with TRANSACTION + LOCKING // VerifyClaim verifies a claim with TRANSACTION + LOCKING func (s *ClaimService) VerifyClaim(managerID, claimID uint, req VerifyClaimRequest, similarityScore float64, matchedKeywords string, ipAddress, userAgent string) error { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Lock claim untuk prevent concurrent verification var claim models.Claim if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Preload("Item").Preload("LostItem"). // ✅ Load dua-duanya Preload("User"). Where("id = ? AND deleted_at IS NULL", claimID). First(&claim).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("claim not found") } return fmt.Errorf("failed to lock claim: %w", err) } if !claim.IsPending() { return errors.New("claim is not pending (status: " + claim.Status + ")") } // 1. Create or update verification record var verification models.ClaimVerification err := tx.Where("claim_id = ?", claimID).First(&verification).Error if errors.Is(err, gorm.ErrRecordNotFound) { verification = models.ClaimVerification{ ClaimID: claimID, SimilarityScore: similarityScore, MatchedKeywords: matchedKeywords, VerificationNotes: req.Notes, IsAutoMatched: false, } if err := tx.Create(&verification).Error; err != nil { return fmt.Errorf("failed to create verification: %w", err) } } else if err != nil { return fmt.Errorf("failed to check verification: %w", err) } else { verification.VerificationNotes = req.Notes verification.SimilarityScore = similarityScore verification.MatchedKeywords = matchedKeywords if err := tx.Save(&verification).Error; err != nil { return fmt.Errorf("failed to update verification: %w", err) } } // 2. Update claim status now := time.Now() if req.Status == models.ClaimStatusApproved { claim.Status = models.ClaimStatusApproved claim.VerifiedBy = &managerID claim.VerifiedAt = &now claim.Notes = req.Notes // ✅ NEW: Update item status to verified if err := tx.Model(&models.Item{}). Where("id = ?", claim.ItemID). Update("status", models.ItemStatusVerified).Error; err != nil { return fmt.Errorf("failed to update item status: %w", err) } result := tx.Model(&models.LostItem{}). Where("user_id = ? AND category_id = ? AND status = ?", claim.UserID, claim.Item.CategoryID, models.LostItemStatusActive). Updates(map[string]interface{}{ "status": models.LostItemStatusFound, // Sesuai request Anda "matched_at": now, // Opsional: mencatat kapan match terjadi }) if result.Error != nil { return fmt.Errorf("failed to update lost item status: %w", result.Error) } // ✅ NEW: Cari apakah ada lost_item yang match dengan item ini // Caranya: Cek di match_results apakah ada match antara item_id dan lost_item_id var matchResults []models.MatchResult if err := tx.Where("item_id = ? AND deleted_at IS NULL", claim.ItemID). Preload("LostItem"). Find(&matchResults).Error; err != nil { return fmt.Errorf("failed to find match results: %w", err) } // ✅ NEW: Update semua lost_items yang match menjadi resolved for _, match := range matchResults { if match.LostItem.Status == models.LostItemStatusActive { if err := tx.Model(&models.LostItem{}). Where("id = ?", match.LostItemID). Updates(map[string]interface{}{ "status": models.LostItemStatusFound, "resolved_at": now, }).Error; err != nil { return fmt.Errorf("failed to update lost item status: %w", err) } // ✅ NEW: Kirim notifikasi ke pemilik lost_item notification := &models.Notification{ UserID: match.LostItem.UserID, Type: "lost_item_resolved", Title: "Barang Ditemukan!", Message: fmt.Sprintf("Laporan kehilangan Anda untuk '%s' telah ditemukan dan diklaim oleh pemiliknya", match.LostItem.Name), EntityType: "lost_item", EntityID: &match.LostItemID, } if err := tx.Create(notification).Error; err != nil { return fmt.Errorf("failed to create lost item notification: %w", err) } } } // Create approval notification untuk claimer notification := &models.Notification{ UserID: claim.UserID, Type: models.NotificationClaimApproved, Title: "Klaim Disetujui! Barang Ditemukan", Message: fmt.Sprintf("Selamat! Klaim Anda untuk '%s' telah disetujui. Status laporan kehilangan Anda kini 'Ditemukan'. Silakan hubungi admin untuk pengambilan.", claim.Item.Name), EntityType: models.EntityClaim, EntityID: &claimID, } if err := tx.Create(notification).Error; err != nil { return fmt.Errorf("failed to create notification: %w", err) } } else if req.Status == models.ClaimStatusRejected { claim.Status = models.ClaimStatusRejected claim.VerifiedBy = &managerID claim.VerifiedAt = &now claim.Notes = req.Notes // Check if there are other pending claims var otherPendingCount int64 if err := tx.Model(&models.Claim{}). Where("item_id = ? AND id != ? AND status = ? AND deleted_at IS NULL", claim.ItemID, claimID, models.ClaimStatusPending). Count(&otherPendingCount).Error; err != nil { return fmt.Errorf("failed to check other claims: %w", err) } // If no other pending claims, set item back to unclaimed if otherPendingCount == 0 { if err := tx.Model(&models.Item{}). Where("id = ?", claim.ItemID). Update("status", models.ItemStatusUnclaimed).Error; err != nil { return fmt.Errorf("failed to update item status: %w", err) } } // Create rejection notification notification := &models.Notification{ UserID: claim.UserID, Type: models.NotificationClaimRejected, Title: "Klaim Ditolak", Message: fmt.Sprintf("Klaim Anda untuk barang '%s' ditolak. Alasan: %s", claim.Item.Name, req.Notes), EntityType: models.EntityClaim, EntityID: &claimID, } if err := tx.Create(notification).Error; err != nil { return fmt.Errorf("failed to create notification: %w", err) } } else { return errors.New("invalid status: must be 'approved' or 'rejected'") } // 3. Save claim if err := tx.Save(&claim).Error; err != nil { return fmt.Errorf("failed to update claim: %w", err) } // 4. Create audit log action := models.ActionApprove if req.Status == models.ClaimStatusRejected { action = models.ActionReject } auditLog := &models.AuditLog{ UserID: &managerID, Action: action, EntityType: models.EntityClaim, EntityID: &claimID, Details: fmt.Sprintf("Claim %s: %s", req.Status, req.Notes), IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return fmt.Errorf("failed to create audit log: %w", err) } return nil }) } // CloseClaim closes a claim and archives item with TRANSACTION // Ganti nama fungsi dari CloseClaim menjadi CloseCase func (s *ClaimService) CloseCase(managerID, claimID uint, req CloseCaseRequest, ipAddress, userAgent string) error { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Lock claim var claim models.Claim if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Preload("Item").Preload("Item.Category"). Where("id = ? AND deleted_at IS NULL", claimID). First(&claim).Error; err != nil { return fmt.Errorf("failed to lock claim: %w", err) } // Validasi Status if !claim.IsApproved() { return errors.New("only approved claims can be closed") } if claim.ItemID == nil { return errors.New("direct claims (lost items) should be closed via user confirmation, not admin close case") } if claim.Item.Status == models.ItemStatusCaseClosed { return errors.New("case already closed") } item := claim.Item // ✅ 1. Update item status (Barang Temuan selesai) if err := tx.Model(&item).Updates(map[string]interface{}{ "status": models.ItemStatusCaseClosed, "berita_acara_no": req.BeritaAcaraNo, "bukti_serah_terima": req.BuktiSerahTerima, "case_closed_at": time.Now(), "case_closed_by": managerID, "case_closed_notes": req.Notes, }).Error; err != nil { return fmt.Errorf("failed to close case: %w", err) } // ✅ 2. Update status LostItem (Laporan Kehilangan selesai) // Kita cari lost_item milik user ini, kategori sama, yang statusnya 'found' if err := tx.Model(&models.LostItem{}). Where("user_id = ? AND category_id = ? AND status = ?", claim.UserID, item.CategoryID, models.LostItemStatusFound). Update("status", models.LostItemStatusClosed).Error; err != nil { // Gunakan constant model jika ada return fmt.Errorf("failed to update lost item status to closed: %w", err) } // ✅ 3. Manage Archive (Create or Update) var existingArchive models.Archive err := tx.Where("item_id = ?", item.ID).First(&existingArchive).Error if err == nil { // Archive exists, UPDATE it if err := tx.Model(&existingArchive).Updates(map[string]interface{}{ "status": models.ItemStatusCaseClosed, "archived_reason": models.ArchiveReasonCaseClosed, "claimed_by": &claim.UserID, "berita_acara_no": req.BeritaAcaraNo, "bukti_serah_terima": req.BuktiSerahTerima, "archived_at": time.Now(), "name": item.Name, "category_id": item.CategoryID, "photo_url": item.PhotoURL, "location": item.Location, "description": item.Description, "date_found": item.DateFound, "reporter_name": item.ReporterName, "reporter_contact": item.ReporterContact, }).Error; err != nil { return fmt.Errorf("failed to update archive: %w", err) } } else if errors.Is(err, gorm.ErrRecordNotFound) { // Archive doesn't exist, CREATE new one archive := &models.Archive{ ItemID: item.ID, Name: item.Name, CategoryID: item.CategoryID, PhotoURL: item.PhotoURL, Location: item.Location, Description: item.Description, DateFound: item.DateFound, Status: models.ItemStatusCaseClosed, ReporterName: item.ReporterName, ReporterContact: item.ReporterContact, ArchivedReason: models.ArchiveReasonCaseClosed, ClaimedBy: &claim.UserID, BeritaAcaraNo: req.BeritaAcaraNo, BuktiSerahTerima: req.BuktiSerahTerima, ArchivedAt: time.Now(), } if err := tx.Create(archive).Error; err != nil { return fmt.Errorf("failed to create archive: %w", err) } } else { return fmt.Errorf("failed to check archive: %w", err) } // ✅ 4. Create Revision Log revisionLog := &models.RevisionLog{ ItemID: item.ID, UserID: managerID, FieldName: "status", OldValue: models.ItemStatusVerified, NewValue: models.ItemStatusCaseClosed, Reason: fmt.Sprintf("Case closed with BA No: %s", req.BeritaAcaraNo), } if err := tx.Create(revisionLog).Error; err != nil { return fmt.Errorf("failed to create revision log: %w", err) } // ✅ 5. Create Audit Log auditLog := &models.AuditLog{ UserID: &managerID, Action: "close_case", // Action name updated EntityType: models.EntityItem, EntityID: &item.ID, Details: fmt.Sprintf("Case closed (Claim ID: %d, BA No: %s)", claimID, req.BeritaAcaraNo), IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return fmt.Errorf("failed to create audit log: %w", err) } // ✅ 6. Send Notification notification := &models.Notification{ UserID: claim.UserID, Type: "case_closed", Title: "Kasus Selesai!", Message: fmt.Sprintf("Kasus untuk barang '%s' telah selesai. Barang resmi diserahterimakan.", item.Name), EntityType: models.EntityClaim, EntityID: &claimID, } if err := tx.Create(notification).Error; err != nil { return fmt.Errorf("failed to create notification: %w", err) } return nil }) } func (s *ClaimService) ReopenCase(managerID, claimID uint, req ReopenCaseRequest, ipAddress, userAgent string) error { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. Lock & Load Claim dengan Item DAN LostItem var claim models.Claim if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Preload("Item"). Preload("LostItem"). // ✅ PENTING: Load LostItem juga Where("id = ? AND deleted_at IS NULL", claimID). First(&claim).Error; err != nil { return fmt.Errorf("failed to lock claim: %w", err) } // 2. Validasi & Revert Logic Berdasarkan Tipe Claim itemName := "Unknown Item" if claim.ItemID != nil { // --- OPSI A: REGULAR CLAIM (Barang Temuan) --- itemName = claim.Item.Name if claim.Item.Status != models.ItemStatusCaseClosed { return errors.New("only closed cases can be reopened") } // Revert Item Status ke 'Verified' (Karena Claim statusnya masih Approved) // Kita kembalikan ke Verified, bukan Pending, agar konsisten dengan Claim.Status = Approved if err := tx.Model(&claim.Item).Updates(map[string]interface{}{ "status": models.ItemStatusVerified, "case_closed_at": nil, "case_closed_by": nil, "case_closed_notes": "", "berita_acara_no": "", "bukti_serah_terima": "", }).Error; err != nil { return fmt.Errorf("failed to reopen item case: %w", err) } // Delete Archive (Karena kasus dibuka lagi, arsip harus dihapus) if err := tx.Unscoped().Where("item_id = ?", *claim.ItemID).Delete(&models.Archive{}).Error; err != nil { return fmt.Errorf("failed to delete archive: %w", err) } // Create Revision Log revisionLog := &models.RevisionLog{ ItemID: *claim.ItemID, UserID: managerID, FieldName: "status", OldValue: models.ItemStatusCaseClosed, NewValue: models.ItemStatusVerified, Reason: "Case reopened: " + req.Reason, } tx.Create(revisionLog) } else if claim.LostItemID != nil { // --- OPSI B: DIRECT CLAIM (Barang Hilang) --- itemName = claim.LostItem.Name // Cek apakah statusnya Closed? if claim.LostItem.Status != models.LostItemStatusClosed { return errors.New("only closed lost item cases can be reopened") } // Revert Lost Item Status ke 'Found' (Status sebelum Closed) if err := tx.Model(&claim.LostItem).Updates(map[string]interface{}{ "status": models.LostItemStatusFound, }).Error; err != nil { return fmt.Errorf("failed to reopen lost item case: %w", err) } } else { return errors.New("invalid claim data: no item or lost_item attached") } // 3. Create Audit Log auditLog := &models.AuditLog{ UserID: &managerID, Action: "reopen_case", EntityType: models.EntityClaim, EntityID: &claimID, Details: "Case reopened. Reason: " + req.Reason, IPAddress: ipAddress, UserAgent: userAgent, } if err := tx.Create(auditLog).Error; err != nil { return err } // 4. Send Notification notification := &models.Notification{ UserID: claim.UserID, Type: "case_reopened", Title: "Kasus Dibuka Kembali", Message: fmt.Sprintf("Kasus untuk barang '%s' dibuka kembali oleh admin. Alasan: %s", itemName, req.Reason), EntityType: models.EntityClaim, EntityID: &claimID, } tx.Create(notification) return nil }) } // Atomicity & Consistency // Penggunaan db.Transaction (misalnya di VerifyClaim ) memastikan pembaruan status item dan klaim terjadi dalam satu unit kerja yang utuh. func (s *ClaimService) CancelClaimApproval(managerID, claimID uint) error { return s.db.Transaction(func(tx *gorm.DB) error { var claim models.Claim if err := tx.Preload("Item").First(&claim, claimID).Error; err != nil { return err } // Hanya boleh cancel jika status Approved if claim.Status != models.ClaimStatusApproved { return errors.New("claim is not in approved status") } // Cek jika case sudah closed (ada BA), tidak boleh cancel lewat sini (pakai Reopen) if claim.Item.Status == models.ItemStatusCaseClosed { return errors.New("case is already closed, use Reopen instead") } // 1. Revert Claim Status to Pending claim.Status = models.ClaimStatusPending claim.VerifiedAt = nil claim.VerifiedBy = nil claim.Notes = claim.Notes + " [Approval Cancelled]" if err := tx.Save(&claim).Error; err != nil { return err } // 2. Revert Item Status to Unclaimed (or Pending Claim) // Karena klaim jadi pending, item harusnya pending_claim (dikunci oleh klaim ini) if err := tx.Model(&models.Item{}). Where("id = ?", claim.ItemID). Update("status", models.ItemStatusUnclaimed).Error; err != nil { // Atau pending_claim return err } // 3. Log Audit audit := models.AuditLog{ UserID: &managerID, Action: "cancel_approval", EntityType: "claim", EntityID: &claimID, Details: "Manager cancelled approval, reverted to pending", } tx.Create(&audit) return nil }) } // DeleteClaim handles deletion safely for both Regular and Direct claims func (s *ClaimService) DeleteClaim(userID, claimID uint, ipAddress, userAgent string) error { var claim models.Claim if err := s.db.First(&claim, claimID).Error; err != nil { return errors.New("claim not found") } var user models.User if err := s.db.Preload("Role").First(&user, userID).Error; err != nil { return errors.New("user not found") } // Check permissions if !user.IsAdmin() && !user.IsManager() && claim.UserID != userID { return errors.New("you don't have permission to delete this claim") } // ===== FIX: Tambahkan completed ke daftar status yang bisa dihapus ===== // Admin/Manager bisa hapus klaim dengan status: pending, waiting_owner, completed // User biasa hanya bisa hapus klaim miliknya yang pending atau waiting_owner isAdminOrManager := user.IsAdmin() || user.IsManager() if isAdminOrManager { // Admin/Manager bisa hapus klaim dengan status: pending, waiting_owner, approved, completed // Hanya tidak bisa hapus yang rejected (untuk audit trail) if claim.Status == models.ClaimStatusRejected { return errors.New("cannot delete rejected claims (audit purposes)") } } else { // User biasa hanya bisa hapus klaim miliknya yang pending atau waiting_owner if claim.Status != models.ClaimStatusPending && claim.Status != models.ClaimStatusWaitingOwner { return errors.New("you can only delete your own pending or waiting_owner claims") } } logDetailID := uint(0) logDetailType := "unknown" // Handle regular claim (item-based) if claim.ItemID != nil { logDetailID = *claim.ItemID logDetailType = "item" // Only reset item status if this is the last pending claim var otherClaimsCount int64 s.db.Model(&models.Claim{}). Where("item_id = ? AND id != ? AND status = ?", *claim.ItemID, claimID, models.ClaimStatusPending). Count(&otherClaimsCount) if otherClaimsCount == 0 { if err := s.db.Model(&models.Item{}). Where("id = ?", *claim.ItemID). Update("status", models.ItemStatusUnclaimed).Error; err != nil { return err } } } else if claim.LostItemID != nil { // Handle direct claim (lost item-based) logDetailID = *claim.LostItemID logDetailType = "lost_item" // Reset lost item status back to active if err := s.db.Model(&models.LostItem{}). Where("id = ?", *claim.LostItemID). Updates(map[string]interface{}{ "status": models.LostItemStatusActive, "direct_claim_id": nil, }).Error; err != nil { return err } } // Delete the claim if err := s.db.Delete(&claim).Error; err != nil { return err } // Create audit log userIDPtr := &userID claimIDPtr := &claimID audit := models.AuditLog{ UserID: userIDPtr, Action: "delete_claim", EntityType: "claims", EntityID: claimIDPtr, IPAddress: ipAddress, UserAgent: userAgent, Details: fmt.Sprintf("Deleted claim #%d (status: %s) for %s #%d", claimID, claim.Status, logDetailType, logDetailID), } s.db.Create(&audit) return nil }