Basdat/internal/services/claim_service.go
2025-12-20 00:01:08 +07:00

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
}