Initial commit - Lost and Found Revisi

This commit is contained in:
bambang-code1
2025-12-20 00:01:08 +07:00
commit f4c838471c
210 changed files with 30706 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
package services
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"net/http"
"os"
"strings"
"gorm.io/gorm"
)
type AIService struct {
db *gorm.DB
chatRepo *repositories.ChatRepository
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository
groqAPIKey string
groqModel string
}
func NewAIService(db *gorm.DB) *AIService {
model := os.Getenv("GROQ_MODEL")
if model == "" {
model = "llama-3.3-70b-versatile" // Default model
}
return &AIService{
db: db,
chatRepo: repositories.NewChatRepository(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db),
groqAPIKey: os.Getenv("GROQ_API_KEY"),
groqModel: model,
}
}
type ChatRequest struct {
Message string `json:"message" binding:"required"`
}
// Groq API Request Structure
type GroqRequest struct {
Model string `json:"model"`
Messages []GroqMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
}
type GroqMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Groq API Response Structure
type GroqResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func (s *AIService) ProcessChat(userID uint, message string) (*models.ChatMessage, error) {
// Build context from user data
context, err := s.buildUserContext(userID, message)
if err != nil {
return nil, err
}
// Detect intent
intent := s.detectIntent(message)
// Build prompt with context
systemPrompt := s.buildSystemPrompt()
userPrompt := s.buildUserPrompt(message, context, intent)
// Call Groq API
response, err := s.callGroqAPI(systemPrompt, userPrompt)
if err != nil {
return nil, err
}
// Save to database
chat := &models.ChatMessage{
UserID: userID,
Message: message,
Response: response,
Intent: intent,
ConfidenceScore: 85.0,
}
if err := s.chatRepo.Create(chat); err != nil {
return nil, err
}
return chat, nil
}
func (s *AIService) buildUserContext(userID uint, message string) (string, error) {
var context strings.Builder
// Get user's lost items
lostItems, _, _ := s.lostItemRepo.FindByUser(userID, 1, 5)
if len(lostItems) > 0 {
context.WriteString("\n📋 Barang yang dilaporkan hilang:\n")
for _, item := range lostItems {
context.WriteString(fmt.Sprintf("- %s (%s) - Status: %s\n",
item.Name, item.Category.Name, item.Status))
}
}
// Search for relevant found items if user is looking for something
if strings.Contains(strings.ToLower(message), "cari") ||
strings.Contains(strings.ToLower(message), "temukan") {
items, _, _ := s.itemRepo.FindAll(1, 5, "unclaimed", "", message)
if len(items) > 0 {
context.WriteString("\n🔍 Barang ditemukan yang relevan:\n")
for _, item := range items {
context.WriteString(fmt.Sprintf("- ID: %d, %s (%s) - Lokasi: %s\n",
item.ID, item.Name, item.Category.Name, item.Location))
}
}
}
return context.String(), nil
}
func (s *AIService) detectIntent(message string) string {
msgLower := strings.ToLower(message)
searchKeywords := []string{"cari", "temukan", "ada", "lihat", "ditemukan"}
reportKeywords := []string{"hilang", "kehilangan", "lapor", "laporkan"}
claimKeywords := []string{"klaim", "ambil", "punya saya", "milik saya"}
for _, kw := range searchKeywords {
if strings.Contains(msgLower, kw) {
return models.IntentSearchItem
}
}
for _, kw := range reportKeywords {
if strings.Contains(msgLower, kw) {
return models.IntentReportLost
}
}
for _, kw := range claimKeywords {
if strings.Contains(msgLower, kw) {
return models.IntentClaimHelp
}
}
return models.IntentGeneral
}
func (s *AIService) buildSystemPrompt() string {
return `Kamu adalah asisten AI untuk sistem Lost & Found kampus bernama "FindItBot".
Tugasmu adalah membantu mahasiswa dan staff dengan:
1. 🔍 Mencari barang yang hilang/ditemukan
2. 📝 Memandu proses pelaporan barang hilang
3. ✅ Menjelaskan proses klaim barang
4. ❓ Menjawab pertanyaan umum tentang sistem
Aturan penting:
- Jawab dengan ramah, profesional, dan membantu
- Gunakan Bahasa Indonesia yang jelas
- Jika ada data barang yang relevan, sebutkan ID dan detailnya
- Untuk pelaporan, tanyakan: nama barang, kategori, lokasi, tanggal hilang, deskripsi
- Untuk klaim, jelaskan proses verifikasi yang diperlukan
- Gunakan emoji yang sesuai untuk memperjelas informasi
- Prioritaskan informasi dari konteks yang diberikan
Contoh respons yang baik:
"🔍 Saya menemukan 2 barang yang mungkin cocok:
1. ID: 123 - Dompet Kulit (Kategori: Wallet) - Ditemukan di Perpustakaan
2. ID: 124 - Dompet Hitam (Kategori: Wallet) - Ditemukan di Kantin
Apakah salah satu dari ini milik Anda? Anda bisa klaim dengan menyebutkan ID barangnya."`
}
func (s *AIService) buildUserPrompt(message, context, intent string) string {
var prompt strings.Builder
if context != "" {
prompt.WriteString("KONTEKS PENGGUNA:\n")
prompt.WriteString(context)
prompt.WriteString("\n\n")
}
prompt.WriteString(fmt.Sprintf("INTENT TERDETEKSI: %s\n\n", intent))
prompt.WriteString(fmt.Sprintf("PERTANYAAN: %s\n\n", message))
prompt.WriteString("Berikan respons yang membantu berdasarkan konteks di atas.")
return prompt.String()
}
func (s *AIService) callGroqAPI(systemPrompt, userPrompt string) (string, error) {
if s.groqAPIKey == "" {
return "", errors.New("GROQ_API_KEY not configured")
}
url := "https://api.groq.com/openai/v1/chat/completions"
reqBody := GroqRequest{
Model: s.groqModel,
Messages: []GroqMessage{
{
Role: "system",
Content: systemPrompt,
},
{
Role: "user",
Content: userPrompt,
},
},
Temperature: 0.7,
MaxTokens: 1024,
TopP: 0.95,
Stream: false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %v", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+s.groqAPIKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to call Groq API: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Groq API error (status %d): %s", resp.StatusCode, string(body))
}
var groqResp GroqResponse
if err := json.Unmarshal(body, &groqResp); err != nil {
return "", fmt.Errorf("failed to parse response: %v", err)
}
if len(groqResp.Choices) == 0 {
return "", errors.New("no response from Groq API")
}
return groqResp.Choices[0].Message.Content, nil
}
func (s *AIService) GetChatHistory(userID uint, limit int) ([]models.ChatMessageResponse, error) {
chats, err := s.chatRepo.GetUserChatHistory(userID, limit)
if err != nil {
return nil, err
}
var responses []models.ChatMessageResponse
for _, chat := range chats {
responses = append(responses, chat.ToResponse())
}
return responses, nil
}
func (s *AIService) ClearChatHistory(userID uint) error {
return s.chatRepo.DeleteUserHistory(userID)
}
+68
View File
@@ -0,0 +1,68 @@
// internal/services/archive_service.go
package services
import (
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type ArchiveService struct {
archiveRepo *repositories.ArchiveRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewArchiveService(db *gorm.DB) *ArchiveService {
return &ArchiveService{
archiveRepo: repositories.NewArchiveRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// GetAllArchives gets all archived items
func (s *ArchiveService) GetAllArchives(page, limit int, reason, search string) ([]models.ArchiveResponse, int64, error) {
archives, total, err := s.archiveRepo.FindAll(page, limit, reason, search)
if err != nil {
return nil, 0, err
}
var responses []models.ArchiveResponse
for _, archive := range archives {
responses = append(responses, archive.ToResponse())
}
return responses, total, nil
}
// GetArchiveByID gets archive by ID
func (s *ArchiveService) GetArchiveByID(id uint) (*models.Archive, error) {
return s.archiveRepo.FindByID(id)
}
// GetArchiveByItemID gets archive by original item ID
func (s *ArchiveService) GetArchiveByItemID(itemID uint) (*models.Archive, error) {
return s.archiveRepo.FindByItemID(itemID)
}
// GetArchiveStats gets archive statistics
func (s *ArchiveService) GetArchiveStats() (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Count by reason
expiredCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonExpired)
if err != nil {
return nil, err
}
stats["expired"] = expiredCount
caseClosedCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonCaseClosed)
if err != nil {
return nil, err
}
stats["case_closed"] = caseClosedCount
stats["total"] = expiredCount + caseClosedCount
return stats, nil
}
+69
View File
@@ -0,0 +1,69 @@
// internal/services/audit_service.go
package services
import (
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type AuditService struct {
auditLogRepo *repositories.AuditLogRepository
}
func NewAuditService(db *gorm.DB) *AuditService {
return &AuditService{
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// GetAllAuditLogs gets all audit logs
func (s *AuditService) GetAllAuditLogs(page, limit int, action, entityType string, userID *uint) ([]models.AuditLogResponse, int64, error) {
logs, total, err := s.auditLogRepo.FindAll(page, limit, action, entityType, userID)
if err != nil {
return nil, 0, err
}
var responses []models.AuditLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
// GetAuditLogsByUser gets audit logs by user
func (s *AuditService) GetAuditLogsByUser(userID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
logs, total, err := s.auditLogRepo.FindByUser(userID, page, limit)
if err != nil {
return nil, 0, err
}
var responses []models.AuditLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
// GetAuditLogsByEntity gets audit logs by entity
func (s *AuditService) GetAuditLogsByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
logs, total, err := s.auditLogRepo.FindByEntity(entityType, entityID, page, limit)
if err != nil {
return nil, 0, err
}
var responses []models.AuditLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
// LogAction creates a new audit log entry
func (s *AuditService) LogAction(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
return s.auditLogRepo.Log(userID, action, entityType, entityID, details, ipAddress, userAgent)
}
+286
View File
@@ -0,0 +1,286 @@
package services
import (
"errors"
"lost-and-found/internal/config"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
// --- 1. Definisi Interface (Contract) ---
// Interface ini mendefinisikan method apa saja yang dibutuhkan oleh Service dari Repository.
// Dengan ini, kita bisa menukar Repository asli dengan Mock Repository saat testing.
type IUserRepository interface {
FindByEmail(email string) (*models.User, error)
FindByNRP(nrp string) (*models.User, error)
FindByID(id uint) (*models.User, error)
Create(user *models.User) error
}
type IRoleRepository interface {
FindByName(name string) (*models.Role, error)
}
type IAuditLogRepository interface {
Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error
}
// --- 2. Struct AuthService dengan Dependency Injection ---
type AuthService struct {
userRepo IUserRepository // Menggunakan Interface, bukan struct konkret *repositories.UserRepository
roleRepo IRoleRepository // Menggunakan Interface
auditLogRepo IAuditLogRepository // Menggunakan Interface
logger *zap.Logger
}
// NewAuthService menginisialisasi service dengan repository asli (untuk Production)
func NewAuthService(db *gorm.DB, logger *zap.Logger) *AuthService {
// ✅ Inisialisasi Enkripsi (Bonus Keamanan)
if err := utils.InitEncryption(); err != nil {
logger.Fatal("Failed to initialize encryption", zap.Error(err))
}
return &AuthService{
// Struct repository asli secara otomatis memenuhi interface (duck typing)
userRepo: repositories.NewUserRepository(db),
roleRepo: repositories.NewRoleRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
logger: logger,
}
}
// --- 3. Struct Request & Response ---
type RegisterRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
NRP string `json:"nrp"`
Phone string `json:"phone"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User models.UserResponse `json:"user"`
}
// --- 4. Implementasi Method Service ---
// Register menangani pendaftaran user baru
func (s *AuthService) Register(req RegisterRequest, ipAddress, userAgent string) (*AuthResponse, error) {
s.logger.Info("Registration attempt",
zap.String("email", req.Email),
zap.String("name", req.Name),
zap.String("ip_address", ipAddress),
)
// Cek apakah email sudah terdaftar
existingUser, _ := s.userRepo.FindByEmail(req.Email)
if existingUser != nil {
s.logger.Warn("Registration failed: email already exists",
zap.String("email", req.Email),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("email already registered")
}
// Cek apakah NRP sudah terdaftar (jika ada)
if req.NRP != "" {
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
if existingNRP != nil {
s.logger.Warn("Registration failed: NRP already exists",
zap.String("email", req.Email),
)
return nil, errors.New("NRP already registered")
}
}
// Hash password
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
s.logger.Error("Failed to hash password",
zap.String("email", req.Email),
zap.Error(err),
)
return nil, errors.New("failed to hash password")
}
// Ambil role default "user"
userRole, err := s.roleRepo.FindByName(models.RoleUser)
if err != nil {
s.logger.Error("Failed to get user role", zap.Error(err))
return nil, errors.New("failed to get user role")
}
// Buat objek User
user := &models.User{
Name: req.Name,
Email: req.Email,
Password: hashedPassword,
NRP: req.NRP, // Disimpan plain text (atau terenkripsi jika model mendukung hook)
Phone: req.Phone, // Disimpan plain text
RoleID: userRole.ID,
Status: "active",
}
// Simpan ke database
if err := s.userRepo.Create(user); err != nil {
s.logger.Error("Failed to create user",
zap.String("email", req.Email),
zap.Error(err),
)
return nil, errors.New("failed to create user")
}
// Muat ulang user untuk mendapatkan relasi Role yang lengkap
user, err = s.userRepo.FindByID(user.ID)
if err != nil {
s.logger.Error("Failed to load user",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
return nil, err
}
// Generate JWT Token
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
if err != nil {
s.logger.Error("Failed to generate token",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
return nil, errors.New("failed to generate token")
}
// Log Audit
s.auditLogRepo.Log(&user.ID, models.ActionCreate, models.EntityUser, &user.ID,
"User registered", ipAddress, userAgent)
s.logger.Info("Registration successful",
zap.Uint("user_id", user.ID),
zap.String("email", user.Email),
zap.String("ip_address", ipAddress),
)
return &AuthResponse{
Token: token,
User: user.ToResponse(),
}, nil
}
// Login menangani autentikasi user
func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) {
s.logger.Info("Login attempt",
zap.String("email", req.Email),
zap.String("ip_address", ipAddress),
)
// Cari user berdasarkan email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
s.logger.Warn("Login failed: user not found",
zap.String("email", req.Email),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("invalid email or password")
}
// Cek apakah akun diblokir
if user.IsBlocked() {
s.logger.Warn("Login failed: account blocked",
zap.String("email", user.Email),
zap.Uint("user_id", user.ID),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("account is blocked")
}
// Verifikasi password
passwordMatch := utils.CheckPasswordHash(req.Password, user.Password)
if !passwordMatch {
s.logger.Warn("Login failed: incorrect password",
zap.String("email", user.Email),
zap.Uint("user_id", user.ID),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("invalid email or password")
}
// Pastikan Role ter-load (Reload jika perlu)
if user.Role.ID == 0 {
user, err = s.userRepo.FindByID(user.ID)
if err != nil {
return nil, errors.New("failed to load user data")
}
}
// Generate JWT Token
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
if err != nil {
s.logger.Error("Failed to generate token",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
return nil, errors.New("failed to generate token")
}
// Log Audit
s.auditLogRepo.Log(&user.ID, models.ActionLogin, models.EntityUser, &user.ID,
"User logged in", ipAddress, userAgent)
s.logger.Info("Login successful",
zap.Uint("user_id", user.ID),
zap.String("email", user.Email),
zap.String("ip_address", ipAddress),
)
return &AuthResponse{
Token: token,
User: user.ToResponse(),
}, nil
}
// ValidateToken memvalidasi token JWT dan mengembalikan user terkait
func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) {
claims, err := config.ValidateToken(tokenString)
if err != nil {
s.logger.Warn("Token validation failed", zap.Error(err))
return nil, errors.New("invalid token")
}
user, err := s.userRepo.FindByID(claims.UserID)
if err != nil {
return nil, errors.New("user not found")
}
if user.IsBlocked() {
return nil, errors.New("account is blocked")
}
return user, nil
}
// RefreshToken memperbarui token JWT yang sudah ada
func (s *AuthService) RefreshToken(oldToken string) (string, error) {
s.logger.Info("Token refresh attempt")
newToken, err := config.RefreshToken(oldToken)
if err != nil {
s.logger.Error("Token refresh failed", zap.Error(err))
return "", err
}
s.logger.Info("Token refreshed successfully")
return newToken, nil
}
+148
View File
@@ -0,0 +1,148 @@
// internal/services/category_service.go
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"strings"
"gorm.io/gorm"
)
type CategoryService struct {
categoryRepo *repositories.CategoryRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewCategoryService(db *gorm.DB) *CategoryService {
return &CategoryService{
categoryRepo: repositories.NewCategoryRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// CreateCategoryRequest represents category creation data
type CreateCategoryRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
// UpdateCategoryRequest represents category update data
type UpdateCategoryRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
// GetAllCategories gets all categories
func (s *CategoryService) GetAllCategories() ([]models.CategoryResponse, error) {
return s.categoryRepo.GetAllWithItemCount()
}
// GetCategoryByID gets category by ID
func (s *CategoryService) GetCategoryByID(id uint) (*models.Category, error) {
return s.categoryRepo.FindByID(id)
}
// GetCategoryBySlug gets category by slug
func (s *CategoryService) GetCategoryBySlug(slug string) (*models.Category, error) {
return s.categoryRepo.FindBySlug(slug)
}
// CreateCategory creates a new category (admin only)
func (s *CategoryService) CreateCategory(adminID uint, req CreateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
// Generate slug from name
slug := s.generateSlug(req.Name)
// Check if slug already exists
existing, _ := s.categoryRepo.FindBySlug(slug)
if existing != nil {
return nil, errors.New("category with similar name already exists")
}
category := &models.Category{
Name: req.Name,
Slug: slug,
Description: req.Description,
}
if err := s.categoryRepo.Create(category); err != nil {
return nil, errors.New("failed to create category")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionCreate, models.EntityCategory, &category.ID,
"Category created: "+category.Name, ipAddress, userAgent)
return category, nil
}
// UpdateCategory updates a category (admin only)
func (s *CategoryService) UpdateCategory(adminID, categoryID uint, req UpdateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
category, err := s.categoryRepo.FindByID(categoryID)
if err != nil {
return nil, err
}
// Update fields
if req.Name != "" {
category.Name = req.Name
category.Slug = s.generateSlug(req.Name)
}
if req.Description != "" {
category.Description = req.Description
}
if err := s.categoryRepo.Update(category); err != nil {
return nil, errors.New("failed to update category")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityCategory, &categoryID,
"Category updated: "+category.Name, ipAddress, userAgent)
return category, nil
}
// DeleteCategory deletes a category (admin only)
func (s *CategoryService) DeleteCategory(adminID, categoryID uint, ipAddress, userAgent string) error {
category, err := s.categoryRepo.FindByID(categoryID)
if err != nil {
return err
}
// Check if category has items
_, count, err := s.categoryRepo.GetCategoryWithItemCount(categoryID)
if err != nil {
return err
}
if count > 0 {
return errors.New("cannot delete category with existing items")
}
if err := s.categoryRepo.Delete(categoryID); err != nil {
return errors.New("failed to delete category")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityCategory, &categoryID,
"Category deleted: "+category.Name, ipAddress, userAgent)
return nil
}
// generateSlug generates URL-friendly slug from name
func (s *CategoryService) generateSlug(name string) string {
slug := strings.ToLower(name)
slug = strings.ReplaceAll(slug, " ", "_")
slug = strings.ReplaceAll(slug, "/", "_")
// Remove special characters
validChars := "abcdefghijklmnopqrstuvwxyz0123456789_"
result := ""
for _, char := range slug {
if strings.ContainsRune(validChars, char) {
result += string(char)
}
}
return result
}
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
// internal/services/dashboard_service.go
package services
import (
"context"
"time"
"gorm.io/gorm"
"lost-and-found/internal/repositories"
)
// ✅ KRITERIA BASDAT: Menggunakan VIEWS dari enhancement.sql
type DashboardService struct {
db *gorm.DB
itemRepo *repositories.ItemRepository // ✅ Tambahkan ini
}
func NewDashboardService(db *gorm.DB) *DashboardService {
return &DashboardService{
db: db,
itemRepo: repositories.NewItemRepository(db), // ✅ Inisialisasi
}
}
// DashboardStats menggunakan VIEW vw_dashboard_stats
type DashboardStats struct {
TotalUnclaimed int64 `json:"total_unclaimed"`
TotalVerified int64 `json:"total_verified"`
TotalLostReports int64 `json:"total_lost_reports"`
PendingClaims int64 `json:"pending_claims"`
UnnotifiedMatches int64 `json:"unnotified_matches"`
}
// GetDashboardStats - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetDashboardStats() (*DashboardStats, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var stats DashboardStats
// ✅ QUERY VIEW vw_dashboard_stats (dari enhancement.sql)
err := s.db.WithContext(ctx).
Table("vw_dashboard_stats").
Select("*").
Scan(&stats).Error
if err != nil {
return nil, err
}
return &stats, nil
}
// ItemDetail menggunakan VIEW vw_items_detail
type ItemDetail struct {
ID uint `json:"id"`
Name string `json:"name"`
CategoryName string `json:"category_name"`
CategorySlug string `json:"category_slug"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
DateFound time.Time `json:"date_found"`
Status string `json:"status"`
ReporterName string `json:"reporter_name"`
ReporterContact string `json:"reporter_contact"`
ExpiresAt *time.Time `json:"expires_at"`
ReporterUserName string `json:"reporter_user_name"`
ReporterEmail string `json:"reporter_email"`
DaysUntilExpire int `json:"days_until_expire"`
CreatedAt time.Time `json:"created_at"`
}
// GetItemsWithDetails - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetItemsWithDetails(page, limit int, status string) ([]ItemDetail, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var items []ItemDetail
var total int64
query := s.db.WithContext(ctx).Table("vw_items_detail")
if status != "" {
query = query.Where("status = ?", status)
}
// Count
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Paginate
offset := (page - 1) * limit
err := query.Offset(offset).Limit(limit).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// ClaimDetail menggunakan VIEW vw_claims_detail
type ClaimDetail struct {
ID uint `json:"id"`
Status string `json:"status"`
ItemName string `json:"item_name"`
CategoryName string `json:"category_name"`
ClaimantName string `json:"claimant_name"`
ClaimantEmail string `json:"claimant_email"`
ClaimantPhone string `json:"claimant_phone"`
ClaimDescription string `json:"claim_description"`
Contact string `json:"contact"`
SimilarityScore *float64 `json:"similarity_score"`
VerifiedAt *time.Time `json:"verified_at"`
VerifiedByName string `json:"verified_by_name"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
}
// GetClaimsWithDetails - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetClaimsWithDetails(status string) ([]ClaimDetail, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var claims []ClaimDetail
query := s.db.WithContext(ctx).Table("vw_claims_detail")
if status != "" {
query = query.Where("status = ?", status)
}
err := query.Order("created_at DESC").Scan(&claims).Error
if err != nil {
return nil, err
}
return claims, nil
}
// MatchDetail menggunakan VIEW vw_match_results_detail
type MatchDetail struct {
ID uint `json:"id"`
LostItemName string `json:"lost_item_name"`
LostByUserID uint `json:"lost_by_user_id"`
LostByUserName string `json:"lost_by_user_name"`
LostByEmail string `json:"lost_by_email"`
FoundItemName string `json:"found_item_name"`
FoundByName string `json:"found_by_name"`
SimilarityScore float64 `json:"similarity_score"`
IsNotified bool `json:"is_notified"`
MatchedAt time.Time `json:"matched_at"`
FoundItemID uint `json:"found_item_id"`
LostItemID uint `json:"lost_item_id"`
}
// GetMatchesWithDetails - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetMatchesWithDetails(minScore float64) ([]MatchDetail, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var matches []MatchDetail
query := s.db.WithContext(ctx).Table("vw_match_results_detail")
if minScore > 0 {
query = query.Where("similarity_score >= ?", minScore)
}
err := query.Limit(100).Scan(&matches).Error
if err != nil {
return nil, err
}
return matches, nil
}
// CategoryStats menggunakan VIEW vw_category_stats
type CategoryStats struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
TotalItems int `json:"total_items"`
UnclaimedItems int `json:"unclaimed_items"`
VerifiedItems int `json:"verified_items"`
TotalLostReports int `json:"total_lost_reports"`
}
// GetCategoryStats - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetCategoryStats() ([]CategoryStats, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var stats []CategoryStats
err := s.db.WithContext(ctx).
Table("vw_category_stats").
Order("total_items DESC").
Scan(&stats).Error
if err != nil {
return nil, err
}
return stats, nil
}
// UserActivity menggunakan VIEW vw_user_activity
type UserActivity struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
RoleName string `json:"role_name"`
ItemsReported int `json:"items_reported"`
LostItemsReported int `json:"lost_items_reported"`
ClaimsMade int `json:"claims_made"`
ClaimsApproved int `json:"claims_approved"`
MemberSince time.Time `json:"member_since"`
}
// GetUserActivity - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetUserActivity(limit int) ([]UserActivity, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var activities []UserActivity
err := s.db.WithContext(ctx).
Table("vw_user_activity").
Order("items_reported DESC").
Limit(limit).
Scan(&activities).Error
if err != nil {
return nil, err
}
return activities, nil
}
func (s *DashboardService) GetStatsFromSP() (map[string]int64, error) {
return s.itemRepo.GetDashboardStatsSP()
}
// RecentActivity menggunakan VIEW vw_recent_activities
type RecentActivity struct {
ID uint `json:"id"`
Action string `json:"action"`
EntityType string `json:"entity_type"`
EntityID *uint `json:"entity_id"`
Details string `json:"details"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
UserRole string `json:"user_role"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
}
// GetRecentActivities - ✅ MENGGUNAKAN VIEW (limited to 100)
func (s *DashboardService) GetRecentActivities() ([]RecentActivity, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var activities []RecentActivity
err := s.db.WithContext(ctx).
Table("vw_recent_activities").
Scan(&activities).Error
if err != nil {
return nil, err
}
return activities, nil
}
+352
View File
@@ -0,0 +1,352 @@
// internal/services/etl_service.go
package services
import (
"context"
"encoding/csv"
"fmt"
"io"
"lost-and-found/internal/models"
"os"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ✅ KRITERIA BASDAT: ETL Implementation (15%)
type ETLService struct {
db *gorm.DB
logger *zap.Logger
}
func NewETLService(db *gorm.DB, logger *zap.Logger) *ETLService {
return &ETLService{
db: db,
logger: logger,
}
}
// ETLResult represents ETL operation result
type ETLResult struct {
TotalRecords int `json:"total_records"`
SuccessRecords int `json:"success_records"`
FailedRecords int `json:"failed_records"`
Duration time.Duration `json:"duration"`
Errors []string `json:"errors"`
TransformedData map[string]interface{} `json:"transformed_data"`
}
// ✅ EXTRACT - Extract data from CSV
func (s *ETLService) ExtractFromCSV(filepath string) ([]map[string]string, error) {
s.logger.Info("Starting EXTRACT phase", zap.String("file", filepath))
file, err := os.Open(filepath)
if err != nil {
s.logger.Error("Failed to open CSV file", zap.Error(err))
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
headers, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("failed to read headers: %w", err)
}
var records []map[string]string
lineNumber := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
s.logger.Warn("Skipping invalid line", zap.Int("line", lineNumber), zap.Error(err))
lineNumber++
continue
}
data := make(map[string]string)
for i, value := range record {
if i < len(headers) {
data[headers[i]] = strings.TrimSpace(value)
}
}
records = append(records, data)
lineNumber++
}
s.logger.Info("EXTRACT completed", zap.Int("records", len(records)))
return records, nil
}
// ✅ TRANSFORM - Transform and validate data
func (s *ETLService) TransformItemData(records []map[string]string) ([]models.Item, []string) {
s.logger.Info("Starting TRANSFORM phase", zap.Int("records", len(records)))
var items []models.Item
var errors []string
// Worker Pool Pattern for concurrent transformation
const numWorkers = 5
recordsChan := make(chan map[string]string, len(records))
resultsChan := make(chan struct {
item *models.Item
error string
}, len(records))
var wg sync.WaitGroup
// Start workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for record := range recordsChan {
item, err := s.transformSingleItem(record)
if err != nil {
resultsChan <- struct {
item *models.Item
error string
}{nil, err.Error()}
} else {
resultsChan <- struct {
item *models.Item
error string
}{item, ""}
}
}
}(i)
}
// Send records to workers
go func() {
for _, record := range records {
recordsChan <- record
}
close(recordsChan)
}()
// Wait for workers to finish
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results
for result := range resultsChan {
if result.error != "" {
errors = append(errors, result.error)
} else if result.item != nil {
items = append(items, *result.item)
}
}
s.logger.Info("TRANSFORM completed",
zap.Int("success", len(items)),
zap.Int("failed", len(errors)))
return items, errors
}
func (s *ETLService) transformSingleItem(record map[string]string) (*models.Item, error) {
// Validate required fields
required := []string{"name", "category_id", "location", "description", "date_found", "reporter_name", "reporter_contact"}
for _, field := range required {
if record[field] == "" {
return nil, fmt.Errorf("missing required field: %s", field)
}
}
// Parse category_id
categoryID, err := strconv.ParseUint(record["category_id"], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid category_id: %s", record["category_id"])
}
// Parse date_found
dateFound, err := time.Parse("2006-01-02", record["date_found"])
if err != nil {
return nil, fmt.Errorf("invalid date_found format: %s", record["date_found"])
}
// Parse reporter_id
reporterID, err := strconv.ParseUint(record["reporter_id"], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid reporter_id: %s", record["reporter_id"])
}
// Create item
item := &models.Item{
Name: record["name"],
CategoryID: uint(categoryID),
PhotoURL: record["photo_url"],
Location: record["location"],
Description: record["description"],
DateFound: dateFound,
Status: models.ItemStatusUnclaimed,
ReporterID: uint(reporterID),
ReporterName: record["reporter_name"],
ReporterContact: record["reporter_contact"],
}
return item, nil
}
// ✅ LOAD - Load transformed data to database with TRANSACTION
func (s *ETLService) LoadItems(items []models.Item) (*ETLResult, error) {
s.logger.Info("Starting LOAD phase", zap.Int("items", len(items)))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
startTime := time.Now()
result := &ETLResult{
TotalRecords: len(items),
Errors: []string{},
}
// Batch insert with transaction
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
if err := tx.Create(&item).Error; err != nil {
result.FailedRecords++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to insert %s: %v", item.Name, err))
s.logger.Warn("Failed to insert item",
zap.String("name", item.Name),
zap.Error(err))
} else {
result.SuccessRecords++
}
}
// If more than 50% failed, rollback
if result.FailedRecords > result.TotalRecords/2 {
return fmt.Errorf("too many failures (%d/%d), rolling back transaction",
result.FailedRecords, result.TotalRecords)
}
return nil
})
result.Duration = time.Since(startTime)
if err != nil {
s.logger.Error("LOAD failed", zap.Error(err))
return result, err
}
s.logger.Info("LOAD completed",
zap.Int("success", result.SuccessRecords),
zap.Int("failed", result.FailedRecords),
zap.Duration("duration", result.Duration))
return result, nil
}
// ✅ Full ETL Pipeline
func (s *ETLService) RunETLPipeline(csvPath string) (*ETLResult, error) {
s.logger.Info("Starting FULL ETL Pipeline", zap.String("source", csvPath))
// EXTRACT
records, err := s.ExtractFromCSV(csvPath)
if err != nil {
return nil, fmt.Errorf("extract failed: %w", err)
}
// TRANSFORM
items, transformErrors := s.TransformItemData(records)
// LOAD
result, err := s.LoadItems(items)
if err != nil {
return nil, fmt.Errorf("load failed: %w", err)
}
// Add transform errors to result
result.Errors = append(result.Errors, transformErrors...)
s.logger.Info("ETL Pipeline completed",
zap.Int("total", result.TotalRecords),
zap.Int("success", result.SuccessRecords),
zap.Int("failed", result.FailedRecords))
return result, nil
}
// ✅ Export data (Reverse ETL)
func (s *ETLService) ExportToCSV(filepath string, query string) error {
s.logger.Info("Exporting data to CSV", zap.String("file", filepath))
var items []models.Item
if err := s.db.Raw(query).Scan(&items).Error; err != nil {
return fmt.Errorf("failed to query data: %w", err)
}
file, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Write headers
headers := []string{"id", "name", "category_id", "location", "description", "date_found", "status"}
if err := writer.Write(headers); err != nil {
return err
}
// Write data
for _, item := range items {
record := []string{
strconv.Itoa(int(item.ID)),
item.Name,
strconv.Itoa(int(item.CategoryID)),
item.Location,
item.Description,
item.DateFound.Format("2006-01-02"),
item.Status,
}
if err := writer.Write(record); err != nil {
return err
}
}
s.logger.Info("Export completed", zap.Int("records", len(items)))
return nil
}
// ✅ Data Synchronization between databases
func (s *ETLService) SyncToExternalDB(externalDB *gorm.DB) error {
s.logger.Info("Starting database synchronization")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Get all items from source
var items []models.Item
if err := s.db.WithContext(ctx).Find(&items).Error; err != nil {
return fmt.Errorf("failed to fetch items: %w", err)
}
// Sync to external DB with transaction
return externalDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
// Upsert logic
if err := tx.Save(&item).Error; err != nil {
s.logger.Warn("Failed to sync item",
zap.Uint("id", item.ID),
zap.Error(err))
return err
}
}
return nil
})
}
+267
View File
@@ -0,0 +1,267 @@
// internal/services/export_service.go
package services
import (
"bytes"
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"time"
"log"
"errors"
"gorm.io/gorm"
)
type ExportService struct {
itemRepo *repositories.ItemRepository
archiveRepo *repositories.ArchiveRepository
claimRepo *repositories.ClaimRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewExportService(db *gorm.DB) *ExportService {
return &ExportService{
itemRepo: repositories.NewItemRepository(db),
archiveRepo: repositories.NewArchiveRepository(db),
claimRepo: repositories.NewClaimRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// ExportRequest represents export request data
type ExportRequest struct {
Type string `json:"type"` // items, archives, claims, audit_logs
Format string `json:"format"` // pdf, excel
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
Status string `json:"status"`
}
// ExportItemsToPDF exports items to PDF
func (s *ExportService) ExportItemsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
log.Printf("DEBUG: Starting PDF export - Type: %s, Status: %s", req.Type, req.Status)
// Get items
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
if err != nil {
log.Printf("ERROR: Failed to fetch items: %v", err) // ← Tambahkan ini
return nil, err
}
log.Printf("DEBUG: Found %d items", len(items))
// Filter by date range if provided
var filteredItems []models.Item
for _, item := range items {
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
continue
}
filteredItems = append(filteredItems, item)
}
if len(filteredItems) == 0 {
return nil, errors.New("no data in specified date range")
}
// Generate PDF
pdf := utils.NewPDFExporter()
pdf.AddTitle("Laporan Barang Ditemukan")
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
formatDate(req.StartDate),
formatDate(req.EndDate)))
pdf.AddNewLine()
// Add table
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Tanggal Ditemukan", "Status"}
var data [][]string
for i, item := range filteredItems {
data = append(data, []string{
fmt.Sprintf("%d", i+1),
item.Name,
item.Category.Name,
item.Location,
item.DateFound.Format("02 Jan 2006"),
item.Status,
})
}
pdf.AddTable(headers, data)
// Add footer
pdf.AddNewLine()
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredItems)))
pdf.AddText(fmt.Sprintf("Dicetak pada: %s", time.Now().Format("02 January 2006 15:04")))
// Log audit
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported items report (PDF, %d items)", len(filteredItems)),
ipAddress, userAgent)
return pdf.Output(), nil
}
// ExportItemsToExcel exports items to Excel
func (s *ExportService) ExportItemsToExcel(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
// Get items
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
if err != nil {
return nil, err
}
// Filter by date range if provided
var filteredItems []models.Item
for _, item := range items {
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
continue
}
filteredItems = append(filteredItems, item)
}
// Generate Excel
excel := utils.NewExcelExporter()
excel.SetSheetName("Barang Ditemukan")
// Add headers
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Deskripsi",
"Tanggal Ditemukan", "Status", "Pelapor", "Kontak"}
excel.AddRow(headers)
// Add data
for i, item := range filteredItems {
excel.AddRow([]string{
fmt.Sprintf("%d", i+1),
item.Name,
item.Category.Name,
item.Location,
item.Description,
item.DateFound.Format("02 Jan 2006"),
item.Status,
item.ReporterName,
item.ReporterContact,
})
}
// Auto-size columns
excel.AutoSizeColumns(len(headers))
// Log audit
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported items report (Excel, %d items)", len(filteredItems)),
ipAddress, userAgent)
return excel.Output()
}
// ExportArchivesToPDF exports archives to PDF
func (s *ExportService) ExportArchivesToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
archives, _, err := s.archiveRepo.FindAll(1, 10000, "", "")
if err != nil {
return nil, err
}
// Filter by date range
var filteredArchives []models.Archive
for _, archive := range archives {
if req.StartDate != nil && archive.ArchivedAt.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && archive.ArchivedAt.After(*req.EndDate) {
continue
}
filteredArchives = append(filteredArchives, archive)
}
pdf := utils.NewPDFExporter()
pdf.AddTitle("Laporan Barang yang Diarsipkan")
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
formatDate(req.StartDate),
formatDate(req.EndDate)))
pdf.AddNewLine()
headers := []string{"No", "Nama Barang", "Kategori", "Alasan Arsip", "Tanggal Arsip"}
var data [][]string
for i, archive := range filteredArchives {
data = append(data, []string{
fmt.Sprintf("%d", i+1),
archive.Name,
archive.Category.Name,
archive.ArchivedReason,
archive.ArchivedAt.Format("02 Jan 2006"),
})
}
pdf.AddTable(headers, data)
pdf.AddNewLine()
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredArchives)))
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported archives report (PDF, %d items)", len(filteredArchives)),
ipAddress, userAgent)
return pdf.Output(), nil
}
// ExportClaimsToPDF exports claims to PDF
func (s *ExportService) ExportClaimsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
claims, _, err := s.claimRepo.FindAll(1, 10000, req.Status, nil, nil)
if err != nil {
return nil, err
}
// Filter by date range
var filteredClaims []models.Claim
for _, claim := range claims {
if req.StartDate != nil && claim.CreatedAt.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && claim.CreatedAt.After(*req.EndDate) {
continue
}
filteredClaims = append(filteredClaims, claim)
}
pdf := utils.NewPDFExporter()
pdf.AddTitle("Laporan Klaim Barang")
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
formatDate(req.StartDate),
formatDate(req.EndDate)))
pdf.AddNewLine()
headers := []string{"No", "Barang", "Pengklaim", "Status", "Tanggal Klaim"}
var data [][]string
for i, claim := range filteredClaims {
data = append(data, []string{
fmt.Sprintf("%d", i+1),
claim.Item.Name,
claim.User.Name,
claim.Status,
claim.CreatedAt.Format("02 Jan 2006"),
})
}
pdf.AddTable(headers, data)
pdf.AddNewLine()
pdf.AddText(fmt.Sprintf("Total: %d klaim", len(filteredClaims)))
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported claims report (PDF, %d claims)", len(filteredClaims)),
ipAddress, userAgent)
return pdf.Output(), nil
}
// Helper function to format date
func formatDate(date *time.Time) string {
if date == nil {
return "N/A"
}
return date.Format("02 Jan 2006")
}
+615
View File
@@ -0,0 +1,615 @@
// internal/services/item_service.go - FIXED VERSION
package services
import (
"context"
"errors"
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"time"
"log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ItemService struct {
itemRepo *repositories.ItemRepository
categoryRepo *repositories.CategoryRepository
auditLogRepo *repositories.AuditLogRepository
revisionRepo *repositories.RevisionLogRepository
db *gorm.DB
lostItemRepo *repositories.LostItemRepository // FIXED: pointer type
notificationRepo *repositories.NotificationRepository // FIXED: pointer type
matchRepo *repositories.MatchResultRepository // TAMBAHAN
}
func NewItemService(db *gorm.DB) *ItemService {
itemRepo := repositories.NewItemRepository(db)
lostItemRepo := repositories.NewLostItemRepository(db)
notifRepo := repositories.NewNotificationRepository(db)
matchRepo := repositories.NewMatchResultRepository(db)
return &ItemService{
db: db,
itemRepo: itemRepo,
categoryRepo: repositories.NewCategoryRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
revisionRepo: repositories.NewRevisionLogRepository(db),
lostItemRepo: lostItemRepo,
notificationRepo: notifRepo,
matchRepo: matchRepo,
}
}
func (s *ItemService) CreateFoundItemLinked(reporterID uint, req CreateFoundItemLinkedRequest, ipAddress, userAgent string) (*models.Item, error) {
tx := s.db.Begin()
if tx.Error != nil {
return nil, tx.Error
}
// 1. Buat Item Awal (Status Default: unclaimed)
item := &models.Item{
Name: req.Name,
CategoryID: req.CategoryID,
Location: req.Location,
DateFound: time.Now(),
Description: req.Description,
ReporterID: reporterID,
ReporterName: req.ReporterName,
ReporterContact: req.ReporterContact,
Status: models.ItemStatusUnclaimed, // Default
}
if req.PhotoURL != "" {
item.PhotoURL = req.PhotoURL
}
if err := tx.Create(item).Error; err != nil {
tx.Rollback()
return nil, err
}
// 2. Logika "Langsung ke Pemilik"
// Pastikan req.LostItemID & req.IsDirectToOwner terisi (Cek tag JSON struct!)
if req.IsDirectToOwner && req.LostItemID != 0 {
var lostItem models.LostItem
// Gunakan Preload agar data User tidak nil saat dipakai nanti
if err := tx.Preload("User").First(&lostItem, req.LostItemID).Error; err != nil {
tx.Rollback()
return nil, errors.New("Laporan kehilangan tidak ditemukan")
}
// A. Update Status Lost Item jadi "claimed"
if err := tx.Model(&lostItem).Update("status", "claimed").Error; err != nil {
tx.Rollback()
return nil, err
}
// B. Update Status Item Temuan jadi "waiting_owner"
if err := tx.Model(item).Update("status", "waiting_owner").Error; err != nil {
tx.Rollback()
return nil, err
}
// ✅ PENTING: Update struct di memori supaya response API benar
itemID := item.ID
newClaim := &models.Claim{
ItemID: &itemID, // PENTING: Harus pointer
UserID: lostItem.UserID,
Description: "Auto-match: Ditemukan dan diserahkan langsung ke pemilik.",
Contact: lostItem.User.Phone,
Status: models.ClaimStatusWaitingOwner,
}
if err := tx.Create(newClaim).Error; err != nil {
tx.Rollback()
return nil, err
}
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
return item, nil
}
type CreateItemRequest struct {
Name string `json:"name" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
PhotoURL string `json:"photo_url"`
Location string `json:"location" binding:"required"`
Description string `json:"description" binding:"required"`
SecretDetails string `json:"secret_details" binding:"required"`
DateFound time.Time `json:"date_found" binding:"required"`
ReporterName string `json:"reporter_name" binding:"required"`
ReporterContact string `json:"reporter_contact" binding:"required"`
}
type UpdateItemRequest struct {
Name string `json:"name"`
CategoryID uint `json:"category_id"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
Description string `json:"description"`
SecretDetails string `json:"secret_details"`
DateFound time.Time `json:"date_found"`
ReporterName string `json:"reporter_name"`
ReporterContact string `json:"reporter_contact"`
Status string `json:"status"`
Reason string `json:"reason"`
}
type CreateFoundItemLinkedRequest struct {
CreateItemRequest
LostItemID uint `json:"lost_item_id" binding:"required"`
IsDirectToOwner bool `json:"is_direct_to_owner"`
}
// GetAllItems gets all items with CONTEXT TIMEOUT
func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
txRepo := repositories.NewItemRepository(s.db.WithContext(ctx))
items, total, err := txRepo.FindAll(page, limit, status, category, search)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, 0, errors.New("request timeout: query took too long")
}
return nil, 0, err
}
var responses []models.ItemPublicResponse
for _, item := range items {
responses = append(responses, item.ToPublicResponse())
}
return responses, total, nil
}
func (s *ItemService) RunAutoArchive(ipAddress, userAgent string) (int, error) {
// Panggil Repository
count, err := s.itemRepo.CallArchiveExpiredProcedure()
if err != nil {
return 0, err
}
// Log Audit jika ada yang diarsip
if count > 0 {
details := fmt.Sprintf("Auto-archived %d expired items using Stored Procedure", count)
// Gunakan ID 0 atau nil untuk system action
s.auditLogRepo.Log(nil, "auto_archive", "system", nil, details, ipAddress, userAgent)
}
return count, nil
}
// ✅ FIXED: CreateItem - NOW INCLUDES SecretDetails
func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var item *models.Item
// ✅ TRANSACTION untuk create item + audit log
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Verify category exists
var category models.Category
if err := tx.Where("id = ? AND deleted_at IS NULL", req.CategoryID).
First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("invalid category")
}
return fmt.Errorf("failed to verify category: %w", err)
}
// 2. Create item - ✅ SEKARANG INCLUDE SecretDetails
item = &models.Item{
Name: req.Name,
CategoryID: req.CategoryID,
PhotoURL: req.PhotoURL,
Location: req.Location,
Description: req.Description,
SecretDetails: req.SecretDetails, // ✅ TAMBAHKAN INI
DateFound: req.DateFound,
Status: models.ItemStatusUnclaimed,
ReporterID: reporterID,
ReporterName: req.ReporterName,
ReporterContact: req.ReporterContact,
}
if err := tx.Create(item).Error; err != nil {
return fmt.Errorf("failed to create item: %w", err)
}
// 3. Create audit log
auditLog := &models.AuditLog{
UserID: &reporterID,
Action: models.ActionCreate,
EntityType: models.EntityItem,
EntityID: &item.ID,
Details: fmt.Sprintf("Item created: %s", item.Name),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return item, nil
}
// ✅ FIXED: UpdateItem - NOW HANDLES SecretDetails
// internal/services/item_service.go
// UpdateItem updates an item with transaction, locking, and status change support
func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
// ✅ Tambahkan logging
log.Printf("🔍 UpdateItem Request:")
log.Printf(" ItemID: %d", itemID)
log.Printf(" Name: %s", req.Name)
log.Printf(" CategoryID: %d", req.CategoryID)
log.Printf(" Status: %s", req.Status)
log.Printf(" Location: %s", req.Location)
log.Printf(" Description: %s", req.Description)
log.Printf(" SecretDetails: %s", req.SecretDetails)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var updatedItem *models.Item
// ✅ TRANSACTION + LOCKING untuk update item
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item models.Item
// 1. Ambil data User beserta Role-nya (FIXED: Preload Role)
var user models.User
if err := tx.Preload("Role").First(&user, userID).Error; err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// 2. Lock item untuk mencegah race condition saat update
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Category").
Where("id = ? AND deleted_at IS NULL", itemID).
First(&item).Error; err != nil {
return errors.New("item not found")
}
// 3. Cek Permission: Izinkan jika User adalah Owner ATAU Admin/Manager
isOwner := item.ReporterID == userID
isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager"
if !isOwner && !isManagerOrAdmin {
return errors.New("unauthorized to edit this item")
}
// 4. Validasi Status Item
// - Case Closed tetap permanen (tidak bisa diedit siapapun)
if item.Status == models.ItemStatusCaseClosed {
return errors.New("cannot edit item with status: " + item.Status)
}
// - Expired hanya bisa diedit oleh Manager/Admin
if !isManagerOrAdmin && item.IsExpired() {
return errors.New("cannot edit expired item")
}
// Track changes for revision log
revisionCreated := false
// --- Update Fields Logics ---
if req.Name != "" && req.Name != item.Name {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "name",
OldValue: item.Name,
NewValue: req.Name,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.Name = req.Name
revisionCreated = true
}
if req.CategoryID != 0 && req.CategoryID != item.CategoryID {
// Verify new category exists
var newCategory models.Category
if err := tx.Where("id = ?", req.CategoryID).First(&newCategory).Error; err != nil {
return errors.New("invalid category")
}
oldCatName := item.Category.Name
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "category",
OldValue: oldCatName,
NewValue: newCategory.Name,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.CategoryID = req.CategoryID
revisionCreated = true
}
if req.Location != "" && req.Location != item.Location {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "location",
OldValue: item.Location,
NewValue: req.Location,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.Location = req.Location
revisionCreated = true
}
if req.Description != "" && req.Description != item.Description {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "description",
OldValue: item.Description,
NewValue: req.Description,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.Description = req.Description
revisionCreated = true
}
// Handle SecretDetails update
if req.SecretDetails != "" && req.SecretDetails != item.SecretDetails {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "secret_details",
OldValue: item.SecretDetails,
NewValue: req.SecretDetails,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.SecretDetails = req.SecretDetails
revisionCreated = true
}
if req.PhotoURL != "" && req.PhotoURL != item.PhotoURL {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "photo_url",
OldValue: item.PhotoURL,
NewValue: req.PhotoURL,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.PhotoURL = req.PhotoURL
revisionCreated = true
}
// ✅ NEW: Handle Status Update (Khusus Manager/Admin)
if req.Status != "" && req.Status != item.Status {
// Validasi status yang diperbolehkan untuk di-set manual
validStatuses := map[string]bool{
models.ItemStatusUnclaimed: true,
models.ItemStatusVerified: true,
models.ItemStatusExpired: true,
models.ItemStatusCaseClosed: true,
}
if !validStatuses[req.Status] {
return errors.New("invalid status value")
}
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "status",
OldValue: item.Status,
NewValue: req.Status,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log for status: %w", err)
}
item.Status = req.Status
revisionCreated = true
}
if !revisionCreated {
return errors.New("no changes detected")
}
// Save updated item
if err := tx.Save(&item).Error; err != nil {
return fmt.Errorf("failed to update item: %w", err)
}
// Create audit log
auditLog := &models.AuditLog{
UserID: &userID,
Action: models.ActionUpdate,
EntityType: models.EntityItem,
EntityID: &itemID,
Details: fmt.Sprintf("Item updated: %s (Reason: %s)", item.Name, req.Reason),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
updatedItem = &item
return nil
})
if err != nil {
return nil, err
}
return updatedItem, nil
}
// UpdateItemStatus updates item status with TRANSACTION
func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Lock item
var item models.Item
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", itemID).
First(&item).Error; err != nil {
return fmt.Errorf("failed to lock item: %w", err)
}
// Update status
if err := tx.Model(&item).Update("status", status).Error; err != nil {
return fmt.Errorf("failed to update status: %w", err)
}
// Create audit log
auditLog := &models.AuditLog{
UserID: &userID,
Action: models.ActionUpdate,
EntityType: models.EntityItem,
EntityID: &itemID,
Details: fmt.Sprintf("Item status updated to: %s", status),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
})
}
// DeleteItem deletes an item with TRANSACTION
func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Ambil data User yang sedang request (untuk cek Role)
var user models.User
if err := tx.Preload("Role").First(&user, userID).Error; err != nil {
return errors.New("user not found")
}
// 2. Lock item
var item models.Item
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", itemID).
First(&item).Error; err != nil {
return fmt.Errorf("failed to lock item: %w", err)
}
// 3. PERMISSION CHECK: Izinkan jika Owner ATAU Manager/Admin
isOwner := item.ReporterID == userID
isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager"
if !isOwner && !isManagerOrAdmin {
return errors.New("unauthorized to delete this item")
}
// 4. ✅ (BARU) Validasi Active Claims - PENGGANTI TRIGGER trg_items_before_delete
// Cek apakah ada claims dengan status 'pending' untuk item ini
var activeClaims int64
if err := tx.Model(&models.Claim{}).
Where("item_id = ? AND status IN ? AND deleted_at IS NULL",
itemID, []string{models.ClaimStatusPending, models.ClaimStatusWaitingOwner}).
Count(&activeClaims).Error; err != nil {
return fmt.Errorf("failed to check active claims: %w", err)
}
if activeClaims > 0 {
return errors.New("cannot delete item with active claims (pending or waiting owner)")
}
if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed {
return errors.New("cannot delete item with status: " + item.Status)
}
if err := tx.Delete(&item).Error; err != nil {
return fmt.Errorf("failed to delete item: %w", err)
}
// 7. Create audit log
auditLog := &models.AuditLog{
UserID: &userID,
Action: models.ActionDelete,
EntityType: models.EntityItem,
EntityID: &itemID,
Details: fmt.Sprintf("Item deleted: %s", item.Name),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
})
}
// GetItemsByReporter gets items by reporter with CONTEXT
func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
txRepo := repositories.NewItemRepository(s.db.WithContext(ctx))
return txRepo.FindByReporter(reporterID, page, limit)
}
// GetItemRevisionHistory gets revision history with CONTEXT
func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
txRepo := repositories.NewRevisionLogRepository(s.db.WithContext(ctx))
logs, total, err := txRepo.FindByItem(itemID, page, limit)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, 0, errors.New("request timeout")
}
return nil, 0, err
}
var responses []models.RevisionLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
+362
View File
@@ -0,0 +1,362 @@
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"time"
"fmt"
"gorm.io/gorm"
)
type LostItemService struct {
db *gorm.DB
lostItemRepo *repositories.LostItemRepository
categoryRepo *repositories.CategoryRepository
auditLogRepo *repositories.AuditLogRepository
userRepo *repositories.UserRepository
notificationRepo *repositories.NotificationRepository
claimRepo *repositories.ClaimRepository
}
func NewLostItemService(db *gorm.DB) *LostItemService {
return &LostItemService{
db: db,
lostItemRepo: repositories.NewLostItemRepository(db),
categoryRepo: repositories.NewCategoryRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
userRepo: repositories.NewUserRepository(db),
notificationRepo: repositories.NewNotificationRepository(db),
claimRepo: repositories.NewClaimRepository(db),
}
}
type CreateLostItemRequest struct {
Name string `json:"name" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
Color string `json:"color"`
Location string `json:"location"`
Description string `json:"description" binding:"required"`
DateLost time.Time `json:"date_lost" binding:"required"`
}
type UpdateLostItemRequest struct {
Name string `json:"name"`
CategoryID uint `json:"category_id"`
Color string `json:"color"`
Location string `json:"location"`
Description string `json:"description"`
DateLost time.Time `json:"date_lost"`
}
type CreateLostItemClaimRequest struct {
Description string `json:"description" binding:"required"`
Contact string `json:"contact" binding:"required"`
ProofURL string `json:"proof_url"`
}
func (s *LostItemService) GetAllLostItems(page, limit int, status, category, search string, userID *uint) ([]models.LostItemResponse, int64, error) {
lostItems, total, err := s.lostItemRepo.FindAll(page, limit, status, category, search, userID)
if err != nil {
return nil, 0, err
}
var responses []models.LostItemResponse
for _, lostItem := range lostItems {
response := lostItem.ToResponse()
if lostItem.DirectClaimID != nil {
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
if err == nil {
response.DirectClaimStatus = claim.Status
}
}
responses = append(responses, response)
}
return responses, total, nil
}
func (s *LostItemService) GetLostItemByID(id uint) (*models.LostItem, error) {
lostItem, err := s.lostItemRepo.FindByID(id)
if err != nil {
return nil, err
}
if lostItem.DirectClaimID != nil {
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
if err == nil {
lostItem.DirectClaim = claim
}
}
return lostItem, nil
}
func (s *LostItemService) CreateLostItem(userID uint, req CreateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
return nil, errors.New("invalid category")
}
lostItem := &models.LostItem{
UserID: userID,
Name: req.Name,
CategoryID: req.CategoryID,
Color: req.Color,
Location: req.Location,
Description: req.Description,
DateLost: req.DateLost,
Status: models.LostItemStatusActive,
}
if err := s.lostItemRepo.Create(lostItem); err != nil {
return nil, errors.New("failed to create lost item report")
}
s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityLostItem, &lostItem.ID,
"Lost item report created: "+lostItem.Name, ipAddress, userAgent)
return s.lostItemRepo.FindByID(lostItem.ID)
}
func (s *LostItemService) UpdateLostItem(userID, lostItemID uint, req UpdateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return nil, err
}
// Cek authorization
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, errors.New("user not found")
}
isOwner := lostItem.UserID == userID
isAdminOrManager := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
if !isOwner && !isAdminOrManager {
return nil, errors.New("unauthorized to update this lost item report")
}
if !isAdminOrManager && !lostItem.IsActive() {
return nil, errors.New("cannot update non-active lost item report")
}
// ✅ UPDATE: Prioritaskan category_id, update terlebih dahulu
if req.CategoryID != 0 {
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
return nil, errors.New("invalid category")
}
// LOG untuk debug
fmt.Printf("📝 Updating category from %d to %d\n", lostItem.CategoryID, req.CategoryID)
lostItem.CategoryID = req.CategoryID
}
if req.Name != "" {
lostItem.Name = req.Name
}
if req.Color != "" {
lostItem.Color = req.Color
}
if req.Location != "" {
lostItem.Location = req.Location
}
if req.Description != "" {
lostItem.Description = req.Description
}
if !req.DateLost.IsZero() {
lostItem.DateLost = req.DateLost
}
// ✅ PASTIKAN update tersimpan
if err := s.lostItemRepo.Update(lostItem); err != nil {
fmt.Printf("❌ Error updating lost item: %v\n", err)
return nil, errors.New("failed to update lost item report")
}
actionDesc := fmt.Sprintf("Lost item report updated: %s", lostItem.Name)
if !isOwner {
actionDesc = fmt.Sprintf("Lost item report updated by %s: %s", user.Role.Name, lostItem.Name)
}
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
actionDesc, ipAddress, userAgent)
// ✅ RELOAD dari database untuk memastikan data terbaru
return s.lostItemRepo.FindByID(lostItem.ID)
}
func (s *LostItemService) UpdateLostItemStatus(userID, lostItemID uint, status string, ipAddress, userAgent string) error {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return err
}
// TAMBAHKAN: Cek admin/manager permission
user, err := s.userRepo.FindByID(userID)
if err != nil {
return errors.New("user not found")
}
isOwner := lostItem.UserID == userID
isAdminOrManager := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
if !isOwner && !isAdminOrManager {
return errors.New("unauthorized to update this lost item report")
}
if err := s.lostItemRepo.UpdateStatus(lostItemID, status); err != nil {
return errors.New("failed to update lost item status")
}
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
"Lost item status updated to: "+status, ipAddress, userAgent)
return nil
}
func (s *LostItemService) DeleteLostItem(userID, lostItemID uint, ipAddress, userAgent string) error {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return err
}
user, err := s.userRepo.FindByID(userID)
if err != nil {
return errors.New("user not found")
}
isOwner := lostItem.UserID == userID
isManagerOrAdmin := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
if !isOwner && !isManagerOrAdmin {
return errors.New("unauthorized to delete this lost item report")
}
if err := s.lostItemRepo.Delete(lostItemID); err != nil {
return errors.New("failed to delete lost item report")
}
s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityLostItem, &lostItemID, "Lost item report deleted: "+lostItem.Name, ipAddress, userAgent)
return nil
}
func (s *LostItemService) GetLostItemsByUser(userID uint, page, limit int) ([]models.LostItemResponse, int64, error) {
lostItems, total, err := s.lostItemRepo.FindByUser(userID, page, limit)
if err != nil {
return nil, 0, err
}
var responses []models.LostItemResponse
for _, lostItem := range lostItems {
response := lostItem.ToResponse()
// ✅ PERBAIKAN: Load data claim lengkap jika ada direct claim
if lostItem.DirectClaimID != nil {
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
if err == nil {
// Load relasi user penemu agar namanya muncul di frontend
s.db.Preload("User").First(claim, claim.ID)
response.DirectClaimStatus = claim.Status
// Konversi claim ke response dan tempelkan ke lost item response
claimResp := claim.ToResponse()
response.DirectClaim = &claimResp // Pastikan struct LostItemResponse punya field ini
}
}
responses = append(responses, response)
}
return responses, total, nil
}
func (s *LostItemService) DirectClaimToOwner(
finderUserID uint,
lostItemID uint,
req CreateLostItemClaimRequest,
ipAddress string, // Tambahkan ini agar audit log valid
userAgent string, // Tambahkan ini agar audit log valid
) (*models.Claim, error) {
var createdClaim *models.Claim
err := s.db.Transaction(func(tx *gorm.DB) error {
// 1. Get Lost Item
var lostItem models.LostItem
if err := tx.First(&lostItem, lostItemID).Error; err != nil {
return errors.New("lost item not found")
}
// 2. Validasi
if lostItem.Status != models.LostItemStatusActive {
return errors.New("lost item is not active")
}
if lostItem.UserID == finderUserID {
return errors.New("cannot claim your own lost item")
}
if lostItem.DirectClaimID != nil {
return errors.New("this lost item already has a pending claim")
}
// 3. Create Claim (LOGIC OPSI A)
// Kita set ItemID nil, tapi isi LostItemID
claim := models.Claim{
ItemID: nil, // PENTING: Set nil (jangan 0)
LostItemID: &lostItemID, // PENTING: Link ke LostItem
UserID: finderUserID,
Description: req.Description,
Contact: req.Contact,
ProofURL: req.ProofURL,
Status: models.ClaimStatusWaitingOwner,
Notes: fmt.Sprintf("Direct claim for lost item #%d", lostItemID),
}
if err := tx.Create(&claim).Error; err != nil {
return err
}
createdClaim = &claim
// 4. Update Lost Item Status
// Status diganti agar tidak muncul di pencarian publik sementara waktu
// Pastikan constant ini ada di models, atau gunakan string "pending_verification"
lostItem.Status = "pending_verification"
lostItem.DirectClaimID = &claim.ID
if err := tx.Save(&lostItem).Error; err != nil {
return err
}
// 5. Notification to Owner
s.notificationRepo.Notify(
lostItem.UserID,
"direct_claim_received",
"🎯 Barang Anda Ditemukan?",
fmt.Sprintf("Seseorang mengklaim menemukan '%s'. Silakan verifikasi klaim ini.", lostItem.Name),
"claim", // Entity Type arahkan ke claim
&claim.ID, // Entity ID arahkan ke claim ID agar owner bisa klik detail claim
)
// 6. Audit Log
s.auditLogRepo.Log(
&finderUserID,
"create_direct_claim",
"claim",
&claim.ID,
fmt.Sprintf("Direct claim created for lost item #%d", lostItemID),
ipAddress,
userAgent,
)
return nil
})
if err != nil {
return nil, err
}
return createdClaim, nil
}
+205
View File
@@ -0,0 +1,205 @@
// internal/services/match_service.go
package services
import (
"encoding/json"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"gorm.io/gorm"
)
type MatchService struct {
db *gorm.DB // Tambahkan ini
matchRepo *repositories.MatchResultRepository
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository
notificationRepo *repositories.NotificationRepository
}
func NewMatchService(db *gorm.DB) *MatchService {
return &MatchService{
db: db, // Tambahkan ini
matchRepo: repositories.NewMatchResultRepository(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db),
notificationRepo: repositories.NewNotificationRepository(db),
}
}
// MatchedField represents a matched field between items
type MatchedField struct {
Field string `json:"field"`
LostValue string `json:"lost_value"`
FoundValue string `json:"found_value"`
Score float64 `json:"score"`
}
// FindSimilarItems finds similar items for a lost item report
func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return nil, err
}
// Search for items in same category
items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color)
if err != nil {
return nil, err
}
var results []models.MatchResultResponse
for _, item := range items {
// Calculate similarity
score, matchedFields := s.calculateSimilarity(lostItem, &item)
// Only include if score is reasonable (>= 30%)
if score >= 30.0 {
// Check if match already exists
exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID)
if !exists {
// Create match result
matchedFieldsJSON, _ := json.Marshal(matchedFields)
match := &models.MatchResult{
LostItemID: lostItemID,
ItemID: item.ID,
SimilarityScore: score,
MatchedFields: string(matchedFieldsJSON),
IsNotified: false,
}
s.matchRepo.Create(match)
// Reload with relations
match, _ = s.matchRepo.FindByID(match.ID)
results = append(results, match.ToResponse())
}
}
}
return results, nil
}
// GetMatchesForLostItem gets all matches for a lost item
func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) {
matches, err := s.matchRepo.FindByLostItem(lostItemID)
if err != nil {
return nil, err
}
var responses []models.MatchResultResponse
for _, match := range matches {
responses = append(responses, match.ToResponse())
}
return responses, nil
}
// GetMatchesForItem gets all matches for an item
func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) {
matches, err := s.matchRepo.FindByItem(itemID)
if err != nil {
return nil, err
}
var responses []models.MatchResultResponse
for _, match := range matches {
responses = append(responses, match.ToResponse())
}
return responses, nil
}
// AutoMatchNewItem automatically matches a new item with lost items
func (s *MatchService) AutoMatchNewItem(itemID uint) error {
item, err := s.itemRepo.FindByID(itemID)
if err != nil {
return err
}
// Find active lost items in same category
lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID)
if err != nil {
return err
}
for _, lostItem := range lostItems {
// Calculate similarity
score, matchedFields := s.calculateSimilarity(&lostItem, item)
// Create match if score is high enough (>= 50% for auto-match)
if score >= 50.0 {
// Check if match already exists
exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID)
if !exists {
matchedFieldsJSON, _ := json.Marshal(matchedFields)
match := &models.MatchResult{
LostItemID: lostItem.ID,
ItemID: itemID,
SimilarityScore: score,
MatchedFields: string(matchedFieldsJSON),
IsNotified: false,
}
s.matchRepo.Create(match)
// Send notification to lost item owner - PERBAIKAN DI SINI
models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID)
s.matchRepo.MarkAsNotified(match.ID)
}
}
}
return nil
}
// calculateSimilarity calculates similarity between lost item and found item
func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) {
var matchedFields []MatchedField
// 1. Hard Filter: Kategori HARUS sama.
if lostItem.CategoryID != item.CategoryID {
return 0.0, matchedFields
}
// --- A. Hitung Kecocokan Nama (50%) ---
nameSim := utils.CalculateStringSimilarity(lostItem.Name, item.Name)
nameScore := nameSim * 100.0
if nameScore > 40.0 {
matchedFields = append(matchedFields, MatchedField{
Field: "name",
LostValue: lostItem.Name,
FoundValue: item.Name,
Score: nameScore,
})
}
// --- B. Hitung Kecocokan Secret Details / Deskripsi (50%) ---
// Target: Utamakan Secret Details penemu, fallback ke Description
targetText := item.SecretDetails
foundValueType := "Secret Details"
if targetText == "" {
targetText = item.Description
foundValueType = "Description"
}
// Bandingkan Deskripsi User vs Target Penemu
descSim := utils.CalculateStringSimilarity(lostItem.Description, targetText)
descScore := descSim * 100.0
if descScore > 40.0 {
matchedFields = append(matchedFields, MatchedField{
Field: "secret_details",
LostValue: "User Description",
FoundValue: foundValueType,
Score: descScore,
})
}
// --- C. Hitung Skor Akhir ---
// Rumus: (Skor Nama * 0.5) + (Skor Deskripsi * 0.5)
finalScore := (nameScore * 0.5) + (descScore * 0.5)
return finalScore, matchedFields
}
+116
View File
@@ -0,0 +1,116 @@
// internal/services/notification_service.go
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type NotificationService struct {
db *gorm.DB // Tambahkan ini
notificationRepo *repositories.NotificationRepository
}
func NewNotificationService(db *gorm.DB) *NotificationService {
return &NotificationService{
db: db, // Tambahkan ini
notificationRepo: repositories.NewNotificationRepository(db),
}
}
// GetUserNotifications gets notifications for a user
func (s *NotificationService) GetUserNotifications(userID uint, page, limit int, onlyUnread bool) ([]models.NotificationResponse, int64, error) {
notifications, total, err := s.notificationRepo.FindByUser(userID, page, limit, onlyUnread)
if err != nil {
return nil, 0, err
}
var responses []models.NotificationResponse
for _, notification := range notifications {
responses = append(responses, notification.ToResponse())
}
return responses, total, nil
}
// GetNotificationByID gets notification by ID
func (s *NotificationService) GetNotificationByID(userID, notificationID uint) (*models.Notification, error) {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
return nil, err
}
// Check ownership
if notification.UserID != userID {
return nil, errors.New("unauthorized")
}
return notification, nil
}
// MarkAsRead marks a notification as read
func (s *NotificationService) MarkAsRead(userID, notificationID uint) error {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
return err
}
// Check ownership
if notification.UserID != userID {
return errors.New("unauthorized")
}
return s.notificationRepo.MarkAsRead(notificationID)
}
// MarkAllAsRead marks all notifications as read for a user
func (s *NotificationService) MarkAllAsRead(userID uint) error {
return s.notificationRepo.MarkAllAsRead(userID)
}
// DeleteNotification deletes a notification
func (s *NotificationService) DeleteNotification(userID, notificationID uint) error {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
return err
}
// Check ownership
if notification.UserID != userID {
return errors.New("unauthorized")
}
return s.notificationRepo.Delete(notificationID)
}
// DeleteAllNotifications deletes all notifications for a user
func (s *NotificationService) DeleteAllNotifications(userID uint) error {
return s.notificationRepo.DeleteAllForUser(userID)
}
// CountUnread counts unread notifications for a user
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
return s.notificationRepo.CountUnread(userID)
}
// CreateNotification creates a new notification
func (s *NotificationService) CreateNotification(userID uint, notifType, title, message, entityType string, entityID *uint) error {
return s.notificationRepo.Notify(userID, notifType, title, message, entityType, entityID)
}
// SendMatchNotification sends notification when a match is found
func (s *NotificationService) SendMatchNotification(userID uint, itemName string, matchID uint) error {
return models.CreateMatchNotification(s.db, userID, itemName, matchID)
}
// SendClaimApprovedNotification sends notification when claim is approved
func (s *NotificationService) SendClaimApprovedNotification(userID uint, itemName string, claimID uint) error {
return models.CreateClaimApprovedNotification(s.db, userID, itemName, claimID)
}
// SendClaimRejectedNotification sends notification when claim is rejected
func (s *NotificationService) SendClaimRejectedNotification(userID uint, itemName, reason string, claimID uint) error {
return models.CreateClaimRejectedNotification(s.db, userID, itemName, reason, claimID)
}
+112
View File
@@ -0,0 +1,112 @@
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type RoleService struct {
roleRepo *repositories.RoleRepository
}
func NewRoleService(db *gorm.DB) *RoleService {
return &RoleService{
roleRepo: repositories.NewRoleRepository(db),
}
}
// Structs for Requests
type CreateRoleRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
PermissionIDs []uint `json:"permission_ids"`
}
type UpdateRoleRequest struct {
Name string `json:"name"`
Description string `json:"description"`
PermissionIDs []uint `json:"permission_ids"`
}
// GetAllRoles returns all roles with permissions
func (s *RoleService) GetAllRoles() ([]models.Role, error) {
return s.roleRepo.FindAllWithPermissions()
}
// GetAllPermissions returns list of all permissions
func (s *RoleService) GetAllPermissions() ([]models.Permission, error) {
return s.roleRepo.FindAllPermissions()
}
// CreateRole creates a new role and assigns permissions
func (s *RoleService) CreateRole(req CreateRoleRequest) (*models.Role, error) {
// Check if role name already exists
existing, _ := s.roleRepo.FindByName(req.Name)
if existing != nil {
return nil, errors.New("role name already exists")
}
role := &models.Role{
Name: req.Name,
Description: req.Description,
}
// 1. Create Role
if err := s.roleRepo.Create(role); err != nil {
return nil, err
}
// 2. Assign Permissions
if len(req.PermissionIDs) > 0 {
if err := s.roleRepo.UpdatePermissions(role, req.PermissionIDs); err != nil {
return nil, err
}
}
// Reload to return complete object
return s.roleRepo.FindByID(role.ID)
}
// UpdateRole updates role details and permissions
func (s *RoleService) UpdateRole(id uint, req UpdateRoleRequest) (*models.Role, error) {
role, err := s.roleRepo.FindByID(id)
if err != nil {
return nil, errors.New("role not found")
}
// Protect core roles from name changes
if (role.Name == "admin" || role.Name == "user" || role.Name == "manager") && req.Name != role.Name {
return nil, errors.New("cannot change name of system roles")
}
// Update fields
if req.Name != "" {
role.Name = req.Name
}
role.Description = req.Description
// Update Permissions
if err := s.roleRepo.UpdatePermissions(role, req.PermissionIDs); err != nil {
return nil, err
}
return s.roleRepo.FindByID(id)
}
// DeleteRole deletes a role
func (s *RoleService) DeleteRole(id uint) error {
role, err := s.roleRepo.FindByID(id)
if err != nil {
return errors.New("role not found")
}
// Prevent deleting core system roles
if role.Name == "admin" || role.Name == "user" || role.Name == "manager" {
return errors.New("cannot delete core system roles")
}
return s.roleRepo.Delete(id)
}
+234
View File
@@ -0,0 +1,234 @@
// internal/services/user_service.go
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"gorm.io/gorm"
)
type UserService struct {
db *gorm.DB // ✅ Tambahkan ini
userRepo *repositories.UserRepository
roleRepo *repositories.RoleRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{
db: db, // ✅ Tambahkan ini
userRepo: repositories.NewUserRepository(db),
roleRepo: repositories.NewRoleRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// UpdateProfileRequest represents profile update data
type UpdateProfileRequest struct {
Name string `json:"name"`
Phone string `json:"phone"`
NRP string `json:"nrp"`
}
// ChangePasswordRequest represents password change data
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
// GetProfile gets user profile
func (s *UserService) GetProfile(userID uint) (*models.User, error) {
return s.userRepo.FindByID(userID)
}
// UpdateProfile - TANPA enkripsi
func (s *UserService) UpdateProfile(userID uint, req UpdateProfileRequest, ipAddress, userAgent string) (*models.User, error) {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, err
}
// Update fields
if req.Name != "" {
user.Name = req.Name
}
// ✅ Update phone TANPA enkripsi
if req.Phone != "" {
user.Phone = req.Phone
}
// ✅ Update NRP TANPA enkripsi
if req.NRP != "" {
// Check if NRP already exists for another user
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
if existingNRP != nil && existingNRP.ID != userID {
return nil, errors.New("NRP already used by another user")
}
user.NRP = req.NRP
}
if err := s.userRepo.Update(user); err != nil {
return nil, errors.New("failed to update profile")
}
// Log audit
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
"Profile updated", ipAddress, userAgent)
return user, nil
}
// ChangePassword changes user password
func (s *UserService) ChangePassword(userID uint, req ChangePasswordRequest, ipAddress, userAgent string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return err
}
// Verify old password
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
return errors.New("invalid old password")
}
// Hash new password
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
return errors.New("failed to hash password")
}
user.Password = hashedPassword
if err := s.userRepo.Update(user); err != nil {
return errors.New("failed to change password")
}
// Log audit
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
"Password changed", ipAddress, userAgent)
return nil
}
// GetUserStats gets user statistics
func (s *UserService) GetUserStats(userID uint) (map[string]interface{}, error) {
stats := make(map[string]interface{})
// ✅ Count items reported by user
var itemCount int64
if err := s.db.Model(&models.Item{}). // ← Ganti dari s.userRepo.db
Where("reporter_id = ? AND deleted_at IS NULL", userID).
Count(&itemCount).Error; err != nil {
return nil, err
}
stats["items_reported"] = itemCount
// ✅ Count lost items reported
var lostItemCount int64
if err := s.db.Model(&models.LostItem{}). // ← Ganti dari s.userRepo.db
Where("user_id = ? AND deleted_at IS NULL", userID).
Count(&lostItemCount).Error; err != nil {
return nil, err
}
stats["lost_items_reported"] = lostItemCount
// ✅ Count claims made
var claimCount int64
if err := s.db.Model(&models.Claim{}). // ← Ganti dari s.userRepo.db
Where("user_id = ? AND deleted_at IS NULL", userID).
Count(&claimCount).Error; err != nil {
return nil, err
}
stats["claims_made"] = claimCount
// ✅ Count approved claims
var approvedClaimCount int64
if err := s.db.Model(&models.Claim{}). // ← Ganti dari s.userRepo.db
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, models.ClaimStatusApproved).
Count(&approvedClaimCount).Error; err != nil {
return nil, err
}
stats["claims_approved"] = approvedClaimCount
return stats, nil
}
// UpdateUserRole updates user role (admin only)
func (s *UserService) UpdateUserRole(adminID, userID, roleID uint, ipAddress, userAgent string) error {
// Verify role exists
role, err := s.roleRepo.FindByID(roleID)
if err != nil {
return errors.New("invalid role")
}
// Update role
if err := s.userRepo.UpdateRole(userID, roleID); err != nil {
return errors.New("failed to update user role")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityUser, &userID,
"Role updated to: "+role.Name, ipAddress, userAgent)
return nil
}
// BlockUser blocks a user (admin only)
func (s *UserService) BlockUser(adminID, userID uint, ipAddress, userAgent string) error {
// Cannot block self
if adminID == userID {
return errors.New("cannot block yourself")
}
if err := s.userRepo.BlockUser(userID); err != nil {
return errors.New("failed to block user")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionBlock, models.EntityUser, &userID,
"User blocked", ipAddress, userAgent)
return nil
}
// UnblockUser unblocks a user (admin only)
func (s *UserService) UnblockUser(adminID, userID uint, ipAddress, userAgent string) error {
if err := s.userRepo.UnblockUser(userID); err != nil {
return errors.New("failed to unblock user")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionUnblock, models.EntityUser, &userID,
"User unblocked", ipAddress, userAgent)
return nil
}
// DeleteUser deletes a user (admin only)
func (s *UserService) DeleteUser(adminID, userID uint, ipAddress, userAgent string) error {
// Cannot delete self
if adminID == userID {
return errors.New("cannot delete yourself")
}
if err := s.userRepo.Delete(userID); err != nil {
return errors.New("failed to delete user")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityUser, &userID,
"User deleted", ipAddress, userAgent)
return nil
}
func (s *UserService) GetAllUsers(page, limit int) ([]models.User, int64, error) {
return s.userRepo.FindAll(page, limit)
}
// GetUserByID gets user by ID
func (s *UserService) GetUserByID(userID uint) (*models.User, error) {
return s.userRepo.FindByID(userID)
}
+186
View File
@@ -0,0 +1,186 @@
// internal/services/verification_service.go
package services
import (
"encoding/json"
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"strconv"
"gorm.io/gorm"
)
type VerificationService struct {
verificationRepo *repositories.ClaimVerificationRepository
claimRepo *repositories.ClaimRepository
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository // ✅ TAMBAHAN: Untuk Direct Claim
}
func NewVerificationService(db *gorm.DB) *VerificationService {
return &VerificationService{
verificationRepo: repositories.NewClaimVerificationRepository(db),
claimRepo: repositories.NewClaimRepository(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db), // ✅ TAMBAHAN
}
}
// VerificationResult represents the verification result
type VerificationResult struct {
SimilarityScore float64 `json:"similarity_score"`
MatchLevel string `json:"match_level"`
MatchedKeywords []string `json:"matched_keywords"`
Details map[string]string `json:"details"`
Recommendation string `json:"recommendation"`
}
// VerifyClaimDescription verifies claim description against item/lost_item description
func (s *VerificationService) VerifyClaimDescription(claimID uint) (*VerificationResult, error) {
// 1. Get Claim
claim, err := s.claimRepo.FindByID(claimID)
if err != nil {
return nil, err
}
var targetDescription string
var sourceName string
// 2. Tentukan Target Deskripsi (Regular vs Direct Claim)
if claim.ItemID != nil {
// --- REGULAR CLAIM (Barang Temuan) ---
item, err := s.itemRepo.FindByID(*claim.ItemID) // ✅ Dereference pointer
if err != nil {
return nil, err
}
// Bandingkan dengan Secret Details (prioritas) atau Description
targetDescription = item.SecretDetails
if targetDescription == "" {
targetDescription = item.Description
}
sourceName = "item_secret_details"
} else if claim.LostItemID != nil {
// --- DIRECT CLAIM (Barang Hilang) ---
lostItem, err := s.lostItemRepo.FindByID(*claim.LostItemID) // ✅ Dereference pointer
if err != nil {
return nil, err
}
// Untuk direct claim, deskripsi Finder (Claim) dibandingkan dengan deskripsi Owner (LostItem)
targetDescription = lostItem.Description
sourceName = "lost_item_description"
} else {
return nil, errors.New("invalid claim: no item or lost_item attached")
}
// 3. Calculate similarity
similarity := utils.CalculateStringSimilarity(claim.Description, targetDescription)
similarityPercent := similarity * 100
// 4. Extract matched keywords
claimKeywords := utils.ExtractKeywords(claim.Description)
itemKeywords := utils.ExtractKeywords(targetDescription)
matchedKeywords := utils.FindMatchedKeywords(claimKeywords, itemKeywords)
// 5. Determine match level
matchLevel := "low"
recommendation := "REJECT - Ciri khusus tidak cocok"
if similarityPercent >= 75.0 {
matchLevel = "high"
recommendation = "APPROVE - Ciri khusus sangat cocok"
} else if similarityPercent >= 50.0 {
matchLevel = "medium"
recommendation = "REVIEW - Perlu verifikasi fisik/tanya jawab"
}
// 6. Create or update verification record
verification, _ := s.verificationRepo.FindByClaimID(claimID)
if verification == nil {
verification = &models.ClaimVerification{
ClaimID: claimID,
SimilarityScore: similarityPercent,
MatchedKeywords: stringSliceToJSON(matchedKeywords),
IsAutoMatched: false,
}
s.verificationRepo.Create(verification)
} else {
verification.SimilarityScore = similarityPercent
verification.MatchedKeywords = stringSliceToJSON(matchedKeywords)
s.verificationRepo.Update(verification)
}
return &VerificationResult{
SimilarityScore: similarityPercent,
MatchLevel: matchLevel,
MatchedKeywords: matchedKeywords,
Details: map[string]string{
"claim_description": claim.Description,
sourceName: targetDescription, // Dinamis (item atau lost_item)
"matched_count": strconv.Itoa(len(matchedKeywords)),
},
Recommendation: recommendation,
}, nil
}
// GetVerificationByClaimID gets verification data for a claim
func (s *VerificationService) GetVerificationByClaimID(claimID uint) (*models.ClaimVerification, error) {
verification, err := s.verificationRepo.FindByClaimID(claimID)
if err != nil {
return nil, err
}
if verification == nil {
return nil, errors.New("verification not found")
}
return verification, nil
}
// GetHighMatchVerifications gets all high match verifications
func (s *VerificationService) GetHighMatchVerifications() ([]models.ClaimVerificationResponse, error) {
verifications, err := s.verificationRepo.FindHighMatches()
if err != nil {
return nil, err
}
var responses []models.ClaimVerificationResponse
for _, v := range verifications {
responses = append(responses, v.ToResponse())
}
return responses, nil
}
// CompareDescriptions provides detailed comparison between two descriptions
func (s *VerificationService) CompareDescriptions(desc1, desc2 string) map[string]interface{} {
similarity := utils.CalculateStringSimilarity(desc1, desc2)
keywords1 := utils.ExtractKeywords(desc1)
keywords2 := utils.ExtractKeywords(desc2)
matchedKeywords := utils.FindMatchedKeywords(keywords1, keywords2)
return map[string]interface{}{
"similarity_score": similarity * 100,
"description_1": desc1,
"description_2": desc2,
"keywords_1": keywords1,
"keywords_2": keywords2,
"matched_keywords": matchedKeywords,
"total_keywords_1": len(keywords1),
"total_keywords_2": len(keywords2),
"matched_count": len(matchedKeywords),
}
}
// Helper function to convert string slice to JSON
func stringSliceToJSON(slice []string) string {
if len(slice) == 0 {
return "[]"
}
data, _ := json.Marshal(slice)
return string(data)
}