1202 lines
40 KiB
Go
1202 lines
40 KiB
Go
// 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
|
|
} |