Initial commit - Lost and Found Revisi
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user