Initial commit - Lost and Found Revisi

This commit is contained in:
bambang-code1 2025-12-20 00:01:08 +07:00
commit f4c838471c
210 changed files with 30706 additions and 0 deletions

30
.env Normal file
View File

@ -0,0 +1,30 @@
# Server Configuration
PORT=8080
ENVIRONMENT=development
# Database Configuration (MySQL/MariaDB)
DB_HOST=202.46.28.160
DB_PORT=53306
DB_USER=bambang
DB_PASSWORD=baminfor25
DB_NAME=iot_db
DB_CHARSET=utf8mb4
DB_PARSE_TIME=True
DB_LOC=Local
# JWT Configuration
JWT_SECRET_KEY=your-secret-key-change-this-in-production-2024
ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456
# Upload Configuration
UPLOAD_PATH=./uploads
MAX_UPLOAD_SIZE=10485760
# CORS Configuration
ALLOWED_ORIGINS=*
# Gemini AI Configuration
GROQ_API_KEY=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
GROQ_MODEL=llama-3.3-70b-versatile

1522
README.md Normal file

File diff suppressed because it is too large Load Diff

243
cmd/server/main.go Normal file
View File

@ -0,0 +1,243 @@
// cmd/server/main.go - ENHANCED WITH DEBUG LOGGING
package main
import (
"context"
"log"
"lost-and-found/internal/config"
"lost-and-found/internal/middleware"
"lost-and-found/internal/routes"
"lost-and-found/internal/workers"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"sync"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"go.uber.org/zap"
)
func main() {
// Load .env file
if err := godotenv.Load(); err != nil {
log.Println("⚠️ No .env file found, using environment variables")
}
// ✅ Structured Logging
logger, err := zap.NewProduction()
if err != nil {
log.Fatalf("❌ Failed to initialize logger: %v", err)
}
defer logger.Sync()
logger.Info("🚀 Starting Lost & Found System...")
// Initialize JWT config
config.InitJWT()
logger.Info("✅ JWT configuration initialized")
// Initialize database
logger.Info("📊 Initializing database...")
if err := config.InitDB(); err != nil {
logger.Fatal("❌ Failed to initialize database", zap.Error(err))
}
defer func() {
logger.Info("🗄️ Closing database connections...")
if err := config.CloseDB(); err != nil {
logger.Error("Failed to close database", zap.Error(err))
} else {
logger.Info("✅ Database connections closed")
}
}()
// Run migrations
logger.Info("📋 Running migrations...")
db := config.GetDB()
if err := config.RunMigrations(db); err != nil {
logger.Fatal("❌ Failed to run migrations", zap.Error(err))
}
// Initialize Gin
if config.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()
// Apply middleware
router.Use(middleware.CORSMiddleware())
router.Use(middleware.LoggerMiddleware())
router.Use(middleware.RateLimiterMiddleware())
if config.IsDevelopment() {
router.Use(func(c *gin.Context) {
c.Writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Writer.Header().Set("Pragma", "no-cache")
c.Writer.Header().Set("Expires", "0")
c.Next()
})
}
// Serve static files
router.Static("/uploads", "./uploads")
router.Static("/css", "./web/css")
router.Static("/js", "./web/js")
router.Static("/assets", "./web/assets")
// Frontend routes
router.GET("/", func(c *gin.Context) { c.File("./web/index.html") })
router.GET("/login", func(c *gin.Context) { c.File("./web/login.html") })
router.GET("/login.html", func(c *gin.Context) { c.File("./web/login.html") }) // ✅ Tambahkan ini
router.GET("/register", func(c *gin.Context) { c.File("./web/register.html") })
router.GET("/register.html", func(c *gin.Context) { c.File("./web/register.html") }) // ✅ Tambahkan ini
router.GET("/admin", func(c *gin.Context) { c.File("./web/admin.html") })
router.GET("/admin.html", func(c *gin.Context) { c.File("./web/admin.html") }) // ✅ Tambahkan ini
router.GET("/manager", func(c *gin.Context) { c.File("./web/manager.html") })
router.GET("/manager.html", func(c *gin.Context) { c.File("./web/manager.html") }) // ✅ Tambahkan ini
router.GET("/user", func(c *gin.Context) { c.File("./web/user.html") })
router.GET("/user.html", func(c *gin.Context) { c.File("./web/user.html") }) // ✅ Tambahkan ini
// Setup API routes
routes.SetupRoutes(router, db, logger)
logger.Info("✅ API routes configured")
// ✅ Start Background Workers
logger.Info("🔄 Starting background workers...")
expireWorker := workers.NewExpireWorker(db)
auditWorker := workers.NewAuditWorker(db)
matchingWorker := workers.NewMatchingWorker(db)
notificationWorker := workers.NewNotificationWorker(db)
// ✅ Background Workers - 4 Goroutines
expireWorker.Start()
auditWorker.Start()
matchingWorker.Start()
notificationWorker.Start()
logger.Info("✅ All background workers started")
// Get server config
serverConfig := config.GetServerConfig()
port := serverConfig.Port
// ✅ DEBUG: Print sebelum create server
log.Println("🔧 DEBUG: Creating HTTP server...")
log.Printf("🔧 DEBUG: Port = %s\n", port)
log.Printf("🔧 DEBUG: Address = :%s\n", port)
// ✅ HTTP Server with Timeouts
srv := &http.Server{
Addr: ":" + port,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// ✅ DEBUG: Print sebelum start goroutine
log.Println("🔧 DEBUG: Starting server goroutine...")
// ✅ Start server in goroutine
serverErrors := make(chan error, 1)
go func() {
// ✅ PENTING: Print ini HARUS muncul
log.Println("🚀 Server starting...")
log.Printf(" 📍 URL: http://localhost:%s\n", port)
log.Printf(" 📡 API: http://localhost:%s/api\n", port)
log.Printf(" 🌍 ENV: %s\n", serverConfig.Environment)
log.Println("✨ Press Ctrl+C to stop")
log.Println("🔧 DEBUG: Calling srv.ListenAndServe()...")
logger.Info("🚀 Server starting",
zap.String("url", "http://localhost:"+port),
zap.String("api", "http://localhost:"+port+"/api"),
zap.String("environment", serverConfig.Environment),
)
// ✅ Ini yang benar-benar mulai server
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("❌ SERVER ERROR: %v\n", err)
serverErrors <- err
}
}()
// ✅ DEBUG: Print setelah goroutine
log.Println("🔧 DEBUG: Server goroutine launched, waiting for signals...")
// ✅ Wait for interrupt signal OR server error
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-quit:
logger.Info("🛑 Shutdown signal received", zap.String("signal", sig.String()))
case err := <-serverErrors:
logger.Fatal("❌ Server error", zap.Error(err))
}
logger.Info("🔄 Starting graceful shutdown...")
// ✅ Step 1: Create shutdown context with timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
// Implementasi ACID dan Kontrol Transaksi (15%)
// ✅ Step 2: Stop accepting new HTTP requests
// Implementasi Graceful Shutdown dengan srv.Shutdown(shutdownCtx) memastikan Durability data yang sedang diproses.
logger.Info("🔌 Stopping server from accepting new requests...")
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("⚠️ Server forced to shutdown", zap.Error(err))
} else {
logger.Info("✅ Server stopped accepting new requests")
}
// ✅ Step 3: Stop background workers
logger.Info("🔄 Stopping background workers...")
workersDone := make(chan struct{})
go func() {
var wg sync.WaitGroup
wg.Add(4)
go func() {
defer wg.Done()
expireWorker.Stop()
logger.Info("✅ Expire worker stopped")
}()
go func() {
defer wg.Done()
auditWorker.Stop()
logger.Info("✅ Audit worker stopped")
}()
go func() {
defer wg.Done()
matchingWorker.Stop()
logger.Info("✅ Matching worker stopped")
}()
go func() {
defer wg.Done()
notificationWorker.Stop()
logger.Info("✅ Notification worker stopped")
}()
wg.Wait()
close(workersDone)
}()
// ✅ Wait for workers or timeout
select {
case <-workersDone:
logger.Info("✅ All background workers stopped gracefully")
case <-shutdownCtx.Done():
logger.Warn("⚠️ Worker shutdown timeout exceeded, forcing exit")
}
logger.Info("✅ Graceful shutdown completed")
logger.Info("👋 Goodbye!")
}

197
database/enhancement.sql Normal file
View File

@ -0,0 +1,197 @@
-- database/enhancement.sql
-- ============================================
-- ENHANCEMENT FOR LOST & FOUND DATABASE (SAFE MODE)
-- Hanya Procedures, Views, dan Indexes
USE iot_db;
DELIMITER $$
-- ============================================
-- STORED PROCEDURES
-- ============================================
-- Procedure: Archive expired items
DROP PROCEDURE IF EXISTS sp_archive_expired_items$$
CREATE PROCEDURE sp_archive_expired_items(
OUT p_archived_count INT
)
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE v_item_id INT;
DECLARE cur CURSOR FOR
SELECT id FROM items
WHERE expires_at < NOW()
AND status = 'unclaimed'
AND deleted_at IS NULL;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
SET p_archived_count = 0;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_item_id;
IF done THEN
LEAVE read_loop;
END IF;
-- Archive the item
INSERT INTO archives (
item_id, name, category_id, photo_url, location,
description, date_found, status, reporter_name,
reporter_contact, archived_reason
)
SELECT
id, name, category_id, photo_url, location,
description, date_found, status, reporter_name,
reporter_contact, 'expired'
FROM items
WHERE id = v_item_id;
-- Update item status
UPDATE items
SET status = 'expired'
WHERE id = v_item_id;
SET p_archived_count = p_archived_count + 1;
END LOOP;
CLOSE cur;
END$$
-- Procedure: Get dashboard statistics
DROP PROCEDURE IF EXISTS sp_get_dashboard_stats$$
CREATE PROCEDURE sp_get_dashboard_stats(
OUT p_total_items INT,
OUT p_unclaimed_items INT,
OUT p_verified_items INT,
OUT p_pending_claims INT
)
BEGIN
SELECT COUNT(*) INTO p_total_items FROM items WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO p_unclaimed_items FROM items WHERE status = 'unclaimed' AND deleted_at IS NULL;
SELECT COUNT(*) INTO p_verified_items FROM items WHERE status = 'verified' AND deleted_at IS NULL;
SELECT COUNT(*) INTO p_pending_claims FROM claims WHERE status = 'pending' AND deleted_at IS NULL;
END$$
DELIMITER ;
-- ============================================
-- VIEWS
-- ============================================
CREATE OR REPLACE VIEW vw_dashboard_stats AS
SELECT
(SELECT COUNT(*) FROM items WHERE status = 'unclaimed' AND deleted_at IS NULL) AS total_unclaimed,
(SELECT COUNT(*) FROM items WHERE status = 'verified' AND deleted_at IS NULL) AS total_verified,
(SELECT COUNT(*) FROM lost_items WHERE status = 'active' AND deleted_at IS NULL) AS total_lost_reports,
(SELECT COUNT(*) FROM claims WHERE status = 'pending' AND deleted_at IS NULL) AS pending_claims,
(SELECT COUNT(*) FROM match_results WHERE is_notified = FALSE AND deleted_at IS NULL) AS unnotified_matches;
CREATE OR REPLACE VIEW vw_items_detail AS
SELECT
i.id, i.name, c.name AS category_name, c.slug AS category_slug,
i.photo_url, i.location, i.date_found, i.status,
i.reporter_name, i.reporter_contact, i.expires_at,
u.name AS reporter_user_name, u.email AS reporter_email,
DATEDIFF(i.expires_at, NOW()) AS days_until_expire, i.created_at
FROM items i
JOIN categories c ON i.category_id = c.id
JOIN users u ON i.reporter_id = u.id
WHERE i.deleted_at IS NULL;
CREATE OR REPLACE VIEW vw_claims_detail AS
SELECT
c.id, c.status, i.name AS item_name, cat.name AS category_name,
u.name AS claimant_name, u.email AS claimant_email, u.phone AS claimant_phone,
c.description AS claim_description, c.contact, cv.similarity_score,
c.verified_at, v.name AS verified_by_name, c.notes, c.created_at
FROM claims c
JOIN items i ON c.item_id = i.id
JOIN categories cat ON i.category_id = cat.id
JOIN users u ON c.user_id = u.id
LEFT JOIN claim_verifications cv ON c.id = cv.claim_id
LEFT JOIN users v ON c.verified_by = v.id
WHERE c.deleted_at IS NULL;
CREATE OR REPLACE VIEW vw_match_results_detail AS
SELECT
mr.id, li.name AS lost_item_name, li.user_id AS lost_by_user_id,
u.name AS lost_by_user_name, u.email AS lost_by_email,
i.name AS found_item_name, i.reporter_name AS found_by_name,
mr.similarity_score, mr.is_notified, mr.matched_at,
i.id AS found_item_id, li.id AS lost_item_id
FROM match_results mr
JOIN lost_items li ON mr.lost_item_id = li.id
JOIN items i ON mr.item_id = i.id
JOIN users u ON li.user_id = u.id
WHERE mr.deleted_at IS NULL
ORDER BY mr.similarity_score DESC;
CREATE OR REPLACE VIEW vw_category_stats AS
SELECT
c.id, c.name, c.slug,
COUNT(DISTINCT i.id) AS total_items,
COUNT(DISTINCT CASE WHEN i.status = 'unclaimed' THEN i.id END) AS unclaimed_items,
COUNT(DISTINCT CASE WHEN i.status = 'verified' THEN i.id END) AS verified_items,
COUNT(DISTINCT li.id) AS total_lost_reports
FROM categories c
LEFT JOIN items i ON c.id = i.category_id AND i.deleted_at IS NULL
LEFT JOIN lost_items li ON c.id = li.category_id AND li.deleted_at IS NULL
WHERE c.deleted_at IS NULL
GROUP BY c.id, c.name, c.slug;
CREATE OR REPLACE VIEW vw_user_activity AS
SELECT
u.id, u.name, u.email, r.name AS role_name,
COUNT(DISTINCT i.id) AS items_reported,
COUNT(DISTINCT li.id) AS lost_items_reported,
COUNT(DISTINCT cl.id) AS claims_made,
COUNT(DISTINCT CASE WHEN cl.status = 'approved' THEN cl.id END) AS claims_approved,
u.created_at AS member_since
FROM users u
JOIN roles r ON u.role_id = r.id
LEFT JOIN items i ON u.id = i.reporter_id AND i.deleted_at IS NULL
LEFT JOIN lost_items li ON u.id = li.user_id AND li.deleted_at IS NULL
LEFT JOIN claims cl ON u.id = cl.user_id AND cl.deleted_at IS NULL
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.name, u.email, r.name, u.created_at;
CREATE OR REPLACE VIEW vw_recent_activities AS
SELECT
al.id, al.action, al.entity_type, al.entity_id, al.details,
u.name AS user_name, u.email AS user_email, r.name AS user_role,
al.ip_address, al.created_at
FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id
LEFT JOIN roles r ON u.role_id = r.id
ORDER BY al.created_at DESC
LIMIT 100;
-- ============================================
-- INDEXES
-- ============================================
-- Hapus index jika exist (cara aman di MySQL lama agak ribet, jadi langsung CREATE saja biasanya OK jika belum ada)
-- Gunakan DROP INDEX jika perlu mereset index
CREATE INDEX idx_items_status_category ON items(status, category_id, deleted_at);
CREATE INDEX idx_items_date_status ON items(date_found, status, deleted_at);
CREATE INDEX idx_claims_status_item ON claims(status, item_id, deleted_at);
CREATE INDEX idx_match_results_scores ON match_results(similarity_score DESC, is_notified);
CREATE INDEX idx_audit_logs_date_user ON audit_logs(created_at DESC, user_id);
CREATE INDEX idx_lost_items_status_user ON lost_items(status, user_id, deleted_at);
CREATE INDEX idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
-- Full-text indexes
CREATE FULLTEXT INDEX idx_items_search ON items(name, location);
CREATE FULLTEXT INDEX idx_lost_items_search ON lost_items(name, description);
-- ============================================
-- SUCCESS MESSAGE
-- ============================================
SELECT '✅ Database enhancements (Safe Mode) created!' AS Status;
SELECT '⚙️ Procedures: 2' AS Info;
SELECT '📈 Views: 7' AS Info;
SELECT '🚀 Indexes: 9' AS Info;
SELECT '⚠️ Note: Triggers & Functions dihapus untuk menghindari Error 1419' AS Note;

31
database/expired_item.sql Normal file
View File

@ -0,0 +1,31 @@
START TRANSACTION;
-- 1. Pindahkan item yang sudah kadaluarsa ke tabel archives
INSERT INTO archives (
item_id, name, category_id, photo_url, location,
description, date_found, status, reporter_name,
reporter_contact, archived_reason, archived_at
)
SELECT
id, name, category_id, photo_url, location,
description, date_found, 'expired', reporter_name,
reporter_contact, 'expired', NOW()
FROM items
WHERE expires_at < NOW()
AND status = 'unclaimed'
AND deleted_at IS NULL
-- Pastikan item ini belum ada di archives untuk mencegah duplikat
AND NOT EXISTS (SELECT 1 FROM archives WHERE archives.item_id = items.id);
-- 2. Update status di tabel items menjadi 'expired'
UPDATE items
SET status = 'expired'
WHERE expires_at < NOW()
AND status = 'unclaimed'
AND deleted_at IS NULL;
COMMIT;
-- 3. Cek Hasilnya
SELECT id, name, status, expires_at FROM items WHERE status = 'expired';
SELECT * FROM archives WHERE archived_reason = 'expired';

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS chat_messages (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
message TEXT NOT NULL,
response TEXT NOT NULL,
context_data JSON DEFAULT NULL COMMENT 'Data konteks (items, lost_items, dll)',
intent VARCHAR(50) DEFAULT NULL COMMENT 'search_item, report_lost, claim_help, general',
confidence_score DECIMAL(5,2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_chat_user_id (user_id),
INDEX idx_chat_intent (intent),
INDEX idx_chat_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT '✅ AI Chat table created successfully!' AS Status;

View File

@ -0,0 +1,34 @@
-- ✅ ADD CASE CLOSE COLUMNS TO items TABLE
ALTER TABLE items
ADD COLUMN berita_acara_no VARCHAR(100) NULL AFTER expires_at,
ADD COLUMN bukti_serah_terima VARCHAR(255) NULL AFTER berita_acara_no,
ADD COLUMN case_closed_at DATETIME NULL AFTER bukti_serah_terima,
ADD COLUMN case_closed_by INT UNSIGNED NULL AFTER case_closed_at,
ADD COLUMN case_closed_notes TEXT NULL AFTER case_closed_by,
ADD CONSTRAINT fk_items_case_closed_by FOREIGN KEY (case_closed_by) REFERENCES users(id);
-- ✅ ADD CASE CLOSE COLUMNS TO archives TABLE
ALTER TABLE archives
ADD COLUMN berita_acara_no VARCHAR(100) NULL AFTER claimed_by,
ADD COLUMN bukti_serah_terima VARCHAR(255) NULL AFTER berita_acara_no;
ALTER TABLE lost_items ADD COLUMN matched_at DATETIME NULL;
-- ✅ ADD INDEX FOR BETTER QUERY PERFORMANCE
CREATE INDEX idx_items_case_closed ON items(case_closed_at, case_closed_by);
CREATE INDEX idx_items_berita_acara ON items(berita_acara_no);
-- ✅ VERIFY CHANGES
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'items'
AND COLUMN_NAME IN ('berita_acara_no', 'bukti_serah_terima', 'case_closed_at', 'case_closed_by', 'case_closed_notes')
ORDER BY ORDINAL_POSITION;

View File

@ -0,0 +1,60 @@
-- Add direct_claim_id to lost_items table
ALTER TABLE lost_items
ADD COLUMN direct_claim_id INT UNSIGNED NULL,
ADD CONSTRAINT fk_lost_items_direct_claim
FOREIGN KEY (direct_claim_id) REFERENCES claims(id)
ON DELETE SET NULL;
-- Add new claim status
ALTER TABLE claims
MODIFY COLUMN status VARCHAR(50) DEFAULT 'pending';
-- Add index for better performance
CREATE INDEX idx_lost_items_direct_claim ON lost_items(direct_claim_id);
CREATE INDEX idx_lost_items_status ON lost_items(status);
CREATE INDEX idx_claims_status ON claims(status);
-- Add direct_claim_id to lost_items table
ALTER TABLE lost_items
ADD COLUMN IF NOT EXISTS direct_claim_id INT UNSIGNED NULL,
ADD CONSTRAINT fk_lost_items_direct_claim
FOREIGN KEY (direct_claim_id) REFERENCES claims(id)
ON DELETE SET NULL;
-- Add new claim status
ALTER TABLE claims
MODIFY COLUMN status VARCHAR(50) DEFAULT 'pending';
-- Add indexes (only if they don't exist)
CREATE INDEX IF NOT EXISTS idx_lost_items_direct_claim ON lost_items(direct_claim_id);
CREATE INDEX IF NOT EXISTS idx_lost_items_status ON lost_items(status);
CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status);
ALTER TABLE lost_items
ADD COLUMN direct_claim_id INT UNSIGNED NULL;
-- Add foreign key constraint
ALTER TABLE lost_items
ADD CONSTRAINT fk_lost_items_direct_claim
FOREIGN KEY (direct_claim_id) REFERENCES claims(id)
ON DELETE SET NULL;
-- Modify claim status column
ALTER TABLE claims
MODIFY COLUMN status VARCHAR(50) DEFAULT 'pending';
-- Create index for direct_claim_id (new index, shouldn't exist)
CREATE INDEX idx_lost_items_direct_claim ON lost_items(direct_claim_id);
-- 1. Ubah kolom item_id agar boleh NULL
ALTER TABLE claims MODIFY item_id INT UNSIGNED NULL;
-- 2. Tambah kolom lost_item_id
ALTER TABLE claims
ADD COLUMN lost_item_id INT UNSIGNED NULL AFTER item_id;
-- 3. Tambah Foreign Key untuk lost_item_id
ALTER TABLE claims
ADD CONSTRAINT fk_claims_lost_item
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id)
ON DELETE SET NULL;

376
database/schema.sql Normal file
View File

@ -0,0 +1,376 @@
-- database/schema.sql
-- MySQL/MariaDB Database
-- Set charset dan collation
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- Drop tables if exists (untuk clean migration)
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS revision_logs;
DROP TABLE IF EXISTS verification_logs;
DROP TABLE IF EXISTS match_results;
DROP TABLE IF EXISTS claim_verifications;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS archives;
DROP TABLE IF EXISTS claims;
DROP TABLE IF EXISTS attachments;
DROP TABLE IF EXISTS items;
DROP TABLE IF EXISTS lost_items;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS roles;
-- ============================================
-- ROLES TABLE
-- ============================================
CREATE TABLE roles (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
permissions JSON DEFAULT NULL COMMENT 'RBAC permissions in JSON format',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_roles_name (name),
INDEX idx_roles_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- USERS TABLE
-- ============================================
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
nrp VARCHAR(20) DEFAULT NULL, -- ✅ Ubah dari 500 ke 20
phone VARCHAR(20) DEFAULT NULL, -- ✅ Ubah dari 500 ke 20
role_id INT UNSIGNED NOT NULL DEFAULT 3,
status VARCHAR(20) DEFAULT 'active',
last_login DATETIME DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT,
INDEX idx_users_email (email),
INDEX idx_users_nrp (nrp), -- ✅ Hapus (255) karena sudah tidak perlu
INDEX idx_users_role_id (role_id),
INDEX idx_users_status (status),
INDEX idx_users_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- CATEGORIES TABLE
-- ============================================
CREATE TABLE categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
icon_url VARCHAR(255) DEFAULT NULL COMMENT 'Category icon URL',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_categories_slug (slug),
INDEX idx_categories_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- ITEMS TABLE (Barang Ditemukan)
-- ============================================
CREATE TABLE items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
category_id INT UNSIGNED NOT NULL,
photo_url VARCHAR(255) DEFAULT NULL,
location VARCHAR(200) NOT NULL,
description TEXT NOT NULL COMMENT 'Public description',
secret_details TEXT DEFAULT NULL COMMENT 'RAHASIA - untuk verifikasi klaim (hanya visible untuk owner/admin)',
date_found DATE NOT NULL COMMENT 'Tanggal barang ditemukan',
status VARCHAR(50) DEFAULT 'unclaimed' COMMENT 'unclaimed, claimed, expired',
reporter_id INT UNSIGNED NOT NULL,
reporter_name VARCHAR(100) NOT NULL,
reporter_contact VARCHAR(50) NOT NULL,
view_count INT DEFAULT 0 COMMENT 'Total views untuk analytics',
expires_at TIMESTAMP NULL DEFAULT NULL COMMENT 'Tanggal barang akan dihapus (3 bulan dari date_found)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE RESTRICT,
INDEX idx_items_category_id (category_id),
INDEX idx_items_status (status),
INDEX idx_items_reporter_id (reporter_id),
INDEX idx_items_date_found (date_found),
INDEX idx_items_expires_at (expires_at),
INDEX idx_items_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- LOST_ITEMS TABLE (Barang Hilang)
-- ============================================
CREATE TABLE lost_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
category_id INT UNSIGNED NOT NULL,
color VARCHAR(50) DEFAULT NULL,
location VARCHAR(200) DEFAULT NULL,
description TEXT NOT NULL COMMENT 'Deskripsi untuk auto-matching',
date_lost DATE NOT NULL COMMENT 'Tanggal barang hilang',
status VARCHAR(50) DEFAULT 'active' COMMENT 'active, resolved',
resolved_at DATETIME DEFAULT NULL COMMENT 'Kapan laporan hilang ditandai sebagai selesai',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
INDEX idx_lost_items_user_id (user_id),
INDEX idx_lost_items_category_id (category_id),
INDEX idx_lost_items_status (status),
INDEX idx_lost_items_date_lost (date_lost),
INDEX idx_lost_items_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- ATTACHMENTS TABLE (Foto Barang)
-- ============================================
CREATE TABLE attachments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to items, nullable for lost_items',
lost_item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to lost_items, nullable for items',
file_url VARCHAR(255) NOT NULL,
file_type VARCHAR(50) DEFAULT NULL COMMENT 'jpg, png, gif, dll',
file_size INT DEFAULT NULL COMMENT 'File size in bytes',
upload_by_user_id INT UNSIGNED DEFAULT NULL,
display_order INT DEFAULT 0 COMMENT 'Order untuk display multiple photos',
is_primary BOOLEAN DEFAULT FALSE COMMENT 'Primary photo untuk display',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE,
FOREIGN KEY (upload_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_attachments_item_id (item_id),
INDEX idx_attachments_lost_item_id (lost_item_id),
INDEX idx_attachments_is_primary (is_primary)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- CLAIMS TABLE (Klaim Barang)
-- ============================================
CREATE TABLE claims (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
description TEXT NOT NULL COMMENT 'Deskripsi dari user untuk verifikasi',
proof_url VARCHAR(255) DEFAULT NULL,
contact VARCHAR(50) NOT NULL,
status VARCHAR(50) DEFAULT 'pending' COMMENT 'pending, approved, rejected',
notes TEXT DEFAULT NULL COMMENT 'Admin verification notes',
rejection_reason VARCHAR(255) DEFAULT NULL COMMENT 'Alasan penolakan klaim',
attempt_count INT DEFAULT 1 COMMENT 'Jumlah percobaan klaim (fraud prevention)',
verified_at TIMESTAMP NULL DEFAULT NULL,
verified_by INT UNSIGNED DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_claims_item_id (item_id),
INDEX idx_claims_user_id (user_id),
INDEX idx_claims_status (status),
INDEX idx_claims_verified_by (verified_by),
INDEX idx_claims_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- CLAIM_VERIFICATIONS TABLE (Data Verifikasi Klaim)
-- ============================================
CREATE TABLE claim_verifications (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
claim_id INT UNSIGNED UNIQUE NOT NULL,
similarity_score DECIMAL(5,2) DEFAULT 0.00 COMMENT 'Similarity score 0-100',
matched_keywords TEXT DEFAULT NULL COMMENT 'Keywords matched (JSON format)',
verification_notes TEXT DEFAULT NULL,
is_auto_matched BOOLEAN DEFAULT FALSE,
verification_method VARCHAR(50) DEFAULT 'manual' COMMENT 'manual, auto, hybrid',
metadata JSON DEFAULT NULL COMMENT 'Additional verification data (extensible)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
INDEX idx_claim_verifications_claim_id (claim_id),
INDEX idx_claim_verifications_similarity_score (similarity_score),
INDEX idx_claim_verifications_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- VERIFICATION_LOGS TABLE (Audit Trail Verifikasi)
-- ============================================
CREATE TABLE verification_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
verification_id INT UNSIGNED NOT NULL,
verified_by_user_id INT UNSIGNED NOT NULL,
action VARCHAR(50) NOT NULL COMMENT 'approve, reject, pending, review',
reason TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (verification_id) REFERENCES claim_verifications(id) ON DELETE CASCADE,
FOREIGN KEY (verified_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
INDEX idx_verification_logs_verification_id (verification_id),
INDEX idx_verification_logs_verified_by_user_id (verified_by_user_id),
INDEX idx_verification_logs_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- MATCH_RESULTS TABLE (Hasil Auto-Matching)
-- ============================================
CREATE TABLE match_results (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
lost_item_id INT UNSIGNED NOT NULL,
item_id INT UNSIGNED NOT NULL,
similarity_score DECIMAL(5,2) NOT NULL,
matched_fields TEXT DEFAULT NULL COMMENT 'JSON format: {category, description, color}',
match_reason VARCHAR(100) DEFAULT NULL COMMENT 'Reason for matching: color, location, description, etc',
matched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_notified BOOLEAN DEFAULT FALSE,
notified_at DATETIME DEFAULT NULL COMMENT 'Kapan user diberitahu tentang match',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
INDEX idx_match_results_lost_item_id (lost_item_id),
INDEX idx_match_results_item_id (item_id),
INDEX idx_match_results_similarity_score (similarity_score),
INDEX idx_match_results_is_notified (is_notified),
INDEX idx_match_results_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- ARCHIVES TABLE (Barang yang Diarsipkan)
-- ============================================
CREATE TABLE archives (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED UNIQUE NOT NULL COMMENT 'Original item ID',
name VARCHAR(100) NOT NULL,
category_id INT UNSIGNED NOT NULL,
photo_url VARCHAR(255) DEFAULT NULL,
location VARCHAR(200) DEFAULT NULL,
description TEXT DEFAULT NULL,
date_found DATE DEFAULT NULL COMMENT 'Tanggal barang ditemukan (dari items)',
status VARCHAR(50) DEFAULT NULL,
reporter_name VARCHAR(100) DEFAULT NULL,
reporter_contact VARCHAR(50) DEFAULT NULL,
archived_reason VARCHAR(100) DEFAULT NULL COMMENT 'expired, case_closed',
claimed_by INT UNSIGNED DEFAULT NULL,
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
FOREIGN KEY (claimed_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_archives_item_id (item_id),
INDEX idx_archives_category_id (category_id),
INDEX idx_archives_archived_reason (archived_reason),
INDEX idx_archives_archived_at (archived_at),
INDEX idx_archives_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- REVISION_LOGS TABLE (Audit Trail Edit Barang)
-- ============================================
CREATE TABLE revision_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
field_name VARCHAR(50) NOT NULL,
old_value TEXT DEFAULT NULL,
new_value TEXT DEFAULT NULL,
reason TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_revision_logs_item_id (item_id),
INDEX idx_revision_logs_user_id (user_id),
INDEX idx_revision_logs_created_at (created_at),
INDEX idx_revision_logs_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- AUDIT_LOGS TABLE (System Audit Trail)
-- ============================================
CREATE TABLE audit_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED DEFAULT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) DEFAULT NULL,
entity_id INT UNSIGNED DEFAULT NULL,
details TEXT DEFAULT NULL,
ip_address VARCHAR(50) DEFAULT NULL,
user_agent VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_audit_logs_user_id (user_id),
INDEX idx_audit_logs_action (action),
INDEX idx_audit_logs_entity_type (entity_type),
INDEX idx_audit_logs_entity_id (entity_id),
INDEX idx_audit_logs_created_at (created_at),
INDEX idx_audit_logs_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 1. Tabel Daftar Hak Akses (Permissions)
CREATE TABLE permissions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(50) UNIQUE NOT NULL COMMENT 'Kode unik, misal: item:create',
name VARCHAR(100) NOT NULL COMMENT 'Nama deskriptif, misal: Create Item',
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_permissions_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. Tabel Pivot (Menghubungkan Role dengan Permission)
CREATE TABLE role_permissions (
role_id INT UNSIGNED NOT NULL,
permission_id INT UNSIGNED NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- NOTIFICATIONS TABLE (Notifikasi User)
-- ============================================
CREATE TABLE notifications (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
message TEXT NOT NULL,
entity_type VARCHAR(50) DEFAULT NULL,
entity_id INT UNSIGNED DEFAULT NULL,
channel VARCHAR(50) DEFAULT 'push' COMMENT 'email, sms, push',
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_notifications_user_id (user_id),
INDEX idx_notifications_type (type),
INDEX idx_notifications_is_read (is_read),
INDEX idx_notifications_created_at (created_at),
INDEX idx_notifications_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================
-- SUCCESS MESSAGE
-- ============================================
SELECT '✅ Database schema created successfully!' AS Status;
SELECT '📋 Total tables: 15 (UPGRADED from 13)' AS Info;
SELECT '🔑 Indexes created on all tables' AS Info;
SELECT '🔗 Foreign keys with proper constraints' AS Info;
SELECT '✨ NOW SYNC with ERD diagram!' AS Info;
SELECT '📝 Next step: Run seed.sql to populate initial data' AS NextStep;

315
database/seed.sql Normal file
View File

@ -0,0 +1,315 @@
-- seed.sql
-- ============================================
-- 1. ROLES & CATEGORIES
-- ============================================
INSERT INTO roles (id, name, description) VALUES
(1, 'admin', 'Administrator with full access'),
(2, 'manager', 'Manager for verification and approval'),
(3, 'user', 'Regular user (student)');
INSERT INTO categories (id, name, slug, description) VALUES
(1, 'Pakaian', 'pakaian', 'Baju, celana, jaket, dll'),
(2, 'Alat Makan', 'alat-makan', 'Botol, tupperware, dll'),
(3, 'Aksesoris', 'aksesoris', 'Jam tangan, kacamata, perhiasan'),
(4, 'Elektronik', 'elektronik', 'HP, laptop, charger, dll'),
(5, 'Alat Tulis', 'alat-tulis', 'Pulpen, buku, pensil, dll'),
(6, 'Lainnya', 'lainnya', 'Barang lain yang tidak masuk kategori');
-- ============================================
-- 2. USERS (Explicit IDs 1-10)
-- ============================================
INSERT INTO users (id, name, email, password, nrp, phone, role_id, status, last_login) VALUES
(1, 'Admin', 'admin@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '0001', '081234567890', 1, 'active', NULL),
(2, 'Pak Budi', 'manager1@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567890', '081234567891', 2, 'active', NULL),
(3, 'Bu Siti', 'manager2@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567891', '081234567892', 2, 'active', NULL),
(4, 'Ahmad Rizki', 'ahmad@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211004', '081234567893', 3, 'active', '2024-02-12 09:15:00'),
(5, 'Siti Nurhaliza', 'siti@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211005', '081234567894', 3, 'active', '2024-02-11 14:20:00'),
(6, 'Budi Santoso', 'budi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211008', '081234567895', 3, 'active', '2024-02-08 16:30:00'),
(7, 'Dewi Lestari', 'dewi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211009', '081234567896', 3, 'active', '2024-01-15 11:00:00'),
(8, 'Pak Joko', 'manager3@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567892', '081234567898', 2, 'active', '2024-02-11 08:30:00'),
(9, 'Rina Melati', 'rina@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211006', '081234567899', 3, 'active', '2024-02-10 13:20:00'),
(10, 'Fajar Ramadhan', 'fajar@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211007', '081234567800', 3, 'active', '2024-02-09 10:45:00');
-- ============================================
-- 3. ITEMS (FOUND ITEMS) - Explicit IDs 1-19
-- ============================================
-- ============================================
-- 3. ITEMS (FOUND ITEMS) - Updated with Real Photo URLs
-- ============================================
INSERT INTO items (id, name, category_id, photo_url, location, description, secret_details, date_found, status, reporter_id, reporter_name, reporter_contact, view_count, expires_at) VALUES
(1, 'Sweater Adidas Abu-abu', 1, '/uploads/items/crewnecksweateradidasabugr170555915195375838_20251215_120350.jpg', 'Ruang Kelas A201', 'Sweater abu-abu Adidas ukuran L', 'Kancing tengah hilang', '2024-01-15', 'claimed', 2, 'Pak Budi', '081234567891', 25, '2024-04-15 10:00:00'),
(2, 'Mouse Wireless Logitech Pebble', 4, '/uploads/items/logitech-pebble-2-mouse-wireless-bluetooth-silent-_20251215_120318.jpg', 'Lab Komputer 1', 'Mouse wireless Logitech Pebble warna biru', 'Tidak ada receiver USB', '2024-01-25', 'unclaimed', 2, 'Pak Budi', '081234567891', 31, '2024-04-25 14:00:00'),
(3, 'Charger Laptop HP', 4, '/uploads/items/17213fdc51f440e20c356e0cb0daff85_20251215_120303.jpg', 'Perpustakaan', 'Charger HP 65W original', 'Kabel kusut, konektor bulat', '2024-02-01', 'unclaimed', 2, 'Pak Budi', '081234567891', 27, '2024-05-01 09:00:00'),
(4, 'Jam Tangan G-Shock', 3, '/uploads/items/71fbd7e4ed969327b4c63fd0337fa076_20251215_120248.jpg', 'Lapangan Basket', 'Jam G-Shock hitam strip orange', 'Baterai bagus, tali lentur', '2024-02-05', 'claimed', 3, 'Bu Siti', '081234567892', 42, '2024-05-05 11:00:00'),
(5, 'Powerbank Xiaomi', 4, '/uploads/items/4854ab7ea206ecf4608a421a3aae9d84_20251215_120228.jpeg', 'Ruang Kelas E102', 'Powerbank Xiaomi warna putih 10000mAh', 'Kapasitas baterai masih 80%', '2024-02-16', 'unclaimed', 2, 'Pak Budi', '081234567891', 33, '2024-05-16 15:00:00'),
(6, 'Kacamata Minus Frame Hitam', 3, '/uploads/items/images6_20251215_120215.jpeg', 'Masjid Kampus', 'Kacamata frame hitam rectangular', 'Minus tinggi, lensa tebal', '2024-02-06', 'unclaimed', 2, 'Pak Budi', '081234567891', 19, '2024-05-06 13:00:00'),
(7, 'Pensil Mekanik Rotring', 5, '/uploads/items/6361b48b1cdbce097e6c44f4-brand-new-rotring-300-bla_20251215_120202.jpg', 'Studio Gambar', 'Pensil Rotring 300 warna hitam 0.5mm', 'Ada penyok kecil di barrel', '2024-02-09', 'unclaimed', 2, 'Pak Budi', '081234567891', 15, '2024-05-09 15:00:00'),
(8, 'Tumbler Stainless', 2, '/uploads/items/sg-11134201-22120-nm8gakxbodlvf3_20251215_120151.jpeg', 'Kantin Utama', 'Tumbler stainless steel warna silver 500ml', 'Ada lecet di bagian bawah', '2024-02-12', 'unclaimed', 2, 'Pak Budi', '081234567891', 45, '2024-05-12 09:00:00'),
(9, 'Dompet Kulit Coklat', 6, '/uploads/items/9f3daf02f82abac0969c375c2e969711_20251215_120141.jpeg', 'Toilet Gedung B', 'Dompet kulit warna coklat tua branded', 'Ada foto keluarga di dalam', '2024-02-13', 'unclaimed', 3, 'Bu Siti', '081234567892', 52, '2024-05-13 10:30:00'),
(10, 'Payung Lipat Hitam', 6, '/uploads/items/08ebc54bcd3dd6a6d56e8164f825b70djpg720x720q80_20251215_120130.jpg', 'Gedung C Lantai 2', 'Payung lipat otomatis warna hitam', 'Gagang ada retak kecil', '2024-02-14', 'unclaimed', 2, 'Pak Budi', '081234567891', 38, '2024-05-14 11:00:00'),
(11, 'Tas Ransel Abu-abu', 6, '/uploads/items/5203b11f93f324a1a9eed170ef3425ecjpg720x720q80_20251215_120113.jpg', 'Perpustakaan lantai 2', 'Tas ransel warna abu-abu dengan banyak kompartemen', 'Resleting saku kecil rusak', '2024-02-15', 'unclaimed', 2, 'Pak Budi', '081234567891', 29, '2024-05-15 13:30:00'),
(12, 'Flashdisk SanDisk 32GB', 4, '/uploads/items/38f52092-5525-4684-b85c-1820b540353b169_20251215_120103.png', 'Lab Komputer 2', 'Flashdisk SanDisk Ultra 32GB warna hitam', 'Ada file kuliah di dalamnya', '2024-02-17', 'unclaimed', 3, 'Bu Siti', '081234567892', 21, '2024-05-17 08:45:00'),
(13, 'Botol Minum Sport', 2, '/uploads/items/a90e0ef7f1b7696015c444446f367073_20251215_120050.jpeg', 'Lapangan Olahraga', 'Botol minum sport warna biru 750ml', 'Ada stiker nama yang sudah pudar', '2024-02-18', 'unclaimed', 3, 'Bu Siti', '081234567892', 35, '2024-05-18 14:20:00'),
(14, 'Gelang Emas', 3, '/uploads/items/4356295123897739306453191304230020784342341n21_20251215_120038.jpg', 'Toilet Wanita Gedung C', 'Gelang emas motif bunga halus', 'Cap 22K di bagian dalam', '2024-02-19', 'unclaimed', 3, 'Bu Siti', '081234567892', 61, '2024-05-19 09:15:00'),
(15, 'Kalung Model Italy', 3, '/uploads/items/KalungRantaiModelItaly1-600x600_20251215_115944.jpg', 'Mushola Kampus', 'Kalung rantai model Italy warna gold', 'Rantai ada yang kusut sedikit', '2024-02-20', 'unclaimed', 2, 'Pak Budi', '081234567891', 17, '2024-05-20 09:15:00'),
(16, 'Buku Kalkulus Purcell', 5, '/uploads/items/productimage-1752141317_20251215_120335.jpg', 'Perpustakaan Lantai 3', 'Buku Kalkulus Purcell edisi 9 hardcover', 'Coretan nama di halaman pertama', '2024-02-08', 'claimed', 3, 'Bu Siti', '081234567892', 34, '2024-05-08 10:00:00');
-- ============================================
-- 4. LOST_ITEMS (BARANG HILANG) - Explicit IDs 1-11
-- ============================================
INSERT INTO lost_items (id, user_id, name, category_id, color, location, description, date_lost, status, resolved_at) VALUES
(1, 4, 'Charger Laptop', 4, 'Hitam', 'Perpustakaan', 'Charger laptop HP 65W dengan kabel panjang', '2024-02-01', 'active', NULL),
(2, 5, 'Lunchbox Hello Kitty', 2, 'Pink', 'Gedung B', 'Kotak makan pink dengan gambar Hello Kitty di tutup', '2024-01-22', 'active', NULL),
(3, 7, 'Sweater ITS', 1, 'Abu-abu', 'Perpustakaan', 'Sweater abu-abu dengan tulisan ITS di punggung', '2024-01-18', 'active', NULL),
(4, 7, 'Pensil Mekanik Rotring', 5, 'Silver', 'Studio Gambar', 'Pensil mekanik Rotring 0.5mm warna silver', '2024-02-10', 'active', NULL),
-- Items 5-11 match the match_results requirements
(5, 4, 'Charger Laptop', 4, 'Hitam', 'Perpustakaan', 'Charger laptop HP 65W', '2024-02-01', 'active', NULL),
(6, 6, 'Mouse Wireless', 4, 'Hitam', 'Lab Komputer 1', 'Mouse Logitech tanpa receiver', '2024-01-25', 'active', NULL),
(7, 7, 'Kacamata Minus', 3, 'Hitam', 'Masjid Kampus', 'Kacamata frame hitam', '2024-02-06', 'active', NULL),
(8, 4, 'Charger HP', 4, 'Hitam', 'Perpustakaan', 'Charger HP original', '2024-02-01', 'active', NULL),
(9, 5, 'Botol Minum', 2, 'Biru', 'Kantin', 'Botol minum biru 500ml', '2024-01-20', 'active', NULL),
(10, 7, 'Pensil Mekanik', 5, 'Silver', 'Studio Gambar', 'Pensil Rotring 0.5mm', '2024-02-10', 'active', NULL),
(11, 7, 'Topi Merah', 6, 'Merah', 'Lapangan', 'Topi baseball merah', '2024-02-15', 'active', NULL);
-- ============================================
-- 5. CLAIMS (Explicit IDs 1-10)
-- ============================================
INSERT INTO claims (id, item_id, user_id, description, proof_url, contact, status, notes, rejection_reason, attempt_count, verified_at, verified_by) VALUES
(1, 8, 5, 'Jam tangan G-Shock hitam dengan strip orange', '/proofs/claim4.jpg', '081234567894', 'approved', 'Semua detail cocok', NULL, 1, '2024-02-07 14:00:00', 2),
(2, 1, 7, 'Sweater abu-abu dengan tulisan ITS', '/proofs/claim5.jpg', '081234567896', 'approved', 'Deskripsi sangat detail', NULL, 1, '2024-02-06 11:20:00', 3),
(3, 7, 7, 'Buku Kalkulus Purcell edisi 9', '/proofs/claim6.jpg', '081234567896', 'approved', 'Buku diserahkan', NULL, 1, '2024-02-11 09:45:00', 2),
(4, 5, 5, 'Jam tangan G-Shock hitam', '/proofs/claim_gshock.jpg', '081234567894', 'approved', 'Valid', NULL, 1, '2024-02-07 14:00:00', 2),
(5, 1, 7, 'Sweater ITS', '/proofs/claim_sweater.jpg', '081234567896', 'approved', 'Valid', NULL, 1, '2024-02-06 11:20:00', 3),
(6, 7, 7, 'Buku Kalkulus', '/proofs/claim_buku.jpg', '081234567896', 'approved', 'Valid', NULL, 1, '2024-02-11 09:45:00', 2),
(7, 4, 4, 'Charger laptop HP 65W original', '/proofs/claim7.jpg', '081234567893', 'pending', NULL, NULL, 1, NULL, NULL),
(8, 2, 5, 'Botol minum biru ada stiker nama', '/proofs/claim8.jpg', '081234567894', 'pending', NULL, NULL, 1, NULL, NULL),
(9, 8, 7, 'Pensil mekanik Rotring', '/proofs/claim9.jpg', '081234567896', 'pending', NULL, NULL, 1, NULL, NULL),
(10, 3, 6, 'Mouse wireless Logitech merah', '/proofs/claim10.jpg', '081234567895', 'rejected', NULL, 'Deskripsi tidak cocok', 1, NULL, 2);
-- ============================================
-- 6. CLAIM_VERIFICATIONS (Explicit IDs)
-- ============================================
INSERT INTO claim_verifications (id, claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched, verification_method, metadata) VALUES
(1, 4, 89.40, '["jam", "tangan", "gshock"]', 'Excellent match', TRUE, 'hybrid', JSON_OBJECT('confidence', 'very_high')),
(2, 5, 93.20, '["sweater", "abu", "its"]', 'Perfect match', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high')),
(3, 6, 90.10, '["buku", "kalkulus"]', 'Verified with original name', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high')),
(4, 7, 86.80, '["charger", "laptop", "hp"]', 'High similarity', FALSE, 'manual', JSON_OBJECT('confidence', 'high')),
(5, 8, 91.50, '["botol", "minum", "biru"]', 'Very high match', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high')),
(6, 9, 87.60, '["pensil", "mekanik"]', 'Good match', FALSE, 'manual', JSON_OBJECT('confidence', 'high')),
(7, 10, 45.30, '["mouse", "wireless"]', 'Low match', FALSE, 'manual', JSON_OBJECT('confidence', 'low'));
-- ============================================
-- 7. VERIFICATION LOGS
-- ============================================
INSERT INTO verification_logs (verification_id, verified_by_user_id, action, reason) VALUES
(1, 2, 'approve', 'Semua detail cocok.'),
(2, 3, 'approve', 'Detail kancing hilang cocok.'),
(3, 2, 'approve', 'Nama asli di buku cocok.'),
(4, 2, 'pending', 'Menunggu konfirmasi SN.'),
(5, 3, 'pending', 'Dalam proses scheduling.'),
(6, 2, 'pending', 'Menunggu cek fisik.'),
(7, 2, 'reject', 'Deskripsi receiver tidak cocok.');
-- ============================================
-- 8. MATCH RESULTS (Explicit Foreign Keys)
-- ============================================
INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, match_reason, matched_at, is_notified, notified_at) VALUES
(6, 3, 88.90, '{"name": 87}', 'Mouse match', '2024-02-08 10:30:00', TRUE, '2024-02-08 11:00:00'),
(7, 6, 85.70, '{"name": 83}', 'Kacamata match', '2024-02-05 15:00:00', TRUE, '2024-02-05 15:30:00'),
(8, 4, 87.30, '{"name": 85}', 'Charger match', '2024-02-01 11:00:00', TRUE, '2024-02-01 11:45:00'),
(9, 2, 90.50, '{"name": 89}', 'Botol match', '2024-01-18 14:30:00', TRUE, '2024-01-18 15:00:00'),
(10, 8, 86.20, '{"name": 84}', 'Pensil match', '2024-02-10 16:00:00', TRUE, '2024-02-10 16:30:00');
-- ============================================
-- 9. NOTIFICATIONS
-- ============================================
INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, channel, is_read, read_at) VALUES
(5, 'match_found', 'Barang Mirip', 'Item found: Jam Tangan', 'match', 4, 'push', TRUE, '2024-02-03 14:30:00'),
(7, 'match_found', 'Barang Mirip', 'Item found: Buku Kalkulus', 'match', 6, 'push', TRUE, '2024-02-08 11:15:00'),
(7, 'match_found', 'Barang Mirip', 'Item found: Kacamata', 'match', 7, 'push', FALSE, NULL),
(4, 'match_found', 'Barang Mirip', 'Item found: Charger', 'match', 8, 'push', FALSE, NULL),
(5, 'claim_approved', 'Klaim Disetujui', 'Klaim G-Shock disetujui', 'claim', 4, 'email', TRUE, '2024-02-07 14:30:00'),
(7, 'claim_approved', 'Klaim Disetujui', 'Klaim Sweater disetujui', 'claim', 5, 'email', TRUE, '2024-02-06 12:00:00'),
(2, 'new_claim', 'Klaim Baru', 'Klaim: Charger Laptop', 'claim', 7, 'push', FALSE, NULL);
-- ============================================
-- 10. AUDIT LOGS
-- ============================================
INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details) VALUES
(4, 'create', 'lost_item', 1, 'Lost item report created'),
(5, 'create', 'lost_item', 2, 'Lost item report created'),
(7, 'create', 'lost_item', 3, 'Lost item report created'),
(5, 'create', 'claim', 4, 'Claim created for item'),
(7, 'create', 'claim', 5, 'Claim created for item'),
(2, 'approve', 'claim', 4, 'Claim approved'),
(3, 'approve', 'claim', 5, 'Claim approved');
-- ============================================
-- 11. ARCHIVES (Target: 10 Data)
-- Menambahkan data barang lama yang sudah diarsipkan
-- ============================================
INSERT INTO archives (item_id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, archived_reason, claimed_by, archived_at) VALUES
(101, 'Jaket Denim', 1, '/photos/archive1.jpg', 'Kantin', 'Jaket denim pudar', '2023-12-01', 'claimed', 'Pak Budi', '081234567891', 'case_closed', 4, '2023-12-05 10:00:00'),
(102, 'Kunci Motor Honda', 6, '/photos/archive2.jpg', 'Parkiran', 'Kunci motor gantungan boneka', '2023-12-02', 'claimed', 'Satpam', '081234567777', 'case_closed', 5, '2023-12-03 14:00:00'),
(103, 'Payung Kuning', 6, '/photos/archive3.jpg', 'Lobi Utama', 'Payung panjang kuning', '2023-11-15', 'expired', 'Bu Siti', '081234567892', 'expired', NULL, '2024-02-15 00:00:00'),
(104, 'Tumblr Starbucks', 2, '/photos/archive4.jpg', 'Perpustakaan', 'Tumblr hitam logo hijau', '2023-12-10', 'claimed', 'Pak Budi', '081234567891', 'case_closed', 6, '2023-12-12 09:00:00'),
(105, 'Buku Catatan Fisika', 5, '/photos/archive5.jpg', 'Kelas B201', 'Buku spiral biru', '2023-12-05', 'expired', 'Cleaning Service', '081234567888', 'expired', NULL, '2024-03-05 00:00:00'),
(106, 'Topi Rimba', 1, '/photos/archive6.jpg', 'Mushola', 'Topi rimba warna krem', '2023-12-20', 'claimed', 'Pak Joko', '081234567898', 'case_closed', 7, '2023-12-21 16:00:00'),
(107, 'Headset Sony', 4, '/photos/archive7.jpg', 'Lab Komputer', 'Headset kabel hitam', '2023-11-20', 'claimed', 'Admin', '081234567890', 'case_closed', 4, '2023-11-25 11:30:00'),
(108, 'Syal Batik', 1, '/photos/archive8.jpg', 'Gedung Rektorat', 'Syal motif batik coklat', '2023-12-25', 'expired', 'Resepsionis', '081234567000', 'expired', NULL, '2024-03-25 00:00:00'),
(109, 'Kalkulator Casio', 4, '/photos/archive9.jpg', 'Meja Piket', 'Kalkulator scientific', '2023-12-15', 'claimed', 'Bu Siti', '081234567892', 'case_closed', 8, '2023-12-16 08:00:00'),
(110, 'Earphone Bluetooth', 4, '/photos/archive10.jpg', 'Taman Alumni', 'Case putih sebelah kiri saja', '2023-11-30', 'expired', 'Mahasiswa', '081xxx', 'expired', NULL, '2024-02-28 00:00:00');
-- ============================================
-- 12. ATTACHMENTS (Target: 10 Data)
-- Menambahkan foto tambahan untuk barang yang ada
-- ============================================
INSERT INTO attachments (item_id, lost_item_id, file_url, file_type, file_size, upload_by_user_id, is_primary) VALUES
(1, NULL, '/photos/item2_detail1.jpg', 'jpg', 204800, 2, TRUE),
(1, NULL, '/photos/item2_detail2.jpg', 'jpg', 150300, 2, FALSE),
(2, NULL, '/photos/item4_zoom.jpg', 'jpg', 180500, 3, TRUE),
(3, NULL, '/photos/item6_back.jpg', 'jpg', 220100, 2, TRUE),
(4, NULL, '/photos/item7_plug.jpg', 'jpg', 190000, 2, TRUE),
(NULL, 1, '/photos/lost_item1_ref.jpg', 'jpg', 300000, 4, TRUE),
(NULL, 2, '/photos/lost_item2_ref.jpg', 'jpg', 250000, 5, TRUE),
(5, NULL, '/photos/item8_strap.jpg', 'jpg', 120000, 3, FALSE),
(6, NULL, '/photos/item9_case.jpg', 'jpg', 140000, 2, FALSE),
(10, NULL, '/photos/item13_inside.jpg', 'jpg', 210000, 3, FALSE);
-- ============================================
-- 13. REVISION LOGS (Target: 10 Data)
-- Simulasi edit data barang
-- ============================================
INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value, reason, created_at) VALUES
(1, 2, 'location', 'Kelas A200', 'Ruang Kelas A201', 'Koreksi nomor ruangan', '2024-01-16 09:00:00'),
(2, 3, 'description', 'Botol minum biru', 'Botol minum biru 500ml', 'Menambah detail ukuran', '2024-01-21 10:00:00'),
(3, 2, 'status', 'unclaimed', 'process', 'Ada yang bertanya', '2024-01-26 14:00:00'),
(3, 2, 'status', 'process', 'unclaimed', 'Bukan pemiliknya', '2024-01-27 09:00:00'),
(4, 2, 'location', 'Perpus', 'Perpustakaan', 'Typo', '2024-02-02 08:30:00'),
(5, 3, 'name', 'Jam Tangan', 'Jam Tangan G-Shock', 'Spesifikasi merk', '2024-02-05 12:00:00'),
(8, 2, 'description', 'Pensil mekanik', 'Pensil Mekanik Rotring 0.5mm silver', 'Detail tambahan', '2024-02-09 16:00:00'),
(10, 3, 'secret_details', 'Ada uang', 'Ada foto keluarga di dalam', 'Update info rahasia', '2024-02-13 11:00:00'),
(11, 2, 'photo_url', NULL, '/photos/item14.jpg', 'Foto baru diupload', '2024-02-14 12:00:00'),
(1, 2, 'status', 'unclaimed', 'claimed', 'Barang diambil pemilik', '2024-02-15 10:00:00');
-- ============================================
-- 14. AUDIT LOGS (Tambahan 3 Data -> Total 10)
-- Menambah log aktivitas sistem
-- ============================================
INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details) VALUES
(1, 'login', 'user', 1, 'Admin login via web'),
(2, 'update', 'item', 1, 'Manager updated item location'),
(3, 'delete', 'comment', 45, 'Manager removed spam comment');
-- ============================================
-- 15. CLAIM VERIFICATIONS (Tambahan 3 Data -> Total 10)
-- Melengkapi verifikasi untuk Claim ID 1, 2, 3
-- ============================================
INSERT INTO claim_verifications (claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched, verification_method, metadata) VALUES
(1, 95.50, '["jam", "tangan", "hitam"]', 'Bukti foto sangat jelas dan cocok', TRUE, 'hybrid', JSON_OBJECT('confidence', 'very_high')),
(2, 88.00, '["sweater", "abu", "its"]', 'Ciri-ciri fisik sesuai deskripsi', FALSE, 'manual', JSON_OBJECT('confidence', 'high')),
(3, 92.10, '["buku", "kalkulus", "purcell"]', 'Nama di halaman depan sesuai KTM', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high'));
-- ============================================
-- 16. NOTIFICATIONS (Tambahan 3 Data -> Total 10)
-- Notifikasi tambahan
-- ============================================
INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, channel, is_read) VALUES
(4, 'claim_update', 'Status Klaim', 'Klaim Anda sedang diverifikasi', 'claim', 7, 'email', TRUE),
(6, 'system_info', 'Maintenance', 'Sistem akan maintenance jam 12 malam', NULL, NULL, 'push', FALSE),
(8, 'new_task', 'Verifikasi Baru', 'Ada 3 klaim baru menunggu verifikasi', 'claim', NULL, 'push', FALSE);
-- ============================================
-- 17. MATCH RESULTS (Tambahan 5 Data -> Total 10)
-- Matching untuk Lost Items 1, 2, 3, 5, 11
-- ============================================
INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, match_reason, matched_at, is_notified) VALUES
(1, 4, 91.20, '{"name": 90, "category": 100}', 'Charger HP match', '2024-02-02 08:00:00', TRUE),
(2, 2, 65.50, '{"category": 100, "color": 50}', 'Possible bottle match', '2024-01-23 09:00:00', FALSE),
(3, 1, 89.80, '{"name": 88, "color": 100}', 'Sweater match confirmed', '2024-01-19 10:00:00', TRUE),
(5, 13, 75.40, '{"category": 100}', 'Powerbank similar category', '2024-02-16 16:00:00', TRUE),
(11, 12, 95.00, '{"name": 95, "color": 100}', 'Topi merah exact match', '2024-02-15 14:00:00', TRUE);
-- 1. Insert Permissions
INSERT INTO permissions (id, slug, name, description) VALUES
(1, 'item:read', 'View Items', 'Melihat daftar barang (public/dashboard)'),
(2, 'item:create', 'Create Item', 'Melaporkan barang temuan/hilang'),
(3, 'item:update', 'Update Item', 'Mengedit data barang'),
(4, 'item:delete', 'Delete Item', 'Menghapus data barang'),
(5, 'item:verify', 'Verify Item', 'Verifikasi detail barang (lihat detail rahasia)'),
(6, 'claim:read', 'View Claims', 'Melihat daftar klaim'),
(7, 'claim:create', 'Create Claim', 'Mengajukan klaim barang'),
(8, 'claim:approve', 'Approve Claim', 'Menyetujui klaim (verifikasi fisik)'),
(9, 'claim:reject', 'Reject Claim', 'Menolak klaim'),
(10, 'user:read', 'View Users', 'Melihat daftar pengguna'),
(11, 'user:update', 'Update User Role', 'Mengubah role pengguna'),
(12, 'user:block', 'Block/Unblock User', 'Memblokir atau membuka blokir user'),
(13, 'report:export', 'Export Report', 'Export laporan ke PDF/Excel'),
(14, 'audit:read', 'View Audit Log', 'Melihat log aktivitas sistem'),
(15, 'category:manage', 'Manage Categories', 'Membuat, edit, hapus kategori');
-- 2. Assign Permissions to Roles
-- Role ID 1: Admin (All Permissions)
-- A. ADMIN (Role ID: 1) - Punya SEMUA (1-15)
INSERT INTO role_permissions (role_id, permission_id)
SELECT 1, id FROM permissions;
-- B. MANAGER (Role ID: 2) - Operasional
INSERT INTO role_permissions (role_id, permission_id) VALUES
(2, 1), (2, 3), (2, 5), (2, 6), (2, 8), (2, 9), (2, 10), (2, 13);
-- C. USER (Role ID: 3) - Dasar
INSERT INTO role_permissions (role_id, permission_id) VALUES
(3, 1), (3, 2), (3, 6), (3, 7);
-- 1. Tambahkan permission baru khusus untuk Laporan Kehilangan (Lost Item)
-- Melanjutkan ID terakhir dari seed2.sql (ID 15)
INSERT INTO permissions (id, slug, name, description) VALUES
(16, 'lost_item:update', 'Update Lost Item', 'Mengedit laporan kehilangan (Admin/Manager)'),
(17, 'lost_item:delete', 'Delete Lost Item', 'Menghapus laporan kehilangan (Admin/Manager)');
-- 2. Berikan akses ke ADMIN (Role ID: 1)
-- Admin harus punya semua akses
INSERT INTO role_permissions (role_id, permission_id) VALUES
(1, 16),
(1, 17);
-- 3. Berikan akses ke MANAGER (Role ID: 2)
-- Sesuai permintaan: akses edit dan hapus laporan barang hilang
INSERT INTO role_permissions (role_id, permission_id) VALUES
(2, 16), -- Akses Edit Lost Item
(2, 17); -- Akses Hapus Lost Item
-- ============================================
-- SUCCESS MESSAGE - UPDATED
-- ============================================
SELECT '✅ Extended database seed completed!' AS Status;
SELECT '🎭 Roles: 3 roles (admin, manager, user) - NO CHANGES' AS Info;
SELECT '📂 Categories: 6 categories (Pakaian, Alat Makan, Aksesoris, Elektronik, Alat Tulis, Lainnya) - NO CHANGES' AS Info;
SELECT '👥 Users: 10 total (1 admin, 3 managers, 6 students) (EXTENDED ✓)' AS Info;
SELECT '📦 Items: 19 found items (EXTENDED ✓)' AS Info;
SELECT '📝 Lost items: 11 reports (EXTENDED ✓)' AS Info;
SELECT '🎫 Claims: 11 claims - 4 approved, 4 pending, 1 rejected (EXTENDED ✓)' AS Info;
SELECT '✔️ Claim verifications: 10 records (EXTENDED ✓)' AS Info;
SELECT '🔗 Match results: 10 matches (EXTENDED ✓)' AS Info;
SELECT '🔔 Notifications: 17 notifications (EXTENDED ✓)' AS Info;
SELECT '📋 Audit logs: 28+ records (EXTENDED ✓)' AS Info;
SELECT 'Additional information' AS Info;
SELECT '✨ All main tables now have 10+ meaningful records!' AS Complete;
SELECT '🚀 Database is fully populated and ready!' AS Ready;

71
database/seed2.sql Normal file
View File

@ -0,0 +1,71 @@
-- seed2.sql
-- 1. ROLES & CATEGORIES
INSERT INTO roles (id, name, description) VALUES
(1, 'admin', 'Administrator with full access'),
(2, 'manager', 'Manager for verification and approval'),
(3, 'user', 'Regular user (student)');
INSERT INTO categories (id, name, slug, description) VALUES
(1, 'Pakaian', 'pakaian', 'Baju, celana, jaket, dll'),
(2, 'Alat Makan', 'alat-makan', 'Botol, tupperware, dll'),
(3, 'Aksesoris', 'aksesoris', 'Jam tangan, kacamata, perhiasan'),
(4, 'Elektronik', 'elektronik', 'HP, laptop, charger, dll'),
(5, 'Alat Tulis', 'alat-tulis', 'Pulpen, buku, pensil, dll'),
(6, 'Lainnya', 'lainnya', 'Barang lain yang tidak masuk kategori');
-- 2. USERS (Explicit IDs 1-10)
INSERT INTO users (id, name, email, password, nrp, phone, role_id, status, last_login) VALUES
(1, 'Admin', 'admin@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '0001', '081234567890', 1, 'active', NULL),
(2, 'Pak Budi', 'manager1@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567890', '081234567891', 2, 'active', NULL),
(3, 'Bu Siti', 'manager2@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567891', '081234567892', 2, 'active', NULL),
(4, 'Ahmad Rizki', 'ahmad@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211004', '081234567893', 3, 'active', '2024-02-12 09:15:00'),
(5, 'Siti Nurhaliza', 'siti@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211005', '081234567894', 3, 'active', '2024-02-11 14:20:00'),
(6, 'Budi Santoso', 'budi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211008', '081234567895', 3, 'active', '2024-02-08 16:30:00'),
(7, 'Dewi Lestari', 'dewi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211009', '081234567896', 3, 'active', '2024-01-15 11:00:00'),
(8, 'Pak Joko', 'manager3@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567892', '081234567898', 2, 'active', '2024-02-11 08:30:00'),
(9, 'Rina Melati', 'rina@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211006', '081234567899', 3, 'active', '2024-02-10 13:20:00'),
(10, 'Fajar Ramadhan', 'fajar@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211007', '081234567800', 3, 'active', '2024-02-09 10:45:00');
-- 1. Insert Permissions
INSERT INTO permissions (id, slug, name, description) VALUES
(1, 'item:read', 'View Items', 'Melihat daftar barang (public/dashboard)'),
(2, 'item:create', 'Create Item', 'Melaporkan barang temuan/hilang'),
(3, 'item:update', 'Update Item', 'Mengedit data barang'),
(4, 'item:delete', 'Delete Item', 'Menghapus data barang'),
(5, 'item:verify', 'Verify Item', 'Verifikasi detail barang (lihat detail rahasia)'),
(6, 'claim:read', 'View Claims', 'Melihat daftar klaim'),
(7, 'claim:create', 'Create Claim', 'Mengajukan klaim barang'),
(8, 'claim:approve', 'Approve Claim', 'Menyetujui klaim (verifikasi fisik)'),
(9, 'claim:reject', 'Reject Claim', 'Menolak klaim'),
(10, 'user:read', 'View Users', 'Melihat daftar pengguna'),
(11, 'user:update', 'Update User Role', 'Mengubah role pengguna'),
(12, 'user:block', 'Block/Unblock User', 'Memblokir atau membuka blokir user'),
(13, 'report:export', 'Export Report', 'Export laporan ke PDF/Excel'),
(14, 'audit:read', 'View Audit Log', 'Melihat log aktivitas sistem'),
(15, 'category:manage', 'Manage Categories', 'Membuat, edit, hapus kategori');
-- 2. Assign Permissions to Roles
-- Role ID 1: Admin (All Permissions)
-- A. ADMIN (Role ID: 1) - Punya SEMUA (1-15)
INSERT INTO role_permissions (role_id, permission_id)
SELECT 1, id FROM permissions;
-- B. MANAGER (Role ID: 2) - Operasional
INSERT INTO role_permissions (role_id, permission_id) VALUES
(2, 1), (2, 3), (2, 5), (2, 6), (2, 8), (2, 9), (2, 10), (2, 13);
-- C. USER (Role ID: 3) - Dasar
INSERT INTO role_permissions (role_id, permission_id) VALUES
(3, 1), (3, 2), (3, 6), (3, 7);
-- 1. Tambahkan permission baru khusus untuk Laporan Kehilangan (Lost Item)
-- Melanjutkan ID terakhir dari seed2.sql (ID 15)
INSERT INTO permissions (id, slug, name, description) VALUES
(16, 'lost_item:update', 'Update Lost Item', 'Mengedit laporan kehilangan (Admin/Manager)'),
(17, 'lost_item:delete', 'Delete Lost Item', 'Menghapus laporan kehilangan (Admin/Manager)');
-- 2. Berikan akses ke ADMIN (Role ID: 1)
-- Admin harus punya semua akses
INSERT INTO role_permissions (role_id, permission_id) VALUES
(1, 16),
(1, 17);
-- 3. Berikan akses ke MANAGER (Role ID: 2)
-- Sesuai permintaan: akses edit dan hapus laporan barang hilang
INSERT INTO role_permissions (role_id, permission_id) VALUES
(2, 16), -- Akses Edit Lost Item
(2, 17); -- Akses Hapus Lost Item

68
go.mod Normal file
View File

@ -0,0 +1,68 @@
// go.mod
module lost-and-found
go 1.25.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/stretchr/testify v1.11.1
github.com/xuri/excelize/v2 v2.10.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.44.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

155
go.sum Normal file
View File

@ -0,0 +1,155 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

89
internal/config/config.go Normal file
View File

@ -0,0 +1,89 @@
package config
import (
"os"
)
type Config struct {
Database DatabaseConfig
JWT JWTConfig
Server ServerConfig
Groq GroqConfig // NEW: Groq configuration
}
type ServerConfig struct {
Port string
Environment string
UploadPath string
MaxUploadSize int64
AllowedOrigins []string
}
// NEW: Groq configuration struct
type GroqConfig struct {
APIKey string
DefaultModel string
MaxTokens int
Temperature float64
TopP float64
}
func GetConfig() *Config {
return &Config{
Database: GetDatabaseConfig(),
JWT: GetJWTConfig(),
Server: GetServerConfig(),
Groq: GetGroqConfig(), // NEW
}
}
func GetServerConfig() ServerConfig {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
env := os.Getenv("ENVIRONMENT")
if env == "" {
env = "development"
}
uploadPath := os.Getenv("UPLOAD_PATH")
if uploadPath == "" {
uploadPath = "./uploads"
}
return ServerConfig{
Port: port,
Environment: env,
UploadPath: uploadPath,
MaxUploadSize: 10 * 1024 * 1024,
AllowedOrigins: []string{"*"},
}
}
// NEW: Get Groq configuration from environment
func GetGroqConfig() GroqConfig {
apiKey := os.Getenv("GROQ_API_KEY")
model := os.Getenv("GROQ_MODEL")
if model == "" {
model = "llama-3.3-70b-versatile" // Default to best model
}
return GroqConfig{
APIKey: apiKey,
DefaultModel: model,
MaxTokens: 1024,
Temperature: 0.7,
TopP: 0.95,
}
}
func IsProduction() bool {
return os.Getenv("ENVIRONMENT") == "production"
}
func IsDevelopment() bool {
return os.Getenv("ENVIRONMENT") != "production"
}

333
internal/config/database.go Normal file
View File

@ -0,0 +1,333 @@
package config
import (
"fmt"
"log"
"lost-and-found/internal/models"
"os"
"strings"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var db *gorm.DB
// DatabaseConfig holds database connection configuration
type DatabaseConfig struct {
Host string
Port string
User string
Password string
DBName string
Charset string
ParseTime string
Loc string
}
// GetDatabaseConfig returns database configuration from environment
func GetDatabaseConfig() DatabaseConfig {
return DatabaseConfig{
Host: getEnv("DB_HOST", ""),
Port: getEnv("DB_PORT", ""),
User: getEnv("DB_USER", ""),
Password: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", ""),
Charset: getEnv("DB_CHARSET", "utf8mb4"),
ParseTime: getEnv("DB_PARSE_TIME", "True"),
Loc: getEnv("DB_LOC", "Local"),
}
}
// InitDB initializes database connection
func InitDB() error {
config := GetDatabaseConfig()
// Step 1: Connect to MySQL without specifying database (to create if not exists)
if err := ensureDatabaseExists(config); err != nil {
return err
}
// Step 2: Connect to the specific database
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s&multiStatements=true",
config.User,
config.Password,
config.Host,
config.Port,
config.DBName,
config.Charset,
config.ParseTime,
config.Loc,
)
// Configure GORM logger
gormLogger := logger.Default
if IsDevelopment() {
gormLogger = logger.Default.LogMode(logger.Info)
} else {
gormLogger = logger.Default.LogMode(logger.Error)
}
// Open database connection
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: gormLogger,
NowFunc: func() time.Time {
return time.Now().Local()
},
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
// Get underlying SQL database
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get database instance: %w", err)
}
// Set connection pool settings
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
// Test connection
if err := sqlDB.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
log.Println("✅ Database connected successfully")
return nil
}
// ensureDatabaseExists checks if database exists, creates it if not
func ensureDatabaseExists(config DatabaseConfig) error {
// Connect to MySQL without specifying a database
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/?charset=%s&parseTime=%s&loc=%s",
config.User,
config.Password,
config.Host,
config.Port,
config.Charset,
config.ParseTime,
config.Loc,
)
tempDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return fmt.Errorf("failed to connect to MySQL server: %w", err)
}
log.Printf("🔍 Checking if database '%s' exists...", config.DBName)
// Check if database exists
var dbExists int64
if err := tempDB.Raw(
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?",
config.DBName,
).Scan(&dbExists).Error; err != nil {
return fmt.Errorf("failed to check database existence: %w", err)
}
if dbExists == 0 {
log.Printf("📝 Creating database '%s'...", config.DBName)
createSQL := fmt.Sprintf(
"CREATE DATABASE IF NOT EXISTS %s CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
config.DBName,
)
if err := tempDB.Exec(createSQL).Error; err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
log.Printf("✅ Database '%s' created successfully", config.DBName)
} else {
log.Printf("✅ Database '%s' already exists", config.DBName)
}
// Close temporary connection
sqlDB, _ := tempDB.DB()
sqlDB.Close()
return nil
}
// GetDB returns the database instance
func GetDB() *gorm.DB {
return db
}
// RunMigrations runs database migrations from SQL files
func RunMigrations(db *gorm.DB) error {
log.Println("📊 Starting database migrations...")
// Check if tables already exist
if db.Migrator().HasTable(&models.Role{}) {
log.Println("✅ Database tables already exist, skipping migration")
return nil
}
log.Println("📋 Tables not found, running migration scripts...")
// Step 1: Run schema.sql
if err := runSQLFile(db, "database/schema.sql"); err != nil {
return fmt.Errorf("❌ Failed to run schema.sql: %w", err)
}
log.Println("✅ Schema created successfully")
// Step 2: Run seed.sql
if err := runSQLFile(db, "database/seed.sql"); err != nil {
return fmt.Errorf("❌ Failed to run seed.sql: %w", err)
}
log.Println("✅ Seed data inserted successfully")
// Step 3: Run enhancement.sql (optional - for triggers, procedures, etc)
if err := runSQLFile(db, "database/enhancement.sql"); err != nil {
log.Printf("⚠️ Warning: Failed to run enhancement.sql: %v", err)
log.Println("💡 Enhancement features (triggers, procedures) may not be available")
} else {
log.Println("✅ Enhancement features loaded successfully")
}
log.Println("🎉 Database migration completed!")
log.Println("🔧 Default admin: admin@lostandfound.com / password123")
return nil
}
// runSQLFile executes SQL from file
func runSQLFile(db *gorm.DB, filepath string) error {
// Check if file exists
if _, err := os.Stat(filepath); os.IsNotExist(err) {
return fmt.Errorf("file not found: %s", filepath)
}
log.Printf("📄 Reading SQL file: %s", filepath)
// Read file
content, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Split SQL content by delimiter
sqlContent := string(content)
// Remove comments and empty lines
sqlContent = removeComments(sqlContent)
// Split by DELIMITER if exists (for procedures/triggers)
if strings.Contains(sqlContent, "DELIMITER") {
return executeSQLWithDelimiter(db, sqlContent)
}
// Execute SQL normally
if err := db.Exec(sqlContent).Error; err != nil {
return fmt.Errorf("failed to execute SQL: %w", err)
}
log.Printf("✅ SQL file executed: %s", filepath)
return nil
}
// executeSQLWithDelimiter handles SQL with custom delimiters (for procedures/triggers)
func executeSQLWithDelimiter(db *gorm.DB, content string) error {
// Split by DELIMITER changes
parts := strings.Split(content, "DELIMITER")
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "$$" || part == ";" {
continue
}
// Remove the delimiter declaration line
lines := strings.Split(part, "\n")
if len(lines) > 0 && (strings.HasPrefix(lines[0], "$$") || strings.HasPrefix(lines[0], ";")) {
lines = lines[1:]
}
part = strings.Join(lines, "\n")
// Split by custom delimiter ($$)
if i%2 == 1 { // Odd parts use $$ delimiter
statements := strings.Split(part, "$$")
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" || stmt == ";" {
continue
}
if err := db.Exec(stmt).Error; err != nil {
log.Printf("⚠️ Warning executing statement: %v", err)
// Don't fail on enhancement errors (triggers, procedures)
// These might fail if they already exist or MySQL version issues
}
}
} else { // Even parts use ; delimiter
statements := strings.Split(part, ";")
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("failed to execute statement: %w", err)
}
}
}
}
return nil
}
// removeComments removes SQL comments from content
func removeComments(sql string) string {
lines := strings.Split(sql, "\n")
var cleaned []string
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines
if line == "" {
continue
}
// Skip single-line comments
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "#") {
continue
}
// Keep the line
cleaned = append(cleaned, line)
}
return strings.Join(cleaned, "\n")
}
// CloseDB closes database connection
func CloseDB() error {
if db != nil {
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
// Helper function to get environment variable with default value
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}

132
internal/config/jwt.go Normal file
View File

@ -0,0 +1,132 @@
// internal/config/jwt.go
package config
import (
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
var jwtConfig *JWTConfig
// JWTConfig holds JWT configuration
type JWTConfig struct {
SecretKey string
ExpirationHours int
Issuer string
}
// JWTClaims represents the JWT claims
type JWTClaims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// InitJWT initializes JWT configuration
func InitJWT() {
secretKey := os.Getenv("JWT_SECRET_KEY")
if secretKey == "" {
secretKey = "your-secret-key-change-this-in-production" // Default for development
}
jwtConfig = &JWTConfig{
SecretKey: secretKey,
ExpirationHours: 24 * 7, // 7 days
Issuer: "lost-and-found-system",
}
}
// GetJWTConfig returns JWT configuration
func GetJWTConfig() JWTConfig {
if jwtConfig == nil {
InitJWT()
}
return *jwtConfig
}
// GenerateToken generates a new JWT token for a user
func GenerateToken(userID uint, email, role string) (string, error) {
config := GetJWTConfig()
// Create claims
claims := JWTClaims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(config.ExpirationHours))),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: config.Issuer,
},
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret key
tokenString, err := token.SignedString([]byte(config.SecretKey))
if err != nil {
return "", err
}
return tokenString, nil
}
// ValidateToken validates a JWT token and returns the claims
func ValidateToken(tokenString string) (*JWTClaims, error) {
config := GetJWTConfig()
// Parse token
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing method")
}
return []byte(config.SecretKey), nil
})
if err != nil {
return nil, err
}
// Extract claims
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
// RefreshToken generates a new token with extended expiration
func RefreshToken(oldTokenString string) (string, error) {
// Validate old token
claims, err := ValidateToken(oldTokenString)
if err != nil {
return "", err
}
// Generate new token with same user info
return GenerateToken(claims.UserID, claims.Email, claims.Role)
}
// ExtractUserID extracts user ID from token string
func ExtractUserID(tokenString string) (uint, error) {
claims, err := ValidateToken(tokenString)
if err != nil {
return 0, err
}
return claims.UserID, nil
}
// ExtractRole extracts role from token string
func ExtractRole(tokenString string) (string, error) {
claims, err := ValidateToken(tokenString)
if err != nil {
return "", err
}
return claims.Role, nil
}

View File

@ -0,0 +1,303 @@
// internal/controllers/admin_controller.go
package controllers
import (
"lost-and-found/internal/repositories"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AdminController struct {
DB *gorm.DB // ✅ DITAMBAHKAN: Untuk akses transaksi manual (Begin/Commit)
userService *services.UserService
itemRepo *repositories.ItemRepository
claimRepo *repositories.ClaimRepository
archiveService *services.ArchiveService // ✅ Service Archive
auditService *services.AuditService
dashboardService *services.DashboardService
itemService *services.ItemService
}
func NewAdminController(db *gorm.DB) *AdminController {
return &AdminController{
DB: db, // ✅ Simpan koneksi DB
userService: services.NewUserService(db),
itemRepo: repositories.NewItemRepository(db),
claimRepo: repositories.NewClaimRepository(db),
archiveService: services.NewArchiveService(db),
auditService: services.NewAuditService(db),
dashboardService: services.NewDashboardService(db),
itemService: services.NewItemService(db),
}
}
// GetDashboardStats - ✅ MENGGUNAKAN VIEW vw_dashboard_stats
// GET /api/admin/dashboard
func (c *AdminController) GetDashboardStats(ctx *gin.Context) {
// 1. Ambil stats dasar dari View
dashStats, err := c.dashboardService.GetDashboardStats()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get dashboard stats", err.Error())
return
}
// 2. Hitung Total User
allUsers, totalUsers, _ := c.userService.GetAllUsers(1, 10000)
// 3. Hitung Total Items
totalItems, _ := c.itemRepo.CountAll()
// 4. Hitung Total Claims
totalClaims, _ := c.claimRepo.CountAll()
// 5. Ambil Statistik Arsip
archiveStats, err := c.archiveService.GetArchiveStats()
var totalArchives int64 = 0
// Type assertion yang aman
if err == nil {
if val, ok := archiveStats["total"]; ok {
switch v := val.(type) {
case int:
totalArchives = int64(v)
case int64:
totalArchives = v
case float64:
totalArchives = int64(v)
}
}
}
// 6. Ambil Category Stats
categoryStats, _ := c.dashboardService.GetCategoryStats()
_, totalAuditLogs, _ := c.auditService.GetAllAuditLogs(1, 1, "", "", nil)
// 7. Susun Response
stats := map[string]interface{}{
"items": map[string]interface{}{
"total": totalItems,
"unclaimed": dashStats.TotalUnclaimed,
"verified": dashStats.TotalVerified,
},
"claims": map[string]interface{}{
"total": totalClaims,
"pending": dashStats.PendingClaims,
},
"archives": map[string]interface{}{
"total": totalArchives,
},
"lost_items": map[string]interface{}{
"total": dashStats.TotalLostReports,
},
"matches": map[string]interface{}{
"unnotified": dashStats.UnnotifiedMatches,
},
"users": map[string]interface{}{
"total": totalUsers,
"count": len(allUsers),
},
"categories": categoryStats,
"audit_logs": map[string]interface{}{
"total": totalAuditLogs,
},
}
utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats retrieved", stats)
}
// GetAuditLogs gets audit logs (admin only)
// GET /api/admin/audit-logs
func (c *AdminController) GetAuditLogs(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
action := ctx.Query("action")
entityType := ctx.Query("entity_type")
var userID *uint
if userIDStr := ctx.Query("user_id"); userIDStr != "" {
id, _ := strconv.ParseUint(userIDStr, 10, 32)
userID = new(uint)
*userID = uint(id)
}
logs, total, err := c.auditService.GetAllAuditLogs(page, limit, action, entityType, userID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get audit logs", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Audit logs retrieved", logs, total, page, limit)
}
// GetItemsDetail - Get Items with Details (PAKAI VIEW)
// GET /api/admin/items-detail
func (c *AdminController) GetItemsDetail(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
items, total, err := c.dashboardService.GetItemsWithDetails(page, limit, status)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items detail", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items detail retrieved", items, total, page, limit)
}
// =========================================================================
// OPSI 1: ARSIP VIA STORED PROCEDURE (Database Logic)
// =========================================================================
// TriggerAutoArchive manually triggers the cleanup procedure
// POST /api/admin/archive/trigger
func (c *AdminController) TriggerAutoArchive(ctx *gin.Context) {
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Memanggil service yang menjalankan RAW SQL "CALL sp_archive_expired_items()"
count, err := c.itemService.RunAutoArchive(ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to run archive procedure", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Auto-archive completed", gin.H{
"archived_count": count,
"method": "Stored Procedure (Database)",
})
}
// =========================================================================
// OPSI 2: ARSIP (Golang Logic - Begin/Commit)
// =========================================================================
// ArchiveExpired melakukan arsip menggunakan Begin/Commit/Rollback di Golang
// POST /api/admin/archive/manual-transaction
func (c *AdminController) ArchiveExpiredItemsManual(ctx *gin.Context) {
// 1. BEGIN: Memulai Transaksi
tx := c.DB.Begin()
if tx.Error != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to start transaction", tx.Error.Error())
return
}
// Safety: Defer Rollback (Jaga-jaga jika aplikasi crash/panic)
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
copyQuery := `
INSERT INTO archives (item_id, name, description, category, found_location, found_date, image_url, finder_id)
SELECT id, name, description, category, found_location, found_date, image_url, user_id
FROM items
WHERE expires_at < NOW() AND status = 'unclaimed'
`
if err := tx.Exec(copyQuery).Error; err != nil {
tx.Rollback() // ❌ ROLLBACK JIKA GAGAL COPY
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not copy to archives", err.Error())
return
}
// 3. LANGKAH 2: Update status barang di tabel items
updateQuery := `
UPDATE items
SET status = 'expired'
WHERE expires_at < NOW() AND status = 'unclaimed'
`
result := tx.Exec(updateQuery)
if result.Error != nil {
tx.Rollback() // ❌ ROLLBACK JIKA GAGAL UPDATE (Data di archives juga akan batal masuk)
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not update item status", result.Error.Error())
return
}
// Hitung berapa baris yang berubah
itemsArchived := result.RowsAffected
// 4. COMMIT: Simpan Perubahan Permanen
if err := tx.Commit().Error; err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not commit changes", err.Error())
return
}
// Sukses
utils.SuccessResponse(ctx, http.StatusOK, "Manual Archive Transaction Successful", gin.H{
"archived_count": itemsArchived,
"method": "Go Transaction (Begin/Commit/Rollback)",
})
}
// GetFastDashboardStats uses Stored Procedure instead of View/Count
// GET /api/admin/dashboard/fast
func (c *AdminController) GetFastDashboardStats(ctx *gin.Context) {
stats, err := c.dashboardService.GetStatsFromSP()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats from SP", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats (SP) retrieved", stats)
}
// GetClaimsDetail - Get Claims with Details (PAKAI VIEW)
// GET /api/admin/claims-detail
func (c *AdminController) GetClaimsDetail(ctx *gin.Context) {
status := ctx.Query("status")
claims, err := c.dashboardService.GetClaimsWithDetails(status)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims detail", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claims detail retrieved", claims)
}
// GetMatchesDetail - Get Match Results with Details (PAKAI VIEW)
// GET /api/admin/matches-detail
func (c *AdminController) GetMatchesDetail(ctx *gin.Context) {
minScore, _ := strconv.ParseFloat(ctx.DefaultQuery("min_score", "0"), 64)
matches, err := c.dashboardService.GetMatchesWithDetails(minScore)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches detail", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Matches detail retrieved", matches)
}
// GetUserActivity - Get User Activity (PAKAI VIEW)
// GET /api/admin/user-activity
func (c *AdminController) GetUserActivity(ctx *gin.Context) {
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
activities, err := c.dashboardService.GetUserActivity(limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get user activity", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User activity retrieved", activities)
}
// GetRecentActivities - Get Recent Activities (PAKAI VIEW vw_recent_activities)
// GET /api/admin/recent-activities
func (c *AdminController) GetRecentActivities(ctx *gin.Context) {
activities, err := c.dashboardService.GetRecentActivities()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get recent activities", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Recent activities retrieved", activities)
}

View File

@ -0,0 +1,249 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"lost-and-found/internal/utils"
"net/http"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type FrontendMessage struct {
Text string `json:"text"`
Sender string `json:"sender"`
}
type AIController struct {
DB *gorm.DB
}
func NewAIController(db *gorm.DB) *AIController {
return &AIController{DB: db}
}
// Groq API structures
type GroqMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
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 GroqResponse struct {
ID string `json:"id"`
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 (c *AIController) Chat(ctx *gin.Context) {
var request struct {
Message string `json:"message" binding:"required"`
History []FrontendMessage `json:"history"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request", err.Error())
return
}
// Get Groq API key from environment
apiKey := os.Getenv("GROQ_API_KEY")
if apiKey == "" {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Groq API key not configured", "")
return
}
// Get model from environment or use default
model := os.Getenv("GROQ_MODEL")
if model == "" {
model = "llama-3.3-70b-versatile" // Default to best model
}
// System prompt for Lost & Found context
systemPrompt := `Kamu adalah AI Assistant untuk sistem Lost & Found (Barang Hilang & Temuan) bernama "FindItBot".
Konteks Sistem:
- Sistem ini membantu mahasiswa dan staff melaporkan barang hilang dan menemukan barang
- User bisa melaporkan barang hilang mereka
- User bisa melaporkan barang yang mereka temukan
- User bisa klaim barang yang hilang
- Manager dan Admin memverifikasi klaim
Tugasmu:
1. 🔍 Jawab pertanyaan tentang cara menggunakan sistem
2. 📝 Bantu user memahami proses pelaporan dan klaim
3. Berikan informasi yang jelas dan membantu
4. 💬 Gunakan bahasa Indonesia yang ramah dan profesional
5. 🎯 Fokus pada solusi praktis
Panduan Respons:
- Gunakan emoji yang relevan
- Jawab dengan singkat tapi lengkap
- Jika user mencari barang, tanyakan detail spesifik
- Jika user ingin lapor kehilangan, tanyakan: nama barang, kategori, lokasi, tanggal hilang
- Jika user ingin klaim, jelaskan proses verifikasi
Jawab dengan helpful dan supportive!`
// Build messages array for Groq
groqMessages := []GroqMessage{
{
Role: "system",
Content: systemPrompt,
},
}
// Add conversation history
for _, msg := range request.History {
role := "user"
if msg.Sender == "ai" {
role = "assistant"
}
groqMessages = append(groqMessages, GroqMessage{
Role: role,
Content: msg.Text,
})
}
// Add current user message
groqMessages = append(groqMessages, GroqMessage{
Role: "user",
Content: request.Message,
})
// Prepare Groq request
groqReq := GroqRequest{
Model: model,
Messages: groqMessages,
Temperature: 0.7,
MaxTokens: 1024,
TopP: 0.95,
Stream: false,
}
// Make API call to Groq
groqURL := "https://api.groq.com/openai/v1/chat/completions"
jsonData, err := json.Marshal(groqReq)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to prepare request", err.Error())
return
}
req, err := http.NewRequest("POST", groqURL, bytes.NewBuffer(jsonData))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to create request", err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to call Groq API", err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to read response", err.Error())
return
}
if resp.StatusCode != http.StatusOK {
utils.ErrorResponse(ctx, http.StatusInternalServerError,
fmt.Sprintf("Groq API error (status %d)", resp.StatusCode), string(body))
return
}
var groqResp GroqResponse
if err := json.Unmarshal(body, &groqResp); err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to parse response", err.Error())
return
}
// Extract AI response
aiResponse := "Maaf, tidak dapat memproses permintaan Anda."
if len(groqResp.Choices) > 0 {
aiResponse = groqResp.Choices[0].Message.Content
}
response := AIMessage{
Role: "assistant",
Content: aiResponse,
}
utils.SuccessResponse(ctx, http.StatusOK, "AI response generated", gin.H{
"message": response,
"response": aiResponse,
"model": model,
"usage": gin.H{
"prompt_tokens": groqResp.Usage.PromptTokens,
"completion_tokens": groqResp.Usage.CompletionTokens,
"total_tokens": groqResp.Usage.TotalTokens,
},
})
}
func (c *AIController) GetHistory(ctx *gin.Context) {
userID, exists := ctx.Get("user_id")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not authenticated", "")
return
}
// TODO: Implement actual history retrieval from database
history := []AIMessage{}
utils.SuccessResponse(ctx, http.StatusOK, "Chat history retrieved", gin.H{
"user_id": userID,
"history": history,
"count": len(history),
})
}
func (c *AIController) ClearHistory(ctx *gin.Context) {
userID, exists := ctx.Get("user_id")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not authenticated", "")
return
}
// TODO: Implement actual history clearing from database
utils.SuccessResponse(ctx, http.StatusOK, "Chat history cleared", gin.H{
"user_id": userID,
"message": "History successfully deleted",
})
}

View File

@ -0,0 +1,69 @@
// internal/controllers/archive_controller.go
package controllers
import (
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ArchiveController struct {
archiveService *services.ArchiveService
}
func NewArchiveController(db *gorm.DB) *ArchiveController {
return &ArchiveController{
archiveService: services.NewArchiveService(db),
}
}
// GetAllArchives gets all archived items
// GET /api/archives
func (c *ArchiveController) GetAllArchives(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
reason := ctx.Query("reason")
search := ctx.Query("search")
archives, total, err := c.archiveService.GetAllArchives(page, limit, reason, search)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get archives", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Archives retrieved", archives, total, page, limit)
}
// GetArchiveByID gets archive by ID
// GET /api/archives/:id
func (c *ArchiveController) GetArchiveByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid archive ID", err.Error())
return
}
archive, err := c.archiveService.GetArchiveByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Archive not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Archive retrieved", archive.ToResponse())
}
// GetArchiveStats gets archive statistics
// GET /api/archives/stats
func (c *ArchiveController) GetArchiveStats(ctx *gin.Context) {
stats, err := c.archiveService.GetArchiveStats()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Archive stats retrieved", stats)
}

View File

@ -0,0 +1,104 @@
// internal/controllers/auth_controller.go
package controllers
import (
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
type AuthController struct {
authService *services.AuthService
}
func NewAuthController(db *gorm.DB, logger *zap.Logger) *AuthController {
return &AuthController{
authService: services.NewAuthService(db, logger),
}
}
// Register handles user registration
// POST /api/register
func (c *AuthController) Register(ctx *gin.Context) {
var req services.RegisterRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Get IP and User-Agent
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Register user
result, err := c.authService.Register(req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Registration failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Registration successful", result)
}
// Login handles user login
// POST /api/login
func (c *AuthController) Login(ctx *gin.Context) {
var req services.LoginRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Get IP and User-Agent
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Login user
result, err := c.authService.Login(req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Login failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Login successful", result)
}
// RefreshToken handles token refresh
// POST /api/refresh-token
func (c *AuthController) RefreshToken(ctx *gin.Context) {
var req struct {
Token string `json:"token" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Refresh token
newToken, err := c.authService.RefreshToken(req.Token)
if err != nil {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Token refresh failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Token refreshed", gin.H{
"token": newToken,
})
}
// GetMe returns current user info
// GET /api/me
func (c *AuthController) GetMe(ctx *gin.Context) {
user, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User info retrieved", user)
}

View File

@ -0,0 +1,130 @@
// internal/controllers/category_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type CategoryController struct {
categoryService *services.CategoryService
}
func NewCategoryController(db *gorm.DB) *CategoryController {
return &CategoryController{
categoryService: services.NewCategoryService(db),
}
}
// GetAllCategories gets all categories
// GET /api/categories
func (c *CategoryController) GetAllCategories(ctx *gin.Context) {
categories, err := c.categoryService.GetAllCategories()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get categories", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Categories retrieved", categories)
}
// GetCategoryByID gets category by ID
// GET /api/categories/:id
func (c *CategoryController) GetCategoryByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
return
}
category, err := c.categoryService.GetCategoryByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Category not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Category retrieved", category.ToResponse())
}
// CreateCategory creates a new category (admin only)
// POST /api/categories
func (c *CategoryController) CreateCategory(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
var req services.CreateCategoryRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
category, err := c.categoryService.CreateCategory(admin.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create category", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Category created", category.ToResponse())
}
// UpdateCategory updates a category (admin only)
// PUT /api/categories/:id
func (c *CategoryController) UpdateCategory(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
return
}
var req services.UpdateCategoryRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
category, err := c.categoryService.UpdateCategory(admin.ID, uint(categoryID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update category", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Category updated", category.ToResponse())
}
// DeleteCategory deletes a category (admin only)
// DELETE /api/categories/:id
func (c *CategoryController) DeleteCategory(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.categoryService.DeleteCategory(admin.ID, uint(categoryID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete category", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Category deleted", nil)
}

View File

@ -0,0 +1,420 @@
// internal/controllers/claim_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ClaimController struct {
claimService *services.ClaimService
verificationService *services.VerificationService
}
func NewClaimController(db *gorm.DB) *ClaimController {
return &ClaimController{
claimService: services.NewClaimService(db),
verificationService: services.NewVerificationService(db),
}
}
// GetAllClaims gets all claims
// GET /api/claims
func (c *ClaimController) GetAllClaims(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
var itemID, userID *uint
if itemIDStr := ctx.Query("item_id"); itemIDStr != "" {
id, _ := strconv.ParseUint(itemIDStr, 10, 32)
itemID = new(uint)
*itemID = uint(id)
}
// If regular user, only show their claims
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
if user.IsUser() {
userID = &user.ID
}
}
claims, total, err := c.claimService.GetAllClaims(page, limit, status, itemID, userID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
return
}
// Pastikan claims selalu berupa array, bukan null
if claims == nil {
claims = []models.ClaimResponse{}
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
}
// POST /api/user/claims/:id/respond
func (c *ClaimController) UserApproveClaim(ctx *gin.Context) {
userObj, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Unauthorized", "User not found")
return
}
user := userObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req struct {
Action string `json:"action" binding:"required"` // 'approve' or 'reject'
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// Validasi Action
if req.Action != "approve" && req.Action != "reject" {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid action", "Action must be 'approve' or 'reject'")
return
}
// Logic approve/reject khusus user
err = c.claimService.ProcessUserDecision(user.ID, uint(claimID), req.Action)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Gagal memproses keputusan", err.Error())
return
}
message := "Klaim berhasil disetujui"
if req.Action == "reject" {
message = "Klaim berhasil ditolak"
}
utils.SuccessResponse(ctx, http.StatusOK, message, nil)
}
// GetClaimByID gets claim by ID
// GET /api/claims/:id
func (c *ClaimController) GetClaimByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
isManager := false
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
isManager = user.IsManager() || user.IsAdmin()
}
// Jika Manager, paksa hitung similarity dulu sebelum ambil data
if isManager {
_, err := c.verificationService.VerifyClaimDescription(uint(id))
if err != nil {
// Log error tapi jangan stop flow
}
}
claim, err := c.claimService.GetClaimByID(uint(id), isManager)
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Claim not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim retrieved", claim)
}
func (c *ClaimController) UserConfirmCompletion(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
claimID, _ := strconv.ParseUint(ctx.Param("id"), 10, 32)
err := c.claimService.UserConfirmCompletion(user.ID, uint(claimID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Gagal menyelesaikan kasus", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Kasus selesai. Barang telah diterima.", nil)
}
// CreateClaim creates a new claim
// POST /api/claims
func (c *ClaimController) CreateClaim(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
claim, err := c.claimService.CreateClaim(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Claim created", claim.ToResponse())
}
// VerifyClaim verifies a claim (manager only)
// POST /api/claims/:id/verify
func (c *ClaimController) VerifyClaim(ctx *gin.Context) {
managerObj, _ := ctx.Get("user")
manager := managerObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req services.VerifyClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// Auto-verify description similarity
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// Verify the claim
if err := c.claimService.VerifyClaim(
manager.ID,
uint(claimID),
req,
verification.SimilarityScore,
stringSliceToString(verification.MatchedKeywords),
ipAddress,
userAgent,
); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to verify claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim verified", gin.H{
"verification": verification,
})
}
// GetClaimVerification gets verification data for a claim
// GET /api/claims/:id/verification
func (c *ClaimController) GetClaimVerification(ctx *gin.Context) {
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Verification retrieved", verification)
}
// CancelClaimApproval handles cancelling an approved claim
// POST /api/claims/:id/cancel-approval
// ✅ RENAMED: CancelApproval -> CancelClaimApproval (Matches routes.go)
func (c *ClaimController) CancelClaimApproval(ctx *gin.Context) {
managerObj, _ := ctx.Get("user")
manager := managerObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
if err := c.claimService.CancelClaimApproval(manager.ID, uint(claimID)); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to cancel approval", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Approval cancelled successfully", nil)
}
// CloseCase handles case closure (Manager only)
// POST /api/claims/:id/close
// ✅ RENAMED: CloseClaim -> CloseCase (Matches routes.go)
func (c *ClaimController) CloseCase(ctx *gin.Context) {
// 1. Ambil User (Manager) dari context secara konsisten
managerObj, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Unauthorized", "User not found")
return
}
manager := managerObj.(*models.User)
// 2. Ambil ID Klaim dari URL
idStr := ctx.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid ID format", err.Error())
return
}
// 3. Parse Body Request
var req services.CloseCaseRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// 4. Ambil Info Audit
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// 5. Panggil Service
if err := c.claimService.CloseCase(manager.ID, uint(id), req, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to close case", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Case closed successfully", nil)
}
// ReopenCase handles reopening a closed case
// POST /api/claims/:id/reopen
// ✅ RENAMED: ReopenClaim -> ReopenCase (Matches routes.go)
func (c *ClaimController) ReopenCase(ctx *gin.Context) {
managerObj, _ := ctx.Get("user")
manager := managerObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req services.ReopenCaseRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.claimService.ReopenCase(manager.ID, uint(claimID), req, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to reopen case", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Case reopened successfully", nil)
}
// GetClaimsByUser gets claims by user
// GET /api/user/claims
func (c *ClaimController) GetClaimsByUser(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
claims, total, err := c.claimService.GetClaimsByUser(user.ID, page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
}
// UpdateClaim updates a claim
// PUT /api/claims/:id
func (c *ClaimController) UpdateClaim(ctx *gin.Context) {
// Bisa Admin atau User pemilik klaim (dicek di middleware/service)
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
var req services.UpdateClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
claim, err := c.claimService.UpdateClaim(user.ID, uint(claimID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim updated successfully", claim)
}
// DeleteClaim deletes a claim
// DELETE /api/claims/:id
func (c *ClaimController) DeleteClaim(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.claimService.DeleteClaim(user.ID, uint(claimID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Claim deleted", nil)
}
// Helper function to convert string slice to string
func stringSliceToString(slice []string) string {
if len(slice) == 0 {
return ""
}
result := ""
for i, s := range slice {
if i > 0 {
result += ", "
}
result += s
}
return result
}

View File

@ -0,0 +1,296 @@
// internal/controllers/item_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/repositories" // ✅ TAMBAHKAN INI
"lost-and-found/internal/utils"
"net/http"
"strconv"
"log"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ItemController struct {
itemService *services.ItemService
matchService *services.MatchService
itemRepo *repositories.ItemRepository // ✅ TAMBAHKAN INI
}
func NewItemController(db *gorm.DB) *ItemController {
return &ItemController{
itemService: services.NewItemService(db),
matchService: services.NewMatchService(db),
itemRepo: repositories.NewItemRepository(db), // ✅ INITIALIZE INI
}
}
// ✅ FIXED GetItemByID
// ✅ PASTIKAN response detail lengkap untuk manager
// ✅ FIXED GetItemByIDfunc (c *ItemController) GetItemByID(ctx *gin.Context) {
// ✅ FIXED GetItemByID - NOW RETURNS FULL DETAILS FOR MANAGER
func (c *ItemController) GetItemByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
// ✅ Check if user is manager or admin
isManager := false
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
isManager = user.IsManager() || user.IsAdmin()
}
// ✅ Get item DIRECTLY from repository with PRELOAD
item, err := c.itemRepo.FindByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Item not found", err.Error())
return
}
// ✅ LOG untuk debug
log.Printf("🔍 Controller GetItemByID - Item ID: %d", item.ID)
log.Printf(" 📝 Description: %s", item.Description)
log.Printf(" 🔒 SecretDetails: %s", item.SecretDetails)
log.Printf(" 👤 ReporterName: %s", item.ReporterName)
log.Printf(" 📞 ReporterContact: %s", item.ReporterContact)
// ✅ Return response based on role
if isManager {
// Manager/Admin gets FULL details
detailResponse := item.ToDetailResponse()
// ✅ LOG response yang akan dikirim
log.Printf("📤 Sending DetailResponse to Manager:")
log.Printf(" Description: %s", detailResponse.Description)
log.Printf(" SecretDetails: %s", detailResponse.SecretDetails)
log.Printf(" ReporterName: %s", detailResponse.ReporterName)
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", detailResponse)
} else {
// Regular user gets public view only
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", item.ToPublicResponse())
}
}
func (c *ItemController) ReportFoundItemLinked(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateFoundItemLinkedRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Data tidak valid", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
item, err := c.itemService.CreateFoundItemLinked(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Gagal membuat laporan", err.Error())
return
}
message := "Laporan berhasil dibuat. Menunggu verifikasi Manager."
if req.IsDirectToOwner {
message = "Laporan berhasil! Notifikasi langsung dikirim ke pemilik barang."
}
utils.SuccessResponse(ctx, http.StatusCreated, message, item)
}
// CreateItem creates a new item
// POST /api/items
func (c *ItemController) CreateItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
item, err := c.itemService.CreateItem(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create item", err.Error())
return
}
// Auto-match with lost items
go c.matchService.AutoMatchNewItem(item.ID)
utils.SuccessResponse(ctx, http.StatusCreated, "Item created", item.ToDetailResponse())
}
// UpdateItem updates an item
// PUT /api/items/:id
func (c *ItemController) UpdateItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
var req services.UpdateItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
item, err := c.itemService.UpdateItem(user.ID, uint(itemID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update item", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Item updated", item.ToDetailResponse())
}
// UpdateItemStatus updates item status
// PATCH /api/items/:id/status
func (c *ItemController) UpdateItemStatus(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.itemService.UpdateItemStatus(user.ID, uint(itemID), req.Status, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Item status updated", nil)
}
// DeleteItem deletes an item
// DELETE /api/items/:id
func (c *ItemController) DeleteItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.itemService.DeleteItem(user.ID, uint(itemID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete item", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Item deleted", nil)
}
// GetItemsByReporter gets items by reporter
// GET /api/user/items
func (c *ItemController) GetItemsByReporter(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
items, total, err := c.itemService.GetItemsByReporter(user.ID, page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
return
}
var responses []models.ItemDetailResponse
for _, item := range items {
responses = append(responses, item.ToDetailResponse())
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", responses, total, page, limit)
}
// GetItemRevisionHistory gets revision history for an item
// GET /api/items/:id/revisions
func (c *ItemController) GetItemRevisionHistory(ctx *gin.Context) {
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
revisions, total, err := c.itemService.GetItemRevisionHistory(uint(itemID), page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get revision history", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Revision history retrieved", revisions, total, page, limit)
}
func (c *ItemController) GetAllItems(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
category := ctx.Query("category")
search := ctx.Query("search")
// ✅ CHECK IF USER IS MANAGER/ADMIN
isManager := false
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
isManager = user.IsManager() || user.IsAdmin()
}
// ✅ FIXED: FORCE FILTER OUT EXPIRED for public users
if !isManager {
// 1. Jika user mencoba meminta status terlarang, paksa filter aman
if status == models.ItemStatusExpired || status == models.ItemStatusCaseClosed {
status = "!expired"
}
// 2. Jika status kosong (default), set ke !expired
if status == "" {
status = "!expired"
}
}
items, total, err := c.itemService.GetAllItems(page, limit, status, category, search)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", items, total, page, limit)
}

View File

@ -0,0 +1,230 @@
// internal/controllers/lost_item_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type LostItemController struct {
lostItemService *services.LostItemService
}
func NewLostItemController(db *gorm.DB) *LostItemController {
return &LostItemController{
lostItemService: services.NewLostItemService(db),
}
}
// GetAllLostItems gets all lost items
// GET /api/lost-items
func (c *LostItemController) GetAllLostItems(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
status := ctx.Query("status")
category := ctx.Query("category")
search := ctx.Query("search")
scope := ctx.Query("scope") // Tambahkan ini
var userID *uint
// Logic Baru: Defaultnya publik (userID = nil), kecuali minta 'mine'
if userObj, exists := ctx.Get("user"); exists {
user := userObj.(*models.User)
// Hanya filter ID jika user meminta scope="mine"
if scope == "mine" {
userID = &user.ID
}
}
// Panggil service (userID akan nil jika melihat publik, terisi jika scope=mine)
lostItems, total, err := c.lostItemService.GetAllLostItems(page, limit, status, category, search, userID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
}
// GetLostItemByID gets lost item by ID
// GET /api/lost-items/:id
func (c *LostItemController) GetLostItemByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
lostItem, err := c.lostItemService.GetLostItemByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Lost item not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item retrieved", lostItem.ToResponse())
}
// CreateLostItem creates a new lost item report
// POST /api/lost-items
func (c *LostItemController) CreateLostItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.CreateLostItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
lostItem, err := c.lostItemService.CreateLostItem(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create lost item report", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Lost item report created", lostItem.ToResponse())
}
// UpdateLostItem updates a lost item report
// PUT /api/lost-items/:id
func (c *LostItemController) UpdateLostItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
var req services.UpdateLostItemRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
lostItem, err := c.lostItemService.UpdateLostItem(user.ID, uint(lostItemID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update lost item report", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report updated", lostItem.ToResponse())
}
// UpdateLostItemStatus updates lost item status
// PATCH /api/lost-items/:id/status
func (c *LostItemController) UpdateLostItemStatus(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.lostItemService.UpdateLostItemStatus(user.ID, uint(lostItemID), req.Status, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item status updated", nil)
}
// DeleteLostItem deletes a lost item report
// DELETE /api/lost-items/:id
func (c *LostItemController) DeleteLostItem(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.lostItemService.DeleteLostItem(user.ID, uint(lostItemID), ipAddress, userAgent);
err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete lost item report", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report deleted", nil)
}
// GetLostItemsByUser gets lost items by user
// GET /api/user/lost-items
func (c *LostItemController) GetLostItemsByUser(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
lostItems, total, err := c.lostItemService.GetLostItemsByUser(user.ID, page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
return
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
}
func (c *LostItemController) DirectClaimToOwner(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
var req services.CreateLostItemClaimRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
// ✅ TAMBAHAN BARU: Ambil IP dan User Agent untuk Audit Log
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
// ✅ UPDATE: Kirim ipAddress dan userAgent ke Service
claim, err := c.lostItemService.DirectClaimToOwner(user.ID, uint(lostItemID), req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to submit direct claim", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Klaim terkirim ke pemilik untuk persetujuan", claim.ToResponse())
}

View File

@ -0,0 +1,40 @@
// internal/controllers/manager_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ManagerController struct {
itemRepo *repositories.ItemRepository
claimRepo *repositories.ClaimRepository
}
func NewManagerController(db *gorm.DB) *ManagerController {
return &ManagerController{
itemRepo: repositories.NewItemRepository(db),
claimRepo: repositories.NewClaimRepository(db),
}
}
func (c *ManagerController) GetDashboardStats(ctx *gin.Context) {
totalItems, _ := c.itemRepo.CountAll()
pendingClaims, _ := c.claimRepo.CountByStatus(models.ClaimStatusPending)
verifiedItems, _ := c.itemRepo.CountByStatus(models.ItemStatusVerified)
expiredItems, _ := c.itemRepo.CountByStatus(models.ItemStatusExpired)
stats := map[string]interface{}{
"total_items": totalItems,
"pending_claims": pendingClaims,
"verified": verifiedItems,
"expired": expiredItems,
}
utils.SuccessResponse(ctx, http.StatusOK, "Manager dashboard stats", stats)
}

View File

@ -0,0 +1,87 @@
// internal/controllers/match_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type MatchController struct {
matchService *services.MatchService
}
func NewMatchController(db *gorm.DB) *MatchController {
return &MatchController{
matchService: services.NewMatchService(db),
}
}
// FindSimilarItems finds similar items for a lost item
// POST /api/lost-items/:id/find-similar
func (c *MatchController) FindSimilarItems(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
// Only allow managers or the owner to search
// Add ownership check here if needed
results, err := c.matchService.FindSimilarItems(uint(lostItemID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to find similar items", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Similar items found", gin.H{
"total": len(results),
"matches": results,
"user_id": user.ID,
})
}
// GetMatchesForLostItem gets all matches for a lost item
// GET /api/lost-items/:id/matches
func (c *MatchController) GetMatchesForLostItem(ctx *gin.Context) {
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
return
}
matches, err := c.matchService.GetMatchesForLostItem(uint(lostItemID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
}
// GetMatchesForItem gets all matches for an item
// GET /api/items/:id/matches
func (c *MatchController) GetMatchesForItem(ctx *gin.Context) {
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
return
}
matches, err := c.matchService.GetMatchesForItem(uint(itemID))
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
}

View File

@ -0,0 +1,82 @@
// internal/controllers/notification_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type NotificationController struct {
notificationService *services.NotificationService
}
func NewNotificationController(db *gorm.DB) *NotificationController {
return &NotificationController{
notificationService: services.NewNotificationService(db),
}
}
// GetUserNotifications gets notifications for current user
// GET /api/notifications
func (c *NotificationController) GetUserNotifications(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
onlyUnread := ctx.Query("unread") == "true"
notifications, total, err := c.notificationService.GetUserNotifications(user.ID, page, limit, onlyUnread)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get notifications", err.Error())
return
}
count, _ := c.notificationService.CountUnread(user.ID)
utils.SuccessResponse(ctx, http.StatusOK, "Notifications retrieved", gin.H{
"notifications": notifications,
"total": total,
"unread_count": count,
})
}
// MarkAsRead marks a notification as read
// PATCH /api/notifications/:id/read
func (c *NotificationController) MarkAsRead(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid ID", err.Error())
return
}
if err := c.notificationService.MarkAsRead(user.ID, uint(id)); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to mark as read", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Notification marked as read", nil)
}
// MarkAllAsRead marks all notifications as read
// PATCH /api/notifications/read-all
func (c *NotificationController) MarkAllAsRead(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
if err := c.notificationService.MarkAllAsRead(user.ID); err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to mark all as read", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "All notifications marked as read", nil)
}

View File

@ -0,0 +1,110 @@
// internal/controllers/report_controller.go
package controllers
import (
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ReportController struct {
exportService *services.ExportService
}
func NewReportController(db *gorm.DB) *ReportController {
return &ReportController{
exportService: services.NewExportService(db),
}
}
// ExportReport exports report based on request
// POST /api/reports/export
func (c *ReportController) ExportReport(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.ExportRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
var buffer *[]byte
var filename string
var contentType string
var err error
// Generate report based on type and format
switch req.Type {
case "items":
if req.Format == "pdf" {
buf, e := c.exportService.ExportItemsToPDF(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("items_report_%s.pdf", time.Now().Format("20060102"))
contentType = "application/pdf"
}
} else {
buf, e := c.exportService.ExportItemsToExcel(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("items_report_%s.xlsx", time.Now().Format("20060102"))
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
}
}
case "archives":
buf, e := c.exportService.ExportArchivesToPDF(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("archives_report_%s.pdf", time.Now().Format("20060102"))
contentType = "application/pdf"
}
case "claims":
buf, e := c.exportService.ExportClaimsToPDF(req, user.ID, ipAddress, userAgent)
if e != nil {
err = e
} else {
data := buf.Bytes()
buffer = &data
filename = fmt.Sprintf("claims_report_%s.pdf", time.Now().Format("20060102"))
contentType = "application/pdf"
}
default:
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid report type", "")
return
}
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to generate report", err.Error())
return
}
// Set headers
ctx.Header("Content-Type", contentType)
ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
ctx.Header("Content-Length", fmt.Sprintf("%d", len(*buffer)))
// Send file
ctx.Data(http.StatusOK, contentType, *buffer)
}

View File

@ -0,0 +1,105 @@
package controllers
import (
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type RoleController struct {
roleService *services.RoleService
}
// NewRoleController initializes the role controller
func NewRoleController(db *gorm.DB) *RoleController {
return &RoleController{
roleService: services.NewRoleService(db),
}
}
// GetRoles gets all roles
// GET /api/admin/roles
func (c *RoleController) GetRoles(ctx *gin.Context) {
roles, err := c.roleService.GetAllRoles()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get roles", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Roles retrieved", roles)
}
// GetPermissions gets all permissions
// GET /api/admin/permissions
func (c *RoleController) GetPermissions(ctx *gin.Context) {
permissions, err := c.roleService.GetAllPermissions()
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get permissions", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Permissions retrieved", permissions)
}
// CreateRole creates a new role
// POST /api/admin/roles
func (c *RoleController) CreateRole(ctx *gin.Context) {
var req services.CreateRoleRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
role, err := c.roleService.CreateRole(req)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusCreated, "Role created", role)
}
// UpdateRole updates a role
// PUT /api/admin/roles/:id
func (c *RoleController) UpdateRole(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid role ID", err.Error())
return
}
var req services.UpdateRoleRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
role, err := c.roleService.UpdateRole(uint(id), req)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Role updated", role)
}
// DeleteRole deletes a role
// DELETE /api/admin/roles/:id
func (c *RoleController) DeleteRole(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid role ID", err.Error())
return
}
if err := c.roleService.DeleteRole(uint(id)); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Role deleted", nil)
}

View File

@ -0,0 +1,284 @@
// internal/controllers/upload_controller.go
package controllers
import (
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/utils"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ✅ KRITERIA BACKEND: File Handling (5 Poin)
// - Validasi MIME type
// - Validasi Max size
// - Multiple file support
// - Secure filename handling
type UploadController struct {
imageHandler *utils.ImageHandler
db *gorm.DB
}
func NewUploadController(db *gorm.DB) *UploadController {
return &UploadController{
imageHandler: utils.NewImageHandler("./uploads"),
db: db,
}
}
// UploadItemImage uploads image untuk item
// POST /api/upload/item-image
func (c *UploadController) UploadItemImage(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
// ✅ VALIDASI: Get file from request
file, err := ctx.FormFile("image")
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "No file uploaded", err.Error())
return
}
// ✅ VALIDASI: Check MIME type (dilakukan di ImageHandler)
// ✅ VALIDASI: Check file size (dilakukan di ImageHandler)
// Upload dengan processing (resize, optimize)
relativePath, err := c.imageHandler.UploadImage(file, "items")
if err != nil {
// Error dari ImageHandler sudah mencakup validasi MIME type & size
utils.ErrorResponse(ctx, http.StatusBadRequest, "Upload failed", err.Error())
return
}
// Generate URL
imageURL := fmt.Sprintf("/uploads/%s", relativePath)
// Log audit
c.logAudit(user.ID, "upload", "item_image", nil,
fmt.Sprintf("Image uploaded: %s", file.Filename),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Image uploaded successfully", gin.H{
"url": imageURL,
"filename": filepath.Base(relativePath),
"size": file.Size,
})
}
// UploadClaimProof uploads bukti untuk claim
// POST /api/upload/claim-proof
func (c *UploadController) UploadClaimProof(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
file, err := ctx.FormFile("proof")
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "No file uploaded", err.Error())
return
}
// ✅ VALIDASI TAMBAHAN: Check file extension
ext := strings.ToLower(filepath.Ext(file.Filename))
allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}
isAllowed := false
for _, allowedExt := range allowedExts {
if ext == allowedExt {
isAllowed = true
break
}
}
if !isAllowed {
utils.ErrorResponse(ctx, http.StatusBadRequest,
"Invalid file extension",
fmt.Sprintf("Allowed: %v", allowedExts))
return
}
// Upload simple (tanpa resize untuk PDF)
var relativePath string
if ext == ".pdf" {
relativePath, err = c.imageHandler.UploadImageSimple(file, "proofs")
} else {
relativePath, err = c.imageHandler.UploadImage(file, "proofs")
}
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Upload failed", err.Error())
return
}
proofURL := fmt.Sprintf("/uploads/%s", relativePath)
c.logAudit(user.ID, "upload", "claim_proof", nil,
fmt.Sprintf("Proof uploaded: %s", file.Filename),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Proof uploaded successfully", gin.H{
"url": proofURL,
"filename": filepath.Base(relativePath),
"size": file.Size,
"type": ext,
})
}
// UploadMultipleImages uploads multiple images sekaligus
// POST /api/upload/multiple
func (c *UploadController) UploadMultipleImages(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
// ✅ VALIDASI: Parse multipart form
form, err := ctx.MultipartForm()
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid form data", err.Error())
return
}
files := form.File["images"]
if len(files) == 0 {
utils.ErrorResponse(ctx, http.StatusBadRequest, "No files uploaded", "")
return
}
// ✅ VALIDASI: Max 5 files per request
const maxFiles = 5
if len(files) > maxFiles {
utils.ErrorResponse(ctx, http.StatusBadRequest,
fmt.Sprintf("Too many files. Max %d files allowed", maxFiles), "")
return
}
var uploadedFiles []gin.H
var failedFiles []gin.H
for _, file := range files {
// Upload each file
relativePath, err := c.imageHandler.UploadImage(file, "items")
if err != nil {
failedFiles = append(failedFiles, gin.H{
"filename": file.Filename,
"error": err.Error(),
})
continue
}
uploadedFiles = append(uploadedFiles, gin.H{
"filename": filepath.Base(relativePath),
"url": fmt.Sprintf("/uploads/%s", relativePath),
"size": file.Size,
})
}
c.logAudit(user.ID, "upload", "multiple_images", nil,
fmt.Sprintf("Uploaded %d files, %d failed", len(uploadedFiles), len(failedFiles)),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Upload completed", gin.H{
"uploaded": uploadedFiles,
"failed": failedFiles,
"total": len(files),
"success": len(uploadedFiles),
})
}
// DeleteImage deletes uploaded image
// DELETE /api/upload/delete
func (c *UploadController) DeleteImage(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req struct {
ImageURL string `json:"image_url" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request", err.Error())
return
}
// Extract relative path from URL
relativePath := strings.TrimPrefix(req.ImageURL, "/uploads/")
// ✅ SECURITY: Validate path (prevent directory traversal)
if strings.Contains(relativePath, "..") {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid path", "Directory traversal detected")
return
}
// Delete file
if err := c.imageHandler.DeleteImage(relativePath); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Delete failed", err.Error())
return
}
c.logAudit(user.ID, "delete", "image", nil,
fmt.Sprintf("Image deleted: %s", relativePath),
ctx.ClientIP(), ctx.Request.UserAgent())
utils.SuccessResponse(ctx, http.StatusOK, "Image deleted successfully", nil)
}
// GetImageInfo gets metadata tentang uploaded image
// GET /api/upload/info?url=/uploads/items/image.jpg
func (c *UploadController) GetImageInfo(ctx *gin.Context) {
imageURL := ctx.Query("url")
if imageURL == "" {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Image URL required", "")
return
}
relativePath := strings.TrimPrefix(imageURL, "/uploads/")
// Security check
if strings.Contains(relativePath, "..") {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid path", "")
return
}
fullPath := filepath.Join("./uploads", relativePath)
// Check if file exists
fileInfo, err := filepath.Abs(fullPath)
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "File not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Image info retrieved", gin.H{
"path": fileInfo,
"url": imageURL,
"filename": filepath.Base(relativePath),
"folder": filepath.Dir(relativePath),
})
}
// logAudit helper untuk logging
func (c *UploadController) logAudit(userID uint, action, entityType string, entityID *uint, details, ip, userAgent string) {
auditLog := &models.AuditLog{
UserID: &userID,
Action: action,
EntityType: entityType,
EntityID: entityID,
Details: details,
IPAddress: ip,
UserAgent: userAgent,
}
c.db.Create(auditLog)
}
// ✅ VALIDASI INFO
// Validasi yang diterapkan:
// 1. MIME type check (di ImageHandler.isAllowedType)
// 2. File size check (di ImageHandler dengan maxSize = 10MB)
// 3. File extension check (allowedExts)
// 4. Max files per request check (5 files)
// 5. Directory traversal prevention (.. check)
// 6. Image resize untuk optimize storage (di ImageHandler)

View File

@ -0,0 +1,238 @@
// internal/controllers/user_controller.go
package controllers
import (
"lost-and-found/internal/models"
"lost-and-found/internal/services"
"lost-and-found/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type UserController struct {
userService *services.UserService
}
func NewUserController(db *gorm.DB) *UserController {
return &UserController{
userService: services.NewUserService(db),
}
}
// GetProfile gets user profile
// GET /api/user/profile
func (c *UserController) GetProfile(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
profile, err := c.userService.GetProfile(user.ID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "Profile not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Profile retrieved", profile.ToResponse())
}
// UpdateProfile updates user profile
// PUT /api/user/profile
func (c *UserController) UpdateProfile(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.UpdateProfileRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
updatedUser, err := c.userService.UpdateProfile(user.ID, req, ipAddress, userAgent)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Update failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Profile updated", updatedUser.ToResponse())
}
// ChangePassword changes user password
// POST /api/user/change-password
func (c *UserController) ChangePassword(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
var req services.ChangePasswordRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.ChangePassword(user.ID, req, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Password change failed", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Password changed successfully", nil)
}
// GetStats gets user statistics
// GET /api/user/stats
func (c *UserController) GetStats(ctx *gin.Context) {
userObj, _ := ctx.Get("user")
user := userObj.(*models.User)
stats, err := c.userService.GetUserStats(user.ID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "Stats retrieved", stats)
}
// GetAllUsers gets all users (admin only)
// GET /api/admin/users
func (c *UserController) GetAllUsers(ctx *gin.Context) {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
users, total, err := c.userService.GetAllUsers(page, limit)
if err != nil {
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get users", err.Error())
return
}
var responses []models.UserResponse
for _, user := range users {
responses = append(responses, user.ToResponse())
}
utils.SendPaginatedResponse(ctx, http.StatusOK, "Users retrieved", responses, total, page, limit)
}
// GetUserByID gets user by ID (admin only)
// GET /api/admin/users/:id
func (c *UserController) GetUserByID(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
user, err := c.userService.GetUserByID(uint(id))
if err != nil {
utils.ErrorResponse(ctx, http.StatusNotFound, "User not found", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User retrieved", user.ToResponse())
}
// UpdateUserRole updates user role (admin only)
// PATCH /api/admin/users/:id/role
func (c *UserController) UpdateUserRole(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
var req struct {
RoleID uint `json:"role_id" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.UpdateUserRole(admin.ID, uint(userID), req.RoleID, ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User role updated", nil)
}
// BlockUser blocks a user (admin only)
// POST /api/admin/users/:id/block
func (c *UserController) BlockUser(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.BlockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to block user", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User blocked", nil)
}
// UnblockUser unblocks a user (admin only)
// POST /api/admin/users/:id/unblock
func (c *UserController) UnblockUser(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.UnblockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to unblock user", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User unblocked", nil)
}
// DeleteUser deletes a user (admin only)
// DELETE /api/admin/users/:id
func (c *UserController) DeleteUser(ctx *gin.Context) {
adminObj, _ := ctx.Get("user")
admin := adminObj.(*models.User)
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
return
}
ipAddress := ctx.ClientIP()
userAgent := ctx.Request.UserAgent()
if err := c.userService.DeleteUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete user", err.Error())
return
}
utils.SuccessResponse(ctx, http.StatusOK, "User deleted", nil)
}

View File

@ -0,0 +1,23 @@
// internal/middleware/cors.go
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORSMiddleware handles CORS
func CORSMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
if ctx.Request.Method == "OPTIONS" {
ctx.AbortWithStatus(204)
return
}
ctx.Next()
}
}

View File

@ -0,0 +1,169 @@
// internal/middleware/idempotency.go
package middleware
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"lost-and-found/internal/utils"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// ✅ KRITERIA BACKEND: Idempotency (5 Poin) - Advanced Feature
// IdempotencyStore menyimpan hasil request yang sudah diproses
type IdempotencyStore struct {
mu sync.RWMutex
results map[string]*IdempotencyResult
}
// IdempotencyResult menyimpan response dari request sebelumnya
type IdempotencyResult struct {
StatusCode int
Body interface{}
Timestamp time.Time
}
var idempotencyStore = &IdempotencyStore{
results: make(map[string]*IdempotencyResult),
}
// cleanupIdempotencyStore membersihkan hasil yang sudah lama (> 24 jam)
func cleanupIdempotencyStore() {
ticker := time.NewTicker(1 * time.Hour)
go func() {
for range ticker.C {
idempotencyStore.mu.Lock()
now := time.Now()
for key, result := range idempotencyStore.results {
if now.Sub(result.Timestamp) > 24*time.Hour {
delete(idempotencyStore.results, key)
}
}
idempotencyStore.mu.Unlock()
}
}()
}
func init() {
cleanupIdempotencyStore()
}
// IdempotencyMiddleware mencegah double-submit pada endpoint kritis
// Endpoint kritis: Payment, Create Order, Transfer, Submit Claim
func IdempotencyMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
// Hanya apply untuk POST/PUT/PATCH methods
if ctx.Request.Method != http.MethodPost &&
ctx.Request.Method != http.MethodPut &&
ctx.Request.Method != http.MethodPatch {
ctx.Next()
return
}
// Get idempotency key from header
idempotencyKey := ctx.GetHeader("Idempotency-Key")
// Jika tidak ada idempotency key, skip (tidak wajib untuk semua request)
if idempotencyKey == "" {
ctx.Next()
return
}
// Generate unique key: idempotency-key + user-id + path + method
userID, _ := ctx.Get("user_id")
uniqueKey := generateUniqueKey(idempotencyKey, fmt.Sprintf("%v", userID), ctx.Request.URL.Path, ctx.Request.Method)
// Check if request sudah pernah diproses
idempotencyStore.mu.RLock()
result, exists := idempotencyStore.results[uniqueKey]
idempotencyStore.mu.RUnlock()
if exists {
// Request sudah pernah diproses, return hasil sebelumnya
ctx.JSON(result.StatusCode, gin.H{
"success": true,
"message": "Request already processed (idempotent)",
"data": result.Body,
"idempotent": true,
"original_at": result.Timestamp,
})
ctx.Abort()
return
}
// Mark request sebagai "processing" untuk prevent concurrent duplicate
processingLock := fmt.Sprintf("%s-processing", uniqueKey)
idempotencyStore.mu.Lock()
if _, processing := idempotencyStore.results[processingLock]; processing {
idempotencyStore.mu.Unlock()
utils.ErrorResponse(ctx, http.StatusConflict, "Request is being processed", "Duplicate request detected")
ctx.Abort()
return
}
// Lock dengan timestamp sebagai marker
idempotencyStore.results[processingLock] = &IdempotencyResult{
Timestamp: time.Now(),
}
idempotencyStore.mu.Unlock()
// Custom response writer untuk capture hasil
blw := &bodyLogWriter{body: []byte{}, ResponseWriter: ctx.Writer}
ctx.Writer = blw
// Process request
ctx.Next()
// Remove processing lock
idempotencyStore.mu.Lock()
delete(idempotencyStore.results, processingLock)
idempotencyStore.mu.Unlock()
// Simpan hasil hanya jika sukses (2xx status)
if ctx.Writer.Status() >= 200 && ctx.Writer.Status() < 300 {
idempotencyStore.mu.Lock()
idempotencyStore.results[uniqueKey] = &IdempotencyResult{
StatusCode: ctx.Writer.Status(),
Body: blw.body,
Timestamp: time.Now(),
}
idempotencyStore.mu.Unlock()
}
}
}
// bodyLogWriter untuk capture response body
type bodyLogWriter struct {
gin.ResponseWriter
body []byte
}
func (w *bodyLogWriter) Write(b []byte) (int, error) {
w.body = append(w.body, b...)
return w.ResponseWriter.Write(b)
}
// generateUniqueKey membuat unique key dari kombinasi parameter
func generateUniqueKey(idempotencyKey, userID, path, method string) string {
data := fmt.Sprintf("%s:%s:%s:%s", idempotencyKey, userID, path, method)
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
// ClearIdempotencyCache membersihkan cache (untuk testing/admin)
func ClearIdempotencyCache() {
idempotencyStore.mu.Lock()
defer idempotencyStore.mu.Unlock()
idempotencyStore.results = make(map[string]*IdempotencyResult)
}
// GetIdempotencyCacheSize return ukuran cache (untuk monitoring)
func GetIdempotencyCacheSize() int {
idempotencyStore.mu.RLock()
defer idempotencyStore.mu.RUnlock()
return len(idempotencyStore.results)
}

View File

@ -0,0 +1,106 @@
// internal/middleware/jwt_middleware.go
package middleware
import (
"lost-and-found/internal/config"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// JWTMiddleware validates JWT token
func JWTMiddleware(db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
// Get token from Authorization header
authHeader := ctx.GetHeader("Authorization")
if authHeader == "" {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authorization header required", "")
ctx.Abort()
return
}
// Check if Bearer token
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid authorization format", "")
ctx.Abort()
return
}
tokenString := parts[1]
// Validate token
claims, err := config.ValidateToken(tokenString)
if err != nil {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid or expired token", err.Error())
ctx.Abort()
return
}
// Get user from database
userRepo := repositories.NewUserRepository(db)
user, err := userRepo.FindByID(claims.UserID)
if err != nil {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
ctx.Abort()
return
}
// Check if user is blocked
if user.IsBlocked() {
utils.ErrorResponse(ctx, http.StatusForbidden, "Account is blocked", "")
ctx.Abort()
return
}
// Set user in context
ctx.Set("user", user)
ctx.Set("user_id", user.ID)
ctx.Set("user_role", user.Role.Name)
ctx.Next()
}
}
// OptionalJWTMiddleware validates JWT token if present (for public routes that can benefit from auth)
func OptionalJWTMiddleware(db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
authHeader := ctx.GetHeader("Authorization")
if authHeader == "" {
ctx.Next()
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
ctx.Next()
return
}
tokenString := parts[1]
claims, err := config.ValidateToken(tokenString)
if err != nil {
ctx.Next()
return
}
userRepo := repositories.NewUserRepository(db)
user, err := userRepo.FindByID(claims.UserID)
if err != nil {
ctx.Next()
return
}
if !user.IsBlocked() {
ctx.Set("user", user)
ctx.Set("user_id", user.ID)
ctx.Set("user_role", user.Role.Name)
}
ctx.Next()
}
}

View File

@ -0,0 +1,46 @@
// internal/middleware/logger.go
package middleware
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
)
// LoggerMiddleware logs HTTP requests
func LoggerMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
// Start timer
startTime := time.Now()
// Process request
ctx.Next()
// Calculate latency
latency := time.Since(startTime)
// Get request info
method := ctx.Request.Method
path := ctx.Request.URL.Path
statusCode := ctx.Writer.Status()
clientIP := ctx.ClientIP()
// Get user ID if authenticated
userID := "guest"
if id, exists := ctx.Get("user_id"); exists {
userID = fmt.Sprintf("%v", id)
}
// Log format
fmt.Printf("[%s] %s | %3d | %13v | %15s | %s | User: %s\n",
time.Now().Format("2006-01-02 15:04:05"),
method,
statusCode,
latency,
clientIP,
path,
userID,
)
}
}

View File

@ -0,0 +1,113 @@
// internal/middleware/rate_limiter.go
package middleware
import (
"lost-and-found/internal/utils"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// RateLimiter stores rate limit data
type RateLimiter struct {
visitors map[string]*Visitor
mu sync.RWMutex
rate int // requests per window
window time.Duration // time window
}
// Visitor represents a visitor's rate limit data
type Visitor struct {
lastSeen time.Time
count int
}
var limiter *RateLimiter
// InitRateLimiter initializes the rate limiter
func InitRateLimiter(rate int, window time.Duration) {
limiter = &RateLimiter{
visitors: make(map[string]*Visitor),
rate: rate,
window: window,
}
// Cleanup old visitors every minute
go limiter.cleanupVisitors()
}
// cleanupVisitors removes old visitor entries
func (rl *RateLimiter) cleanupVisitors() {
for {
time.Sleep(time.Minute)
rl.mu.Lock()
for ip, visitor := range rl.visitors {
if time.Since(visitor.lastSeen) > rl.window {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
// getVisitor retrieves or creates a visitor
func (rl *RateLimiter) getVisitor(ip string) *Visitor {
rl.mu.Lock()
defer rl.mu.Unlock()
visitor, exists := rl.visitors[ip]
if !exists {
visitor = &Visitor{
lastSeen: time.Now(),
count: 0,
}
rl.visitors[ip] = visitor
}
return visitor
}
// isAllowed checks if request is allowed
func (rl *RateLimiter) isAllowed(ip string) bool {
visitor := rl.getVisitor(ip)
rl.mu.Lock()
defer rl.mu.Unlock()
// Reset count if window has passed
if time.Since(visitor.lastSeen) > rl.window {
visitor.count = 0
visitor.lastSeen = time.Now()
}
// Check if limit exceeded
if visitor.count >= rl.rate {
return false
}
visitor.count++
visitor.lastSeen = time.Now()
return true
}
// RateLimiterMiddleware applies rate limiting
func RateLimiterMiddleware() gin.HandlerFunc {
// Initialize rate limiter (1000 requests per minute)
if limiter == nil {
InitRateLimiter(1000, time.Minute)
}
return func(ctx *gin.Context) {
ip := ctx.ClientIP()
if !limiter.isAllowed(ip) {
utils.ErrorResponse(ctx, http.StatusTooManyRequests, "Rate limit exceeded", "Too many requests, please try again later")
ctx.Abort()
return
}
ctx.Next()
}
}

View File

@ -0,0 +1,81 @@
// internal/middleware/role_middleware.go
package middleware
import (
"lost-and-found/internal/models"
"lost-and-found/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
)
func RequirePermission(requiredPerm string) gin.HandlerFunc {
return func(ctx *gin.Context) {
// 1. Ambil object user dari context (diset oleh JWTMiddleware)
userObj, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
ctx.Abort()
return
}
user := userObj.(*models.User)
// 2. Cek Permission menggunakan method helper di model User
// Pastikan method HasPermission sudah ditambahkan di internal/models/user.go
if !user.HasPermission(requiredPerm) {
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "Missing permission: "+requiredPerm)
ctx.Abort()
return
}
ctx.Next()
}
}
// RequireRole checks if user has required role
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
return func(ctx *gin.Context) {
userObj, exists := ctx.Get("user")
if !exists {
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
ctx.Abort()
return
}
user := userObj.(*models.User)
userRole := user.Role.Name
// Check if user has allowed role
hasRole := false
for _, role := range allowedRoles {
if userRole == role {
hasRole = true
break
}
}
if !hasRole {
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "")
ctx.Abort()
return
}
ctx.Next()
}
}
// RequireAdmin middleware (admin only)
func RequireAdmin() gin.HandlerFunc {
return RequireRole(models.RoleAdmin)
}
// RequireManager middleware (manager and admin)
func RequireManager() gin.HandlerFunc {
return RequireRole(models.RoleAdmin, models.RoleManager)
}
// RequireUser middleware (all authenticated users)
func RequireUser() gin.HandlerFunc {
return RequireRole(models.RoleAdmin, models.RoleManager, models.RoleUser)
}

116
internal/models/archive.go Normal file
View File

@ -0,0 +1,116 @@
// internal/models/archive.go
package models
import (
"time"
"gorm.io/gorm"
)
// Archive represents an archived item
type Archive struct {
ID uint `gorm:"primaryKey" json:"id"`
ItemID uint `gorm:"not null;uniqueIndex" json:"item_id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
CategoryID uint `gorm:"not null" json:"category_id"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"`
Location string `gorm:"type:varchar(200)" json:"location"`
Description string `gorm:"type:text" json:"description"`
DateFound time.Time `json:"date_found"`
Status string `gorm:"type:varchar(50)" json:"status"`
ReporterName string `gorm:"type:varchar(100)" json:"reporter_name"`
ReporterContact string `gorm:"type:varchar(50)" json:"reporter_contact"`
ArchivedReason string `gorm:"type:varchar(100)" json:"archived_reason"`
ClaimedBy *uint `json:"claimed_by"`
Claimer *User `gorm:"foreignKey:ClaimedBy" json:"claimer,omitempty"`
// ✅ NEW FIELDS
BeritaAcaraNo string `gorm:"type:varchar(100)" json:"berita_acara_no"`
BuktiSerahTerima string `gorm:"type:varchar(255)" json:"bukti_serah_terima"`
ArchivedAt time.Time `json:"archived_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for Archive model
func (Archive) TableName() string {
return "archives"
}
// Archive reason constants
const (
ArchiveReasonExpired = "expired"
ArchiveReasonCaseClosed = "case_closed"
)
// BeforeCreate hook
func (a *Archive) BeforeCreate(tx *gorm.DB) error {
if a.ArchivedAt.IsZero() {
a.ArchivedAt = time.Now()
}
return nil
}
// ArchiveResponse represents archive data for API responses
type ArchiveResponse struct {
ID uint `json:"id"`
ItemID uint `json:"item_id"`
Name string `json:"name"`
Category string `json:"category"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
DateFound time.Time `json:"date_found"`
Status string `json:"status"`
ArchivedReason string `json:"archived_reason"`
ClaimedBy string `json:"claimed_by,omitempty"`
ArchivedAt time.Time `json:"archived_at"`
}
// ToResponse converts Archive to ArchiveResponse
func (a *Archive) ToResponse() ArchiveResponse {
categoryName := ""
if a.Category.ID != 0 {
categoryName = a.Category.Name
}
claimedByName := ""
if a.Claimer != nil && a.Claimer.ID != 0 {
claimedByName = a.Claimer.Name
}
return ArchiveResponse{
ID: a.ID,
ItemID: a.ItemID,
Name: a.Name,
Category: categoryName,
PhotoURL: a.PhotoURL,
Location: a.Location,
DateFound: a.DateFound,
Status: a.Status,
ArchivedReason: a.ArchivedReason,
ClaimedBy: claimedByName,
ArchivedAt: a.ArchivedAt,
}
}
// CreateFromItem creates an Archive from an Item
func CreateFromItem(item *Item, reason string, claimedBy *uint) *Archive {
return &Archive{
ItemID: item.ID,
Name: item.Name,
CategoryID: item.CategoryID,
PhotoURL: item.PhotoURL,
Location: item.Location,
Description: item.Description,
DateFound: item.DateFound,
Status: item.Status,
ReporterName: item.ReporterName,
ReporterContact: item.ReporterContact,
ArchivedReason: reason,
ClaimedBy: claimedBy,
ArchivedAt: time.Now(),
}
}

View File

@ -0,0 +1,99 @@
// internal/models/audit_log.go
package models
import (
"time"
"gorm.io/gorm"
)
// AuditLog represents an audit log entry
type AuditLog struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID *uint `json:"user_id"` // Nullable for system actions
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Action string `gorm:"type:varchar(50);not null" json:"action"` // create, update, delete, verify, login, etc.
EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, user, etc.
EntityID *uint `json:"entity_id"`
Details string `gorm:"type:text" json:"details"`
IPAddress string `gorm:"type:varchar(50)" json:"ip_address"`
UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for AuditLog model
func (AuditLog) TableName() string {
return "audit_logs"
}
// Action constants
const (
ActionCreate = "create"
ActionUpdate = "update"
ActionDelete = "delete"
ActionVerify = "verify"
ActionLogin = "login"
ActionLogout = "logout"
ActionBlock = "block"
ActionUnblock = "unblock"
ActionApprove = "approve"
ActionReject = "reject"
ActionExport = "export"
)
// Entity type constants
const (
EntityItem = "item"
EntityLostItem = "lost_item"
EntityClaim = "claim"
EntityUser = "user"
EntityCategory = "category"
EntityArchive = "archive"
)
// AuditLogResponse represents audit log data for API responses
type AuditLogResponse struct {
ID uint `json:"id"`
UserName string `json:"user_name,omitempty"`
Action string `json:"action"`
EntityType string `json:"entity_type"`
EntityID *uint `json:"entity_id"`
Details string `json:"details"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
}
// ToResponse converts AuditLog to AuditLogResponse
func (a *AuditLog) ToResponse() AuditLogResponse {
userName := "System"
if a.User != nil && a.User.ID != 0 {
userName = a.User.Name
}
return AuditLogResponse{
ID: a.ID,
UserName: userName,
Action: a.Action,
EntityType: a.EntityType,
EntityID: a.EntityID,
Details: a.Details,
IPAddress: a.IPAddress,
CreatedAt: a.CreatedAt,
}
}
// CreateAuditLog creates a new audit log entry
func CreateAuditLog(db *gorm.DB, userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
log := &AuditLog{
UserID: userID,
Action: action,
EntityType: entityType,
EntityID: entityID,
Details: details,
IPAddress: ipAddress,
UserAgent: userAgent,
}
return db.Create(log).Error
}

View File

@ -0,0 +1,49 @@
// internal/models/category.go
package models
import (
"time"
"gorm.io/gorm"
)
// Category represents an item category
type Category struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"`
Description string `gorm:"type:text" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Relationships
Items []Item `gorm:"foreignKey:CategoryID" json:"items,omitempty"`
LostItems []LostItem `gorm:"foreignKey:CategoryID" json:"lost_items,omitempty"`
}
// TableName specifies the table name for Category model
func (Category) TableName() string {
return "categories"
}
// CategoryResponse represents category data for API responses
type CategoryResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
ItemCount int64 `json:"item_count,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ToResponse converts Category to CategoryResponse
func (c *Category) ToResponse() CategoryResponse {
return CategoryResponse{
ID: c.ID,
Name: c.Name,
Slug: c.Slug,
Description: c.Description,
CreatedAt: c.CreatedAt,
}
}

View File

@ -0,0 +1,50 @@
package models
import (
"time"
)
type ChatMessage struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Message string `gorm:"type:text;not null" json:"message"`
Response string `gorm:"type:text;not null" json:"response"`
ContextData string `gorm:"type:json" json:"context_data"`
Intent string `gorm:"type:varchar(50)" json:"intent"`
ConfidenceScore float64 `gorm:"type:decimal(5,2);default:0.00" json:"confidence_score"`
CreatedAt time.Time `json:"created_at"`
}
func (ChatMessage) TableName() string {
return "chat_messages"
}
// Intent types
const (
IntentSearchItem = "search_item"
IntentReportLost = "report_lost"
IntentClaimHelp = "claim_help"
IntentGeneral = "general"
IntentRecommendItem = "recommend_item"
)
type ChatMessageResponse struct {
ID uint `json:"id"`
Message string `json:"message"`
Response string `json:"response"`
Intent string `json:"intent"`
ConfidenceScore float64 `json:"confidence_score"`
CreatedAt time.Time `json:"created_at"`
}
func (c *ChatMessage) ToResponse() ChatMessageResponse {
return ChatMessageResponse{
ID: c.ID,
Message: c.Message,
Response: c.Response,
Intent: c.Intent,
ConfidenceScore: c.ConfidenceScore,
CreatedAt: c.CreatedAt,
}
}

235
internal/models/claim.go Normal file
View File

@ -0,0 +1,235 @@
package models
import (
"fmt"
"time"
"gorm.io/gorm"
)
// Claim represents a claim for a found item or a direct claim on a lost item
type Claim struct {
ID uint `gorm:"primaryKey" json:"id"`
// ✅ UBAH: Jadi Pointer (*uint) agar bisa NULL
ItemID *uint `json:"item_id"`
// ✅ UBAH: Jadi Pointer (*Item) untuk handling relasi opsional
Item *Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
// ✅ BARU: Relasi ke LostItem (untuk Direct Claim)
LostItemID *uint `json:"lost_item_id"`
LostItem *LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"`
UserID uint `gorm:"not null" json:"user_id"` // Ini adalah Finder (Penemu)
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Description string `gorm:"type:text;not null" json:"description"`
ProofURL string `gorm:"type:varchar(255)" json:"proof_url"`
Contact string `gorm:"type:varchar(50);not null" json:"contact"`
Status string `gorm:"type:varchar(50);default:'pending'" json:"status"`
Notes string `gorm:"type:text" json:"notes"`
VerifiedAt *time.Time `json:"verified_at"`
VerifiedBy *uint `json:"verified_by"`
Verifier *User `gorm:"foreignKey:VerifiedBy" json:"verifier,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Relationships
Verification *ClaimVerification `gorm:"foreignKey:ClaimID" json:"verification,omitempty"`
}
func (Claim) TableName() string {
return "claims"
}
// Claim status constants
const (
ClaimStatusPending = "pending"
ClaimStatusApproved = "approved"
ClaimStatusRejected = "rejected"
ClaimStatusWaitingOwner = "waiting_owner"
ClaimStatusVerified = "verified"
)
func (c *Claim) BeforeCreate(tx *gorm.DB) error {
if c.Status == "" {
c.Status = ClaimStatusPending
}
return nil
}
// Helper methods status check
func (c *Claim) IsPending() bool { return c.Status == ClaimStatusPending }
func (c *Claim) IsApproved() bool { return c.Status == ClaimStatusApproved }
func (c *Claim) IsRejected() bool { return c.Status == ClaimStatusRejected }
func (c *Claim) IsWaitingOwner() bool { return c.Status == ClaimStatusWaitingOwner }
func (c *Claim) IsVerified() bool { return c.Status == ClaimStatusVerified }
// Approve approves the claim
func (c *Claim) Approve(verifierID uint, notes string) {
c.Status = ClaimStatusApproved
c.VerifiedBy = &verifierID
now := time.Now()
c.VerifiedAt = &now
c.Notes = notes
}
// Reject rejects the claim
func (c *Claim) Reject(verifierID uint, notes string) {
c.Status = ClaimStatusRejected
c.VerifiedBy = &verifierID
now := time.Now()
c.VerifiedAt = &now
c.Notes = notes
}
// ClaimResponse represents claim data for API responses
type ClaimResponse struct {
ID uint `json:"id"`
ItemID uint `json:"item_id"` // 0 jika Direct Claim
LostItemID uint `json:"lost_item_id"` // ✅ BARU: 0 jika Regular Claim
LostItemUserID *uint `json:"lost_item_user_id"`
ItemName string `json:"item_name"`
UserID uint `json:"user_id"`
UserName string `json:"user_name"`
Description string `json:"description"`
ItemSecretDetails string `json:"item_secret_details"`
ProofURL string `json:"proof_url"`
Contact string `json:"contact"`
Status string `json:"status"`
Notes string `json:"notes"`
MatchPercentage *float64 `json:"match_percentage,omitempty"`
VerifiedAt *time.Time `json:"verified_at"`
VerifiedBy *uint `json:"verified_by"`
VerifierName string `json:"verifier_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Administrative Fields
BeritaAcaraNo string `json:"berita_acara_no,omitempty"`
BuktiSerahTerima string `json:"bukti_serah_terima,omitempty"`
CaseClosedAt *time.Time `json:"case_closed_at,omitempty"`
ReporterName string `json:"reporter_name"`
CaseClosedByName string `json:"case_closed_by_name,omitempty"`
Type string `json:"type"` // ✅ BARU: "regular" atau "direct"
}
// ToResponse converts Claim to ClaimResponse with SAFETY CHECKS
func (c *Claim) ToResponse() ClaimResponse {
// 1. Initialize Default Values
var itemID, lostItemID uint
itemName := "Unknown Item"
itemSecret := ""
beritaAcara := ""
buktiSerahTerima := ""
var caseClosedAt *time.Time
caseClosedByName := ""
reporterName := ""
claimType := "unknown"
// 2. LOGIC PENTING: Tentukan sumber data (Item vs LostItem)
// KASUS A: REGULAR CLAIM (Dari Barang Temuan)
if c.Item != nil {
claimType = "regular"
itemID = c.Item.ID
itemName = c.Item.Name
itemSecret = c.Item.SecretDetails
// Admin details hanya ada di Item Temuan
beritaAcara = c.Item.BeritaAcaraNo
buktiSerahTerima = c.Item.BuktiSerahTerima
caseClosedAt = c.Item.CaseClosedAt
reporterName = c.Item.ReporterName
if c.Item.CaseClosedBy_User != nil {
caseClosedByName = c.Item.CaseClosedBy_User.Name
}
} else if c.LostItem != nil {
// KASUS B: DIRECT CLAIM (Dari Barang Hilang)
claimType = "direct"
lostItemID = c.LostItem.ID
itemName = fmt.Sprintf("[DICARI] %s", c.LostItem.Name)
itemSecret = c.LostItem.Description // Gunakan deskripsi lost item sebagai rahasia
// Untuk direct claim, ReporterName adalah si User (Finder) yang membuat claim ini
if c.User.ID != 0 {
reporterName = c.User.Name
}
}
// 3. User Info (Claimant)
userName := ""
if c.User.ID != 0 {
userName = c.User.Name
}
// 4. Verifier Info
verifierName := ""
if c.Verifier != nil {
verifierName = c.Verifier.Name
}
// 5. Match Info
var matchPercentage *float64
if c.Verification != nil {
matchPercentage = &c.Verification.SimilarityScore
}
return ClaimResponse{
ID: c.ID,
ItemID: itemID,
LostItemID: lostItemID, // ✅ Field Baru
ItemName: itemName,
UserID: c.UserID,
UserName: userName,
Description: c.Description,
ItemSecretDetails: itemSecret,
ProofURL: c.ProofURL,
Contact: c.Contact,
Status: c.Status,
Notes: c.Notes,
MatchPercentage: matchPercentage,
VerifiedAt: c.VerifiedAt,
VerifiedBy: c.VerifiedBy,
VerifierName: verifierName,
CreatedAt: c.CreatedAt,
BeritaAcaraNo: beritaAcara,
BuktiSerahTerima: buktiSerahTerima,
CaseClosedAt: caseClosedAt,
CaseClosedByName: caseClosedByName,
ReporterName: reporterName,
Type: claimType, // ✅ Field Baru
}
}
// ClaimDetailResponse includes item description for verification
type ClaimDetailResponse struct {
ClaimResponse
ItemDescription string `json:"item_description"`
ItemSecretDetails string `json:"item_secret_details"`
}
// ToDetailResponse converts Claim to ClaimDetailResponse
func (c *Claim) ToDetailResponse() ClaimDetailResponse {
baseResponse := c.ToResponse()
itemDescription := ""
itemSecretDetails := ""
// ✅ Logic Aman: Cek Item dulu, kalau nil cek LostItem
if c.Item != nil {
itemDescription = c.Item.Description
itemSecretDetails = c.Item.SecretDetails
} else if c.LostItem != nil {
itemDescription = c.LostItem.Description
itemSecretDetails = c.LostItem.Description // Fallback
}
return ClaimDetailResponse{
ClaimResponse: baseResponse,
ItemDescription: itemDescription,
ItemSecretDetails: itemSecretDetails,
}
}

View File

@ -0,0 +1,78 @@
// internal/models/claim_verification.go
package models
import (
"time"
"gorm.io/gorm"
)
// ClaimVerification represents verification data for a claim
type ClaimVerification struct {
ID uint `gorm:"primaryKey" json:"id"`
ClaimID uint `gorm:"not null;uniqueIndex" json:"claim_id"`
Claim Claim `gorm:"foreignKey:ClaimID" json:"claim,omitempty"`
SimilarityScore float64 `gorm:"type:decimal(5,2);default:0" json:"similarity_score"` // Percentage match (0-100)
MatchedKeywords string `gorm:"type:text" json:"matched_keywords"` // Keywords that matched
VerificationNotes string `gorm:"type:text" json:"verification_notes"` // Manager's notes
IsAutoMatched bool `gorm:"default:false" json:"is_auto_matched"` // Was it auto-matched?
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for ClaimVerification model
func (ClaimVerification) TableName() string {
return "claim_verifications"
}
// IsHighMatch checks if similarity score is high (>= 70%)
func (cv *ClaimVerification) IsHighMatch() bool {
return cv.SimilarityScore >= 70.0
}
// IsMediumMatch checks if similarity score is medium (50-69%)
func (cv *ClaimVerification) IsMediumMatch() bool {
return cv.SimilarityScore >= 50.0 && cv.SimilarityScore < 70.0
}
// IsLowMatch checks if similarity score is low (< 50%)
func (cv *ClaimVerification) IsLowMatch() bool {
return cv.SimilarityScore < 50.0
}
// GetMatchLevel returns the match level as string
func (cv *ClaimVerification) GetMatchLevel() string {
if cv.IsHighMatch() {
return "high"
} else if cv.IsMediumMatch() {
return "medium"
}
return "low"
}
// ClaimVerificationResponse represents verification data for API responses
type ClaimVerificationResponse struct {
ID uint `json:"id"`
ClaimID uint `json:"claim_id"`
SimilarityScore float64 `json:"similarity_score"`
MatchLevel string `json:"match_level"`
MatchedKeywords string `json:"matched_keywords"`
VerificationNotes string `json:"verification_notes"`
IsAutoMatched bool `json:"is_auto_matched"`
CreatedAt time.Time `json:"created_at"`
}
// ToResponse converts ClaimVerification to ClaimVerificationResponse
func (cv *ClaimVerification) ToResponse() ClaimVerificationResponse {
return ClaimVerificationResponse{
ID: cv.ID,
ClaimID: cv.ClaimID,
SimilarityScore: cv.SimilarityScore,
MatchLevel: cv.GetMatchLevel(),
MatchedKeywords: cv.MatchedKeywords,
VerificationNotes: cv.VerificationNotes,
IsAutoMatched: cv.IsAutoMatched,
CreatedAt: cv.CreatedAt,
}
}

214
internal/models/item.go Normal file
View File

@ -0,0 +1,214 @@
// internal/models/item.go - FIXED VERSION dengan SecretDetails
package models
import (
"time"
"gorm.io/gorm"
)
// Item represents a found item
type Item struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
CategoryID uint `gorm:"not null" json:"category_id"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"`
Location string `gorm:"type:varchar(200);not null" json:"location"`
Description string `gorm:"type:text;not null" json:"description"`
SecretDetails string `gorm:"type:text;column:secret_details" json:"secret_details,omitempty"`
DateFound time.Time `gorm:"not null" json:"date_found"`
Status string `gorm:"type:varchar(50);default:'unclaimed'" json:"status"`
ReporterID uint `gorm:"not null" json:"reporter_id"`
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
ReporterName string `gorm:"type:varchar(100);not null" json:"reporter_name"`
ReporterContact string `gorm:"type:varchar(50);not null" json:"reporter_contact"`
ExpiresAt *time.Time `json:"expires_at"`
// ✅ NEW FIELDS FOR CASE CLOSE
BeritaAcaraNo string `gorm:"type:varchar(100)" json:"berita_acara_no"`
BuktiSerahTerima string `gorm:"type:varchar(255)" json:"bukti_serah_terima"`
CaseClosedAt *time.Time `json:"case_closed_at"`
CaseClosedBy *uint `json:"case_closed_by"`
CaseClosedBy_User *User `gorm:"foreignKey:CaseClosedBy" json:"case_closed_by_user,omitempty"`
CaseClosedNotes string `gorm:"type:text" json:"case_closed_notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Relationships
Claims []Claim `gorm:"foreignKey:ItemID" json:"claims,omitempty"`
MatchResults []MatchResult `gorm:"foreignKey:ItemID" json:"match_results,omitempty"`
RevisionLogs []RevisionLog `gorm:"foreignKey:ItemID" json:"revision_logs,omitempty"`
}
// TableName specifies the table name for Item model
func (Item) TableName() string {
return "items"
}
// Status constants
const (
ItemStatusUnclaimed = "unclaimed"
ItemStatusPendingClaim = "pending_claim"
ItemStatusVerified = "verified"
ItemStatusCaseClosed = "case_closed"
ItemStatusExpired = "expired"
)
// BeforeCreate hook to set expiration date
func (i *Item) BeforeCreate(tx *gorm.DB) error {
if i.Status == "" {
i.Status = ItemStatusUnclaimed
}
if i.ExpiresAt == nil {
expiresAt := i.DateFound.AddDate(0, 0, 90)
i.ExpiresAt = &expiresAt
}
return nil
}
// IsExpired checks if item has expired
func (i *Item) IsExpired() bool {
if i.ExpiresAt == nil {
return false
}
return time.Now().After(*i.ExpiresAt)
}
// CanBeClaimed checks if item can be claimed
func (i *Item) CanBeClaimed() bool {
return i.Status == ItemStatusUnclaimed && !i.IsExpired()
}
// CanBeEdited checks if item can be edited
func (i *Item) CanBeEdited() bool {
return i.Status != ItemStatusCaseClosed && i.Status != ItemStatusExpired
}
// ItemPublicResponse represents item data for public view (without sensitive info)
type ItemPublicResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
DateFound time.Time `json:"date_found"`
Status string `json:"status"`
ReporterID uint `json:"reporter_id"`
CreatedAt time.Time `json:"created_at"`
}
func (i *Item) GetDisplayStatus() string {
// Jika case closed atau expired, return as-is
if i.Status == ItemStatusCaseClosed || i.Status == ItemStatusExpired {
return i.Status
}
// ✅ LOGIKA BARU: Check claims
if len(i.Claims) > 0 {
// Check apakah ada claim yang approved/completed
for _, claim := range i.Claims {
if claim.Status == ClaimStatusApproved || claim.Status == ClaimStatusVerified {
return ItemStatusVerified // atau "completed"
}
if claim.Status == ClaimStatusPending || claim.Status == ClaimStatusWaitingOwner {
return ItemStatusPendingClaim // "Sedang Diklaim"
}
}
}
// Default: return status dari database
return i.Status
}
// ToPublicResponse converts Item to ItemPublicResponse (hides description, reporter details)
func (i *Item) ToPublicResponse() ItemPublicResponse {
categoryName := ""
if i.Category.ID != 0 {
categoryName = i.Category.Name
}
return ItemPublicResponse{
ID: i.ID,
Name: i.Name,
Category: categoryName,
PhotoURL: i.PhotoURL,
Location: i.Location,
DateFound: i.DateFound,
Status: i.GetDisplayStatus(), // ✅ GANTI JADI GetDisplayStatus()
ReporterID: i.ReporterID,
CreatedAt: i.CreatedAt,
}
}
type ItemDetailResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
Description string `json:"description"`
SecretDetails string `json:"secret_details"`
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"`
// ✅ NEW FIELDS
BeritaAcaraNo string `json:"berita_acara_no,omitempty"`
BuktiSerahTerima string `json:"bukti_serah_terima,omitempty"`
CaseClosedAt *time.Time `json:"case_closed_at,omitempty"`
CaseClosedByName string `json:"case_closed_by_name,omitempty"`
CaseClosedNotes string `json:"case_closed_notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ✅ FIXED ToDetailResponse - MAPPING LENGKAP
func (i *Item) ToDetailResponse() ItemDetailResponse {
categoryName := ""
if i.Category.ID != 0 {
categoryName = i.Category.Name
}
reporterName := i.ReporterName
if reporterName == "" && i.Reporter.ID != 0 {
reporterName = i.Reporter.Name
}
reporterContact := i.ReporterContact
if reporterContact == "" && i.Reporter.ID != 0 {
reporterContact = i.Reporter.Phone
}
caseClosedByName := ""
if i.CaseClosedBy_User != nil && i.CaseClosedBy_User.ID != 0 {
caseClosedByName = i.CaseClosedBy_User.Name
}
return ItemDetailResponse{
ID: i.ID,
Name: i.Name,
Category: categoryName,
PhotoURL: i.PhotoURL,
Location: i.Location,
Description: i.Description,
SecretDetails: i.SecretDetails,
DateFound: i.DateFound,
Status: i.GetDisplayStatus(), // ✅ GANTI JADI GetDisplayStatus()
ReporterName: reporterName,
ReporterContact: reporterContact,
ExpiresAt: i.ExpiresAt,
BeritaAcaraNo: i.BeritaAcaraNo,
BuktiSerahTerima: i.BuktiSerahTerima,
CaseClosedAt: i.CaseClosedAt,
CaseClosedByName: caseClosedByName,
CaseClosedNotes: i.CaseClosedNotes,
CreatedAt: i.CreatedAt,
}
}

View File

@ -0,0 +1,111 @@
package models
import (
"time"
"gorm.io/gorm"
)
type LostItem struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
CategoryID uint `gorm:"not null" json:"category_id"`
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Color string `gorm:"type:varchar(50)" json:"color"`
Location string `gorm:"type:varchar(200)" json:"location"`
Description string `gorm:"type:text;not null" json:"description"`
DateLost time.Time `gorm:"not null" json:"date_lost"`
Status string `gorm:"type:varchar(50);default:'active'" json:"status"`
MatchedAt *time.Time `json:"matched_at"`
// NEW: Direct claim fields
DirectClaimID *uint `json:"direct_claim_id"`
DirectClaim *Claim `gorm:"foreignKey:DirectClaimID" json:"direct_claim,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MatchResults []MatchResult `gorm:"foreignKey:LostItemID" json:"match_results,omitempty"`
}
func (LostItem) TableName() string {
return "lost_items"
}
const (
LostItemStatusActive = "active"
LostItemStatusFound = "found"
LostItemStatusExpired = "expired"
LostItemStatusClosed = "closed"
LostItemStatusClaimed = "claimed" // NEW: Status ketika ada yang klaim langsung ke owner
LostItemStatusCompleted = "completed" // NEW: Status ketika owner confirm sudah terima barang
)
func (l *LostItem) BeforeCreate(tx *gorm.DB) error {
if l.Status == "" {
l.Status = LostItemStatusActive
}
return nil
}
func (l *LostItem) IsActive() bool {
return l.Status == LostItemStatusActive
}
func (l *LostItem) IsClaimed() bool {
return l.Status == LostItemStatusClaimed
}
type LostItemResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
UserName string `json:"user_name"`
Name string `json:"name"`
CategoryID uint `json:"category_id"` // ✅ TAMBAHKAN INI
Category string `json:"category"`
Color string `json:"color"`
Location string `json:"location"`
Description string `json:"description"`
DateLost time.Time `json:"date_lost"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
DirectClaimID *uint `json:"direct_claim_id,omitempty"`
DirectClaimStatus string `json:"direct_claim_status,omitempty"`
DirectClaim *ClaimResponse `json:"direct_claim,omitempty"`
}
func (l *LostItem) ToResponse() LostItemResponse {
userName := ""
if l.User.ID != 0 {
userName = l.User.Name
}
categoryName := ""
if l.Category.ID != 0 {
categoryName = l.Category.Name
}
directClaimStatus := ""
var directClaimResp *ClaimResponse
if l.DirectClaim != nil {
directClaimStatus = l.DirectClaim.Status
resp := l.DirectClaim.ToResponse()
directClaimResp = &resp
}
return LostItemResponse{
ID: l.ID,
UserID: l.UserID,
UserName: userName,
Name: l.Name,
CategoryID: l.CategoryID, // ✅ TAMBAHKAN INI
Category: categoryName,
Color: l.Color,
Location: l.Location,
Description: l.Description,
DateLost: l.DateLost,
Status: l.Status,
CreatedAt: l.CreatedAt,
DirectClaimID: l.DirectClaimID,
DirectClaimStatus: directClaimStatus,
DirectClaim: directClaimResp,
}
}

View File

@ -0,0 +1,129 @@
// internal/models/match_result.go
package models
import (
"time"
"gorm.io/gorm"
)
// MatchResult represents auto-matching result between lost item and found item
type MatchResult struct {
ID uint `gorm:"primaryKey" json:"id"`
LostItemID uint `gorm:"not null" json:"lost_item_id"`
LostItem LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"`
ItemID uint `gorm:"not null" json:"item_id"`
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
SimilarityScore float64 `gorm:"type:decimal(5,2)" json:"similarity_score"` // Percentage match (0-100)
MatchedFields string `gorm:"type:text" json:"matched_fields"` // JSON of matched fields
MatchedAt time.Time `gorm:"not null" json:"matched_at"`
IsNotified bool `gorm:"default:false" json:"is_notified"` // Was user notified?
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for MatchResult model
func (MatchResult) TableName() string {
return "match_results"
}
// BeforeCreate hook
func (mr *MatchResult) BeforeCreate(tx *gorm.DB) error {
if mr.MatchedAt.IsZero() {
mr.MatchedAt = time.Now()
}
return nil
}
// IsHighMatch checks if similarity score is high (>= 70%)
func (mr *MatchResult) IsHighMatch() bool {
return mr.SimilarityScore >= 70.0
}
// IsMediumMatch checks if similarity score is medium (50-69%)
func (mr *MatchResult) IsMediumMatch() bool {
return mr.SimilarityScore >= 50.0 && mr.SimilarityScore < 70.0
}
// IsLowMatch checks if similarity score is low (< 50%)
func (mr *MatchResult) IsLowMatch() bool {
return mr.SimilarityScore < 50.0
}
// GetMatchLevel returns the match level as string
func (mr *MatchResult) GetMatchLevel() string {
if mr.IsHighMatch() {
return "high"
} else if mr.IsMediumMatch() {
return "medium"
}
return "low"
}
// MatchResultResponse represents match result data for API responses
type MatchResultResponse struct {
ID uint `json:"id"`
LostItemID uint `json:"lost_item_id"`
LostItemName string `json:"lost_item_name"`
ItemID uint `json:"item_id"`
ItemName string `json:"item_name"`
ItemPhotoURL string `json:"item_photo_url"`
ItemLocation string `json:"item_location"`
ItemDateFound time.Time `json:"item_date_found"`
ItemStatus string `json:"item_status"`
SimilarityScore float64 `json:"similarity_score"`
MatchLevel string `json:"match_level"`
MatchedFields string `json:"matched_fields"`
MatchedAt time.Time `json:"matched_at"`
IsNotified bool `json:"is_notified"`
}
// ToResponse converts MatchResult to MatchResultResponse
func (mr *MatchResult) ToResponse() MatchResultResponse {
lostItemName := ""
if mr.LostItem.ID != 0 {
lostItemName = mr.LostItem.Name
}
itemName := ""
itemPhotoURL := ""
itemLocation := ""
itemDateFound := time.Time{}
itemStatus := ""
if mr.Item.ID != 0 {
itemName = mr.Item.Name
itemPhotoURL = mr.Item.PhotoURL
itemLocation = mr.Item.Location
itemDateFound = mr.Item.DateFound
itemStatus = mr.Item.Status
}
return MatchResultResponse{
ID: mr.ID,
LostItemID: mr.LostItemID,
LostItemName: lostItemName,
ItemID: mr.ItemID,
ItemName: itemName,
ItemPhotoURL: itemPhotoURL,
ItemLocation: itemLocation,
ItemDateFound: itemDateFound,
ItemStatus: itemStatus,
SimilarityScore: mr.SimilarityScore,
MatchLevel: mr.GetMatchLevel(),
MatchedFields: mr.MatchedFields,
MatchedAt: mr.MatchedAt,
IsNotified: mr.IsNotified,
}
}
// ItemMatchResponse represents simplified item data for matching display
type ItemMatchResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
DateFound time.Time `json:"date_found"`
Status string `json:"status"`
Similarity float64 `json:"similarity"`
}

View File

@ -0,0 +1,128 @@
// internal/models/notification.go
package models
import (
"time"
"gorm.io/gorm"
)
// Notification represents a notification for a user
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Type string `gorm:"type:varchar(50);not null" json:"type"` // match_found, claim_approved, claim_rejected, item_expired, etc.
Title string `gorm:"type:varchar(200);not null" json:"title"`
Message string `gorm:"type:text;not null" json:"message"`
EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, match, etc.
EntityID *uint `json:"entity_id"`
IsRead bool `gorm:"default:false" json:"is_read"`
ReadAt *time.Time `json:"read_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for Notification model
func (Notification) TableName() string {
return "notifications"
}
// Notification type constants
const (
NotificationMatchFound = "match_found"
NotificationClaimApproved = "claim_approved"
NotificationClaimRejected = "claim_rejected"
NotificationItemExpired = "item_expired"
NotificationNewClaim = "new_claim"
NotificationItemReturned = "item_returned"
)
// MarkAsRead marks the notification as read
func (n *Notification) MarkAsRead() {
n.IsRead = true
now := time.Now()
n.ReadAt = &now
}
// NotificationResponse represents notification data for API responses
type NotificationResponse struct {
ID uint `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Message string `json:"message"`
EntityType string `json:"entity_type"`
EntityID *uint `json:"entity_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
CreatedAt time.Time `json:"created_at"`
}
// ToResponse converts Notification to NotificationResponse
func (n *Notification) ToResponse() NotificationResponse {
return NotificationResponse{
ID: n.ID,
Type: n.Type,
Title: n.Title,
Message: n.Message,
EntityType: n.EntityType,
EntityID: n.EntityID,
IsRead: n.IsRead,
ReadAt: n.ReadAt,
CreatedAt: n.CreatedAt,
}
}
// CreateNotification creates a new notification
func CreateNotification(db *gorm.DB, userID uint, notifType, title, message, entityType string, entityID *uint) error {
notification := &Notification{
UserID: userID,
Type: notifType,
Title: title,
Message: message,
EntityType: entityType,
EntityID: entityID,
}
return db.Create(notification).Error
}
// CreateMatchNotification creates a notification for match found
func CreateMatchNotification(db *gorm.DB, userID uint, itemName string, matchID uint) error {
return CreateNotification(
db,
userID,
NotificationMatchFound,
"Barang yang Mirip Ditemukan!",
"Kami menemukan barang yang mirip dengan laporan kehilangan Anda: "+itemName,
"match",
&matchID,
)
}
// CreateClaimApprovedNotification creates a notification for approved claim
func CreateClaimApprovedNotification(db *gorm.DB, userID uint, itemName string, claimID uint) error {
return CreateNotification(
db,
userID,
NotificationClaimApproved,
"Klaim Disetujui!",
"Klaim Anda untuk barang '"+itemName+"' telah disetujui. Silakan ambil barang di tempat yang ditentukan.",
"claim",
&claimID,
)
}
// CreateClaimRejectedNotification creates a notification for rejected claim
func CreateClaimRejectedNotification(db *gorm.DB, userID uint, itemName, reason string, claimID uint) error {
return CreateNotification(
db,
userID,
NotificationClaimRejected,
"Klaim Ditolak",
"Klaim Anda untuk barang '"+itemName+"' ditolak. Alasan: "+reason,
"claim",
&claimID,
)
}

View File

@ -0,0 +1,12 @@
package models
import "time"
type Permission struct {
ID uint `gorm:"primaryKey" json:"id"`
Slug string `gorm:"unique;not null" json:"slug"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,73 @@
// internal/models/revision_log.go
package models
import (
"time"
"gorm.io/gorm"
)
// RevisionLog represents revision history for item edits
type RevisionLog struct {
ID uint `gorm:"primaryKey" json:"id"`
ItemID uint `gorm:"not null" json:"item_id"`
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
UserID uint `gorm:"not null" json:"user_id"` // Who made the edit
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
FieldName string `gorm:"type:varchar(50);not null" json:"field_name"` // Which field was edited
OldValue string `gorm:"type:text" json:"old_value"`
NewValue string `gorm:"type:text" json:"new_value"`
Reason string `gorm:"type:text" json:"reason"` // Why was it edited
CreatedAt time.Time `json:"created_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name for RevisionLog model
func (RevisionLog) TableName() string {
return "revision_logs"
}
// RevisionLogResponse represents revision log data for API responses
type RevisionLogResponse struct {
ID uint `json:"id"`
ItemID uint `json:"item_id"`
UserName string `json:"user_name"`
FieldName string `json:"field_name"`
OldValue string `json:"old_value"`
NewValue string `json:"new_value"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
// ToResponse converts RevisionLog to RevisionLogResponse
func (rl *RevisionLog) ToResponse() RevisionLogResponse {
userName := ""
if rl.User.ID != 0 {
userName = rl.User.Name
}
return RevisionLogResponse{
ID: rl.ID,
ItemID: rl.ItemID,
UserName: userName,
FieldName: rl.FieldName,
OldValue: rl.OldValue,
NewValue: rl.NewValue,
Reason: rl.Reason,
CreatedAt: rl.CreatedAt,
}
}
// CreateRevisionLog creates a new revision log entry
func CreateRevisionLog(db *gorm.DB, itemID, userID uint, fieldName, oldValue, newValue, reason string) error {
log := &RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: fieldName,
OldValue: oldValue,
NewValue: newValue,
Reason: reason,
}
return db.Create(log).Error
}

56
internal/models/role.go Normal file
View File

@ -0,0 +1,56 @@
// internal/models/role.go
package models
import (
"time"
"gorm.io/gorm"
)
// Role represents a user role in the system
type Role struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
// ✅ Tambahkan relasi Permissions
Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Users []User `gorm:"foreignKey:RoleID" json:"users,omitempty"`
}
// TableName specifies the table name for Role model
func (Role) TableName() string {
return "roles"
}
// Role constants
const (
RoleAdmin = "admin"
RoleManager = "manager"
RoleUser = "user"
)
// GetRoleID returns the ID for a given role name
func GetRoleID(db *gorm.DB, roleName string) (uint, error) {
var role Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return 0, err
}
return role.ID, nil
}
// IsValidRole checks if a role name is valid
func IsValidRole(roleName string) bool {
validRoles := []string{RoleAdmin, RoleManager, RoleUser}
for _, r := range validRoles {
if r == roleName {
return true
}
}
return false
}

147
internal/models/user.go Normal file
View File

@ -0,0 +1,147 @@
// internal/models/user.go - FIXED VERSION
package models
import (
"lost-and-found/internal/utils"
"time"
"gorm.io/gorm"
)
// User represents a user in the system
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Email string `gorm:"type:varchar(100);uniqueIndex;not null" json:"email"`
Password string `gorm:"type:varchar(255);not null" json:"-"`
NRP string `gorm:"type:varchar(20)" json:"nrp"` // ✅ Plain text
Phone string `gorm:"type:varchar(20)" json:"phone"` // ✅ Plain text
RoleID uint `gorm:"not null;default:3" json:"role_id"`
Role Role `gorm:"foreignKey:RoleID" json:"role,omitempty"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
LastLogin *time.Time `json:"last_login"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName specifies the table name
func (User) TableName() string {
return "users"
}
// User status constants
const (
UserStatusActive = "active"
UserStatusBlocked = "blocked"
)
// IsActive checks if user is active
func (u *User) IsActive() bool {
return u.Status == UserStatusActive
}
// IsBlocked checks if user is blocked
func (u *User) IsBlocked() bool {
return u.Status == UserStatusBlocked
}
// IsAdmin checks if user is admin
func (u *User) IsAdmin() bool {
return u.Role.Name == "admin"
}
// IsManager checks if user is manager
func (u *User) IsManager() bool {
return u.Role.Name == "manager"
}
func (u *User) HasPermission(permissionSlug string) bool {
// Jika Role atau Permissions belum di-load, return false (fail safe)
if u.Role.ID == 0 {
return false
}
// Admin (Role ID 1) biasanya bypass semua check, tapi sebaiknya tetap cek list
// untuk konsistensi database.
for _, perm := range u.Role.Permissions {
if perm.Slug == permissionSlug {
return true
}
}
return false
}
// IsUser checks if user is regular user
func (u *User) IsUser() bool {
return u.Role.Name == "user"
}
// UserResponse represents user data for API responses
type UserResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
NRP string `json:"nrp,omitempty"` // ✅ Langsung dari database
Phone string `json:"phone,omitempty"` // ✅ Langsung dari database
Role string `json:"role"`
Status string `json:"status"`
LastLogin *time.Time `json:"last_login,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ✅ ToResponse converts User to UserResponse dengan DEKRIPSI
func (u *User) ToResponse() UserResponse {
response := UserResponse{
ID: u.ID,
Name: u.Name,
Email: u.Email,
NRP: u.NRP, // ✅ Langsung assign
Phone: u.Phone, // ✅ Langsung assign
Status: u.Status,
LastLogin: u.LastLogin,
CreatedAt: u.CreatedAt,
}
// Set role name
if u.Role.ID != 0 {
response.Role = u.Role.Name
}
// ✅ DEKRIPSI NRP
if u.NRP != "" {
decryptedNRP, err := utils.DecryptString(u.NRP)
if err == nil {
response.NRP = decryptedNRP
} else {
// Jika dekripsi gagal, kembalikan nilai asli (untuk backward compatibility)
response.NRP = u.NRP
}
}
// ✅ DEKRIPSI Phone
if u.Phone != "" {
decryptedPhone, err := utils.DecryptString(u.Phone)
if err == nil {
response.Phone = decryptedPhone
} else {
// Jika dekripsi gagal, kembalikan nilai asli (untuk backward compatibility)
response.Phone = u.Phone
}
}
return response
}
// ✅ ToPublicResponse - untuk public access (hide sensitive data)
func (u *User) ToPublicResponse() UserResponse {
return UserResponse{
ID: u.ID,
Name: u.Name,
Role: u.Role.Name,
Status: u.Status,
CreatedAt: u.CreatedAt,
// NRP & Phone tidak disertakan untuk security
}
}

View File

@ -0,0 +1,93 @@
// internal/repositories/archive_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type ArchiveRepository struct {
db *gorm.DB
}
func NewArchiveRepository(db *gorm.DB) *ArchiveRepository {
return &ArchiveRepository{db: db}
}
// Create creates a new archive record
func (r *ArchiveRepository) Create(archive *models.Archive) error {
return r.db.Create(archive).Error
}
// FindByID finds archive by ID
func (r *ArchiveRepository) FindByID(id uint) (*models.Archive, error) {
var archive models.Archive
err := r.db.Preload("Category").Preload("Claimer").Preload("Claimer.Role").First(&archive, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("archive not found")
}
return nil, err
}
return &archive, nil
}
// FindAll returns all archived items with filters
func (r *ArchiveRepository) FindAll(page, limit int, reason, search string) ([]models.Archive, int64, error) {
var archives []models.Archive
var total int64
query := r.db.Model(&models.Archive{})
// Apply filters
if reason != "" {
query = query.Where("archived_reason = ?", reason)
}
if search != "" {
// GANTI ILIKE MENJADI LIKE
query = query.Where("name LIKE ? OR location LIKE ?", "%"+search+"%", "%"+search+"%")
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("Category").Preload("Claimer").Preload("Claimer.Role").
Order("archived_at DESC").
Offset(offset).Limit(limit).Find(&archives).Error
if err != nil {
return nil, 0, err
}
return archives, total, nil
}
// FindByItemID finds archive by original item ID
func (r *ArchiveRepository) FindByItemID(itemID uint) (*models.Archive, error) {
var archive models.Archive
err := r.db.Where("item_id = ?", itemID).Preload("Category").Preload("Claimer").First(&archive).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("archive not found")
}
return nil, err
}
return &archive, nil
}
// Delete permanently deletes an archive
func (r *ArchiveRepository) Delete(id uint) error {
return r.db.Unscoped().Delete(&models.Archive{}, id).Error
}
// CountByReason counts archives by reason
func (r *ArchiveRepository) CountByReason(reason string) (int64, error) {
var count int64
err := r.db.Model(&models.Archive{}).Where("archived_reason = ?", reason).Count(&count).Error
return count, err
}

View File

@ -0,0 +1,105 @@
// internal/repositories/audit_log_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type AuditLogRepository struct {
db *gorm.DB
}
func NewAuditLogRepository(db *gorm.DB) *AuditLogRepository {
return &AuditLogRepository{db: db}
}
// Create creates a new audit log
func (r *AuditLogRepository) Create(log *models.AuditLog) error {
return r.db.Create(log).Error
}
// FindByID finds audit log by ID
func (r *AuditLogRepository) FindByID(id uint) (*models.AuditLog, error) {
var log models.AuditLog
err := r.db.Preload("User").Preload("User.Role").First(&log, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("audit log not found")
}
return nil, err
}
return &log, nil
}
// FindAll returns all audit logs with filters
func (r *AuditLogRepository) FindAll(page, limit int, action, entityType string, userID *uint) ([]models.AuditLog, int64, error) {
var logs []models.AuditLog
var total int64
query := r.db.Model(&models.AuditLog{})
// Apply filters
if action != "" {
query = query.Where("action = ?", action)
}
if entityType != "" {
query = query.Where("entity_type = ?", entityType)
}
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("User").Preload("User.Role").
Order("created_at DESC").
Offset(offset).Limit(limit).Find(&logs).Error
if err != nil {
return nil, 0, err
}
return logs, total, nil
}
// FindByUser finds audit logs by user
func (r *AuditLogRepository) FindByUser(userID uint, page, limit int) ([]models.AuditLog, int64, error) {
return r.FindAll(page, limit, "", "", &userID)
}
// FindByEntity finds audit logs by entity
func (r *AuditLogRepository) FindByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLog, int64, error) {
var logs []models.AuditLog
var total int64
query := r.db.Model(&models.AuditLog{}).
Where("entity_type = ? AND entity_id = ?", entityType, entityID)
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("User").Preload("User.Role").
Order("created_at DESC").
Offset(offset).Limit(limit).Find(&logs).Error
if err != nil {
return nil, 0, err
}
return logs, total, nil
}
// Log creates a new audit log entry (helper method)
func (r *AuditLogRepository) Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
return models.CreateAuditLog(r.db, userID, action, entityType, entityID, details, ipAddress, userAgent)
}

View File

@ -0,0 +1,102 @@
// internal/repositories/category_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type CategoryRepository struct {
db *gorm.DB
}
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
return &CategoryRepository{db: db}
}
// Create creates a new category
func (r *CategoryRepository) Create(category *models.Category) error {
return r.db.Create(category).Error
}
// FindByID finds category by ID
func (r *CategoryRepository) FindByID(id uint) (*models.Category, error) {
var category models.Category
err := r.db.First(&category, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("category not found")
}
return nil, err
}
return &category, nil
}
// FindBySlug finds category by slug
func (r *CategoryRepository) FindBySlug(slug string) (*models.Category, error) {
var category models.Category
err := r.db.Where("slug = ?", slug).First(&category).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("category not found")
}
return nil, err
}
return &category, nil
}
// FindAll returns all categories
func (r *CategoryRepository) FindAll() ([]models.Category, error) {
var categories []models.Category
err := r.db.Order("name ASC").Find(&categories).Error
return categories, err
}
// Update updates category data
func (r *CategoryRepository) Update(category *models.Category) error {
return r.db.Save(category).Error
}
// Delete soft deletes a category
func (r *CategoryRepository) Delete(id uint) error {
return r.db.Delete(&models.Category{}, id).Error
}
// GetCategoryWithItemCount gets category with item count
func (r *CategoryRepository) GetCategoryWithItemCount(id uint) (*models.Category, int64, error) {
category, err := r.FindByID(id)
if err != nil {
return nil, 0, err
}
var count int64
if err := r.db.Model(&models.Item{}).Where("category_id = ?", id).Count(&count).Error; err != nil {
return nil, 0, err
}
return category, count, nil
}
// GetAllWithItemCount gets all categories with item counts
func (r *CategoryRepository) GetAllWithItemCount() ([]models.CategoryResponse, error) {
var categories []models.Category
if err := r.db.Order("name ASC").Find(&categories).Error; err != nil {
return nil, err
}
var responses []models.CategoryResponse
for _, cat := range categories {
var count int64
if err := r.db.Model(&models.Item{}).Where("category_id = ?", cat.ID).Count(&count).Error; err != nil {
return nil, err
}
response := cat.ToResponse()
response.ItemCount = count
responses = append(responses, response)
}
return responses, nil
}

View File

@ -0,0 +1,40 @@
package repositories
import (
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type ChatRepository struct {
db *gorm.DB
}
func NewChatRepository(db *gorm.DB) *ChatRepository {
return &ChatRepository{db: db}
}
func (r *ChatRepository) Create(chat *models.ChatMessage) error {
return r.db.Create(chat).Error
}
func (r *ChatRepository) FindByUserID(userID uint, limit int) ([]models.ChatMessage, error) {
var chats []models.ChatMessage
err := r.db.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Find(&chats).Error
return chats, err
}
func (r *ChatRepository) GetUserChatHistory(userID uint, limit int) ([]models.ChatMessage, error) {
var chats []models.ChatMessage
err := r.db.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Find(&chats).Error
return chats, err
}
func (r *ChatRepository) DeleteUserHistory(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&models.ChatMessage{}).Error
}

View File

@ -0,0 +1,166 @@
// internal/repositories/claim_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type ClaimRepository struct {
db *gorm.DB
}
func NewClaimRepository(db *gorm.DB) *ClaimRepository {
return &ClaimRepository{db: db}
}
// Create creates a new claim
func (r *ClaimRepository) Create(claim *models.Claim) error {
return r.db.Create(claim).Error
}
// FindByID finds claim by ID
func (r *ClaimRepository) FindByID(id uint) (*models.Claim, error) {
var claim models.Claim
err := r.db.
Preload("Item").
Preload("Item.Category").
Preload("Item.CaseClosedBy_User"). // ✅ ADD THIS
Preload("Item.CaseClosedBy_User.Role"). // ✅ ADD THIS
Preload("User").
Preload("User.Role").
Preload("Verifier").
Preload("Verifier.Role").
Preload("Verification").
First(&claim, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("claim not found")
}
return nil, err
}
return &claim, nil
}
// FindAll returns all claims with filters
func (r *ClaimRepository) FindAll(page, limit int, status string, itemID, userID *uint) ([]models.Claim, int64, error) {
var claims []models.Claim
var total int64
query := r.db.Model(&models.Claim{})
// Apply filters... (biarkan kode filter yang ada)
if status != "" {
query = query.Where("status = ?", status)
}
if itemID != nil {
query = query.Where("item_id = ?", *itemID)
}
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
// Count total...
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
// ✅ PERBAIKAN: Tambahkan Preload yang hilang di sini
err := query.
Preload("Item").
Preload("Item.Category").
Preload("Item.CaseClosedBy_User"). // <--- TAMBAHKAN INI
Preload("Item.CaseClosedBy_User.Role"). // <--- TAMBAHKAN INI
Preload("User").
Preload("User.Role").
Preload("Verifier").
Preload("Verifier.Role").
Preload("Verification").
Order("created_at DESC").
Offset(offset).Limit(limit).Find(&claims).Error
if err != nil {
return nil, 0, err
}
return claims, total, nil
}
// Update updates claim data
func (r *ClaimRepository) Update(claim *models.Claim) error {
return r.db.Save(claim).Error
}
// Delete soft deletes a claim
func (r *ClaimRepository) Delete(id uint) error {
return r.db.Delete(&models.Claim{}, id).Error
}
// CheckExistingClaim checks if user already claimed an item
func (r *ClaimRepository) CheckExistingClaim(userID, itemID uint) (bool, error) {
var count int64
err := r.db.Model(&models.Claim{}).
Where("user_id = ? AND item_id = ? AND status != ?", userID, itemID, models.ClaimStatusRejected).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// FindByItem finds claims for an item
func (r *ClaimRepository) FindByItem(itemID uint) ([]models.Claim, error) {
var claims []models.Claim
err := r.db.Where("item_id = ?", itemID).
Preload("User").Preload("User.Role").
Preload("Verification").
Order("created_at DESC").Find(&claims).Error
return claims, err
}
// FindByUser finds claims by user
func (r *ClaimRepository) FindByUser(userID uint, page, limit int) ([]models.Claim, int64, error) {
var claims []models.Claim
var total int64
query := r.db.Model(&models.Claim{}).Where("user_id = ?", userID)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * limit
err := query.
Preload("Item").
Preload("Item.Category").
Preload("LostItem"). // ← TAMBAHKAN INI!
Preload("User").
Preload("User.Role").
Preload("Verification").
Order("created_at DESC").
Offset(offset).Limit(limit).Find(&claims).Error
if err != nil {
return nil, 0, err
}
return claims, total, nil
}
// CountByStatus counts claims by status
func (r *ClaimRepository) CountByStatus(status string) (int64, error) {
var count int64
err := r.db.Model(&models.Claim{}).Where("status = ?", status).Count(&count).Error
return count, err
}
// FindPendingClaims finds all pending claims
func (r *ClaimRepository) FindPendingClaims(page, limit int) ([]models.Claim, int64, error) {
return r.FindAll(page, limit, models.ClaimStatusPending, nil, nil)
}
func (r *ClaimRepository) CountAll() (int64, error) {
var count int64
err := r.db.Model(&models.Claim{}).Count(&count).Error
return count, err
}

View File

@ -0,0 +1,67 @@
// internal/repositories/claim_verification_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type ClaimVerificationRepository struct {
db *gorm.DB
}
func NewClaimVerificationRepository(db *gorm.DB) *ClaimVerificationRepository {
return &ClaimVerificationRepository{db: db}
}
// Create creates a new claim verification
func (r *ClaimVerificationRepository) Create(verification *models.ClaimVerification) error {
return r.db.Create(verification).Error
}
// FindByID finds claim verification by ID
func (r *ClaimVerificationRepository) FindByID(id uint) (*models.ClaimVerification, error) {
var verification models.ClaimVerification
err := r.db.Preload("Claim").First(&verification, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("claim verification not found")
}
return nil, err
}
return &verification, nil
}
// FindByClaimID finds claim verification by claim ID
func (r *ClaimVerificationRepository) FindByClaimID(claimID uint) (*models.ClaimVerification, error) {
var verification models.ClaimVerification
err := r.db.Where("claim_id = ?", claimID).Preload("Claim").First(&verification).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // Return nil if not found (not an error)
}
return nil, err
}
return &verification, nil
}
// Update updates claim verification
func (r *ClaimVerificationRepository) Update(verification *models.ClaimVerification) error {
return r.db.Save(verification).Error
}
// Delete deletes a claim verification
func (r *ClaimVerificationRepository) Delete(id uint) error {
return r.db.Delete(&models.ClaimVerification{}, id).Error
}
// FindHighMatches finds high match verifications (>= 70%)
func (r *ClaimVerificationRepository) FindHighMatches() ([]models.ClaimVerification, error) {
var verifications []models.ClaimVerification
err := r.db.Where("similarity_score >= ?", 70.0).
Preload("Claim").Preload("Claim.Item").Preload("Claim.User").
Order("similarity_score DESC").Find(&verifications).Error
return verifications, err
}

View File

@ -0,0 +1,254 @@
// internal/repositories/item_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"time"
"gorm.io/gorm"
)
type ItemRepository struct {
db *gorm.DB
}
func NewItemRepository(db *gorm.DB) *ItemRepository {
return &ItemRepository{db: db}
}
// Create creates a new item
func (r *ItemRepository) Create(item *models.Item) error {
return r.db.Create(item).Error
}
// FindByID finds item by ID
func (r *ItemRepository) FindByID(id uint) (*models.Item, error) {
var item models.Item
err := r.db.
Preload("Category").
Preload("Reporter").
Preload("Reporter.Role").
Preload("CaseClosedBy_User").
Preload("CaseClosedBy_User.Role").
Preload("Claims", "deleted_at IS NULL"). // ✅ TAMBAH INI!
First(&item, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("item not found")
}
return nil, err
}
return &item, nil
}
// ✅ IMPLEMENTASI PROCEDURE 1: Archive Expired Items
// CallArchiveExpiredProcedure memanggil SP sp_archive_expired_items
func (r *ItemRepository) CallArchiveExpiredProcedure() (int, error) {
var archivedCount int
// Menggunakan transaksi untuk eksekusi procedure
err := r.db.Transaction(func(tx *gorm.DB) error {
// 1. Eksekusi Procedure dengan variabel output session MySQL (@count)
if err := tx.Exec("CALL sp_archive_expired_items(@count)").Error; err != nil {
return err
}
// 2. Ambil nilai dari variabel output
// Kita menggunakan Raw SQL karena GORM tidak support OUT param secara native di semua driver
type Result struct {
Count int
}
var res Result
if err := tx.Raw("SELECT @count as count").Scan(&res).Error; err != nil {
return err
}
archivedCount = res.Count
return nil
})
return archivedCount, err
}
// ✅ IMPLEMENTASI PROCEDURE 2: Dashboard Stats
// GetDashboardStatsSP memanggil SP sp_get_dashboard_stats
func (r *ItemRepository) GetDashboardStatsSP() (map[string]int64, error) {
stats := make(map[string]int64)
err := r.db.Transaction(func(tx *gorm.DB) error {
// 1. Eksekusi Procedure dengan 4 variabel output
query := "CALL sp_get_dashboard_stats(@total, @unclaimed, @verified, @pending)"
if err := tx.Exec(query).Error; err != nil {
return err
}
// 2. Select nilai variabel tersebut
type Result struct {
Total int64
Unclaimed int64
Verified int64
Pending int64
}
var res Result
querySelect := "SELECT @total as total, @unclaimed as unclaimed, @verified as verified, @pending as pending"
if err := tx.Raw(querySelect).Scan(&res).Error; err != nil {
return err
}
stats["total_items"] = res.Total
stats["unclaimed_items"] = res.Unclaimed
stats["verified_items"] = res.Verified
stats["pending_claims"] = res.Pending
return nil
})
return stats, err
}
// FindAll returns all items with filters
// internal/repositories/item_repo.go
func (r *ItemRepository) FindAll(page, limit int, status, category, search string) ([]models.Item, int64, error) {
var items []models.Item
var total int64
query := r.db.Model(&models.Item{})
if status != "" {
if status == "!expired" {
query = query.Where("status NOT IN ?", []string{models.ItemStatusExpired, models.ItemStatusCaseClosed})
} else {
query = query.Where("status = ?", status)
}
}
if category != "" {
query = query.Joins("JOIN categories ON categories.id = items.category_id").Where("categories.slug = ?", category)
}
if search != "" {
query = query.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * limit
// ✅ FIX: Tambahkan Preload Claims untuk hitung status dinamis
err := query.
Preload("Category").
Preload("Reporter").
Preload("Reporter.Role").
Preload("Claims", "deleted_at IS NULL"). // ✅ TAMBAH INI!
Order("date_found DESC").
Offset(offset).
Limit(limit).
Find(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// Update updates item data
func (r *ItemRepository) Update(item *models.Item) error {
return r.db.Save(item).Error
}
// UpdateStatus updates item status
func (r *ItemRepository) UpdateStatus(id uint, status string) error {
return r.db.Model(&models.Item{}).Where("id = ?", id).Update("status", status).Error
}
// Delete soft deletes an item
func (r *ItemRepository) Delete(id uint) error {
return r.db.Delete(&models.Item{}, id).Error
}
// FindExpired finds expired items
func (r *ItemRepository) FindExpired() ([]models.Item, error) {
var items []models.Item
now := time.Now()
err := r.db.Where("expires_at <= ? AND status = ?", now, models.ItemStatusUnclaimed).
Preload("Category").Find(&items).Error
return items, err
}
// ArchiveItem moves item to archive
func (r *ItemRepository) ArchiveItem(item *models.Item, reason string, claimedBy *uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Create archive record
archive := models.CreateFromItem(item, reason, claimedBy)
if err := tx.Create(archive).Error; err != nil {
return err
}
// Update item status
if err := tx.Model(item).Updates(map[string]interface{}{
"status": models.ItemStatusExpired,
}).Error; err != nil {
return err
}
return nil
})
}
// CountByStatus counts items by status
func (r *ItemRepository) CountByStatus(status string) (int64, error) {
var count int64
err := r.db.Model(&models.Item{}).Where("status = ?", status).Count(&count).Error
return count, err
}
// FindByReporter finds items by reporter ID
func (r *ItemRepository) FindByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
var items []models.Item
var total int64
query := r.db.Model(&models.Item{}).Where("reporter_id = ?", reporterID)
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("Category").Order("date_found DESC").
Offset(offset).Limit(limit).Find(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// SearchForMatching searches items for matching with lost items
func (r *ItemRepository) SearchForMatching(categoryID uint, name, color string) ([]models.Item, error) {
var items []models.Item
query := r.db.Where("status = ? AND category_id = ?", models.ItemStatusUnclaimed, categoryID)
if name != "" {
query = query.Where("name ILIKE ?", "%"+name+"%")
}
err := query.Preload("Category").Order("date_found DESC").Limit(10).Find(&items).Error
return items, err
}
func (r *ItemRepository) CountAll() (int64, error) {
var count int64
err := r.db.Model(&models.Item{}).Count(&count).Error
return count, err
}

View File

@ -0,0 +1,156 @@
// internal/repositories/lost_item_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type LostItemRepository struct {
db *gorm.DB
}
func NewLostItemRepository(db *gorm.DB) *LostItemRepository {
return &LostItemRepository{db: db}
}
// Create creates a new lost item report
func (r *LostItemRepository) Create(lostItem *models.LostItem) error {
return r.db.Create(lostItem).Error
}
// FindByID finds lost item by ID
func (r *LostItemRepository) FindByID(id uint) (*models.LostItem, error) {
var lostItem models.LostItem
err := r.db.Preload("Category").Preload("User").Preload("User.Role").First(&lostItem, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("lost item not found")
}
return nil, err
}
return &lostItem, nil
}
// ✅ NEW: FindByMatchedItemID (Dibutuhkan untuk VerifyClaim)
// Mencari lost item yang sudah di-match dengan item tertentu
func (r *LostItemRepository) FindByMatchedItemID(itemID uint) (*models.LostItem, error) {
var lostItem models.LostItem
// Asumsi: Anda punya kolom 'matched_item_id' atau logika matching tersimpan
// Jika logika matching ada di tabel 'match_results', query ini mungkin perlu disesuaikan.
// Namun, untuk struktur simple, kita cari berdasarkan relasi match
// Jika Anda menggunakan tabel terpisah (MatchResult), method ini mungkin tidak direct di sini,
// tapi jika LostItem punya field MatchedItemID:
err := r.db.Where("matched_item_id = ?", itemID).First(&lostItem).Error
if err != nil {
return nil, err
}
return &lostItem, nil
}
// FindAll returns all lost items with filters
func (r *LostItemRepository) FindAll(page, limit int, status, category, search string, userID *uint) ([]models.LostItem, int64, error) {
var lostItems []models.LostItem
var total int64
query := r.db.Model(&models.LostItem{})
// Filter by user if specified
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
// Apply filters
if status != "" {
query = query.Where("status = ?", status)
}
if category != "" {
query = query.Joins("JOIN categories ON categories.id = lost_items.category_id").Where("categories.slug = ?", category)
}
if search != "" {
// ✅ FIX: Ganti ILIKE (Postgres) ke LIKE (MySQL Compatible)
// Jika pakai Postgres, ILIKE boleh dipakai. Jika MySQL, harus LIKE.
query = query.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("Category").Preload("User").Preload("User.Role").
Order("date_lost DESC").
Offset(offset).Limit(limit).Find(&lostItems).Error
if err != nil {
return nil, 0, err
}
return lostItems, total, nil
}
// Update updates lost item data
func (r *LostItemRepository) Update(lostItem *models.LostItem) error {
return r.db.Save(lostItem).Error
}
// UpdateStatus updates lost item status
func (r *LostItemRepository) UpdateStatus(id uint, status string) error {
return r.db.Model(&models.LostItem{}).Where("id = ?", id).Update("status", status).Error
}
// ✅ NEW: UpdateStatusByUserAndCategory (Dibutuhkan untuk CloseCase)
// Mengubah status lost item milik user tertentu di kategori tertentu
// Berguna saat item 'Found' berubah jadi 'Closed'
func (r *LostItemRepository) UpdateStatusByUserAndCategory(userID, categoryID uint, oldStatus, newStatus string) error {
return r.db.Model(&models.LostItem{}).
Where("user_id = ? AND category_id = ? AND status = ?", userID, categoryID, oldStatus).
Update("status", newStatus).Error
}
// Delete soft deletes a lost item
func (r *LostItemRepository) Delete(id uint) error {
return r.db.Delete(&models.LostItem{}, id).Error
}
// FindByUser finds lost items by user ID
func (r *LostItemRepository) FindByUser(userID uint, page, limit int) ([]models.LostItem, int64, error) {
var lostItems []models.LostItem
var total int64
query := r.db.Model(&models.LostItem{}).Where("user_id = ?", userID)
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("Category").Order("date_lost DESC").
Offset(offset).Limit(limit).Find(&lostItems).Error
if err != nil {
return nil, 0, err
}
return lostItems, total, nil
}
// CountByStatus counts lost items by status
func (r *LostItemRepository) CountByStatus(status string) (int64, error) {
var count int64
err := r.db.Model(&models.LostItem{}).Where("status = ?", status).Count(&count).Error
return count, err
}
// FindActiveForMatching finds active lost items for matching
func (r *LostItemRepository) FindActiveForMatching(categoryID uint) ([]models.LostItem, error) {
var lostItems []models.LostItem
err := r.db.Where("status = ? AND category_id = ?", models.LostItemStatusActive, categoryID).
Preload("User").Find(&lostItems).Error
return lostItems, err
}

View File

@ -0,0 +1,125 @@
// internal/repositories/match_result_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type MatchResultRepository struct {
db *gorm.DB
}
func NewMatchResultRepository(db *gorm.DB) *MatchResultRepository {
return &MatchResultRepository{db: db}
}
// Create creates a new match result
func (r *MatchResultRepository) Create(match *models.MatchResult) error {
return r.db.Create(match).Error
}
// FindByID finds match result by ID
func (r *MatchResultRepository) FindByID(id uint) (*models.MatchResult, error) {
var match models.MatchResult
err := r.db.Preload("LostItem").Preload("LostItem.User").
Preload("Item").Preload("Item.Category").
First(&match, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("match result not found")
}
return nil, err
}
return &match, nil
}
// FindAll returns all match results with filters
func (r *MatchResultRepository) FindAll(page, limit int, lostItemID, itemID *uint) ([]models.MatchResult, int64, error) {
var matches []models.MatchResult
var total int64
query := r.db.Model(&models.MatchResult{})
// Apply filters
if lostItemID != nil {
query = query.Where("lost_item_id = ?", *lostItemID)
}
if itemID != nil {
query = query.Where("item_id = ?", *itemID)
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("LostItem").Preload("LostItem.User").
Preload("Item").Preload("Item.Category").
Order("similarity_score DESC").
Offset(offset).Limit(limit).Find(&matches).Error
if err != nil {
return nil, 0, err
}
return matches, total, nil
}
// FindByLostItem finds match results for a lost item
func (r *MatchResultRepository) FindByLostItem(lostItemID uint) ([]models.MatchResult, error) {
var matches []models.MatchResult
err := r.db.Where("lost_item_id = ?", lostItemID).
Preload("Item").Preload("Item.Category").
Order("similarity_score DESC").Find(&matches).Error
return matches, err
}
// FindByItem finds match results for an item
func (r *MatchResultRepository) FindByItem(itemID uint) ([]models.MatchResult, error) {
var matches []models.MatchResult
err := r.db.Where("item_id = ?", itemID).
Preload("LostItem").Preload("LostItem.User").
Order("similarity_score DESC").Find(&matches).Error
return matches, err
}
// Update updates match result
func (r *MatchResultRepository) Update(match *models.MatchResult) error {
return r.db.Save(match).Error
}
// MarkAsNotified marks match result as notified
func (r *MatchResultRepository) MarkAsNotified(id uint) error {
return r.db.Model(&models.MatchResult{}).Where("id = ?", id).Update("is_notified", true).Error
}
// FindUnnotifiedMatches finds match results that haven't been notified
func (r *MatchResultRepository) FindUnnotifiedMatches() ([]models.MatchResult, error) {
var matches []models.MatchResult
err := r.db.Where("is_notified = ?", false).
Preload("LostItem").Preload("LostItem.User").
Preload("Item").Preload("Item.Category").
Order("matched_at ASC").Find(&matches).Error
return matches, err
}
// Delete deletes a match result
func (r *MatchResultRepository) Delete(id uint) error {
return r.db.Delete(&models.MatchResult{}, id).Error
}
// CheckExistingMatch checks if a match already exists
func (r *MatchResultRepository) CheckExistingMatch(lostItemID, itemID uint) (bool, error) {
var count int64
err := r.db.Model(&models.MatchResult{}).
Where("lost_item_id = ? AND item_id = ?", lostItemID, itemID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@ -0,0 +1,104 @@
// internal/repositories/notification_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type NotificationRepository struct {
db *gorm.DB
}
func NewNotificationRepository(db *gorm.DB) *NotificationRepository {
return &NotificationRepository{db: db}
}
// Create creates a new notification
func (r *NotificationRepository) Create(notification *models.Notification) error {
return r.db.Create(notification).Error
}
// FindByID finds notification by ID
func (r *NotificationRepository) FindByID(id uint) (*models.Notification, error) {
var notification models.Notification
err := r.db.Preload("User").First(&notification, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("notification not found")
}
return nil, err
}
return &notification, nil
}
// FindByUser finds notifications for a user
func (r *NotificationRepository) FindByUser(userID uint, page, limit int, onlyUnread bool) ([]models.Notification, int64, error) {
var notifications []models.Notification
var total int64
query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID)
// Filter unread if specified
if onlyUnread {
query = query.Where("is_read = ?", false)
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Order("created_at DESC").
Offset(offset).Limit(limit).Find(&notifications).Error
if err != nil {
return nil, 0, err
}
return notifications, total, nil
}
// MarkAsRead marks a notification as read
func (r *NotificationRepository) MarkAsRead(id uint) error {
notification, err := r.FindByID(id)
if err != nil {
return err
}
notification.MarkAsRead()
return r.db.Save(notification).Error
}
// MarkAllAsRead marks all notifications for a user as read
func (r *NotificationRepository) MarkAllAsRead(userID uint) error {
return r.db.Model(&models.Notification{}).
Where("user_id = ? AND is_read = ?", userID, false).
Update("is_read", true).Error
}
// Delete deletes a notification
func (r *NotificationRepository) Delete(id uint) error {
return r.db.Delete(&models.Notification{}, id).Error
}
// DeleteAllForUser deletes all notifications for a user
func (r *NotificationRepository) DeleteAllForUser(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error
}
// CountUnread counts unread notifications for a user
func (r *NotificationRepository) CountUnread(userID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Notification{}).
Where("user_id = ? AND is_read = ?", userID, false).
Count(&count).Error
return count, err
}
// Notify creates a notification (helper method)
func (r *NotificationRepository) Notify(userID uint, notifType, title, message, entityType string, entityID *uint) error {
return models.CreateNotification(r.db, userID, notifType, title, message, entityType, entityID)
}

View File

@ -0,0 +1,93 @@
// internal/repositories/revision_log_repo.go
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type RevisionLogRepository struct {
db *gorm.DB
}
func NewRevisionLogRepository(db *gorm.DB) *RevisionLogRepository {
return &RevisionLogRepository{db: db}
}
// Create creates a new revision log
func (r *RevisionLogRepository) Create(log *models.RevisionLog) error {
return r.db.Create(log).Error
}
// FindByID finds revision log by ID
func (r *RevisionLogRepository) FindByID(id uint) (*models.RevisionLog, error) {
var log models.RevisionLog
err := r.db.Preload("Item").Preload("User").Preload("User.Role").First(&log, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("revision log not found")
}
return nil, err
}
return &log, nil
}
// FindByItem finds revision logs for an item
func (r *RevisionLogRepository) FindByItem(itemID uint, page, limit int) ([]models.RevisionLog, int64, error) {
var logs []models.RevisionLog
var total int64
query := r.db.Model(&models.RevisionLog{}).Where("item_id = ?", itemID)
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("User").Preload("User.Role").
Order("created_at DESC").
Offset(offset).Limit(limit).Find(&logs).Error
if err != nil {
return nil, 0, err
}
return logs, total, nil
}
// FindAll returns all revision logs with filters
func (r *RevisionLogRepository) FindAll(page, limit int, userID *uint) ([]models.RevisionLog, int64, error) {
var logs []models.RevisionLog
var total int64
query := r.db.Model(&models.RevisionLog{})
// Apply filters
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Preload("Item").Preload("User").Preload("User.Role").
Order("created_at DESC").
Offset(offset).Limit(limit).Find(&logs).Error
if err != nil {
return nil, 0, err
}
return logs, total, nil
}
// Log creates a new revision log entry (helper method)
func (r *RevisionLogRepository) Log(itemID, userID uint, fieldName, oldValue, newValue, reason string) error {
return models.CreateRevisionLog(r.db, itemID, userID, fieldName, oldValue, newValue, reason)
}

View File

@ -0,0 +1,90 @@
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type RoleRepository struct {
db *gorm.DB
}
func NewRoleRepository(db *gorm.DB) *RoleRepository {
return &RoleRepository{db: db}
}
// FindAllWithPermissions returns all roles with their permissions (for Role Management)
func (r *RoleRepository) FindAllWithPermissions() ([]models.Role, error) {
var roles []models.Role
err := r.db.Preload("Permissions").Find(&roles).Error
return roles, err
}
// FindAllPermissions gets list of all available permissions
func (r *RoleRepository) FindAllPermissions() ([]models.Permission, error) {
var permissions []models.Permission
err := r.db.Find(&permissions).Error
return permissions, err
}
// Create creates a new role
func (r *RoleRepository) Create(role *models.Role) error {
return r.db.Create(role).Error
}
// FindByID finds role by ID with permissions loaded
func (r *RoleRepository) FindByID(id uint) (*models.Role, error) {
var role models.Role
err := r.db.Preload("Permissions").First(&role, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("role not found")
}
return nil, err
}
return &role, nil
}
// ✅ [PERBAIKAN] Menambahkan kembali method FindByName yang hilang
// FindByName finds role by name (Required by AuthService)
func (r *RoleRepository) FindByName(name string) (*models.Role, error) {
var role models.Role
err := r.db.Where("name = ?", name).First(&role).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("role not found")
}
return nil, err
}
return &role, nil
}
// UpdatePermissions syncs permissions for a role (Update logic)
func (r *RoleRepository) UpdatePermissions(role *models.Role, permissionIDs []uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// 1. Update basic info (name/desc)
if err := tx.Save(role).Error; err != nil {
return err
}
// 2. Fetch permission objects based on IDs
var permissions []models.Permission
if len(permissionIDs) > 0 {
if err := tx.Where("id IN ?", permissionIDs).Find(&permissions).Error; err != nil {
return err
}
}
// 3. Replace associations (Hapus yang lama, set yang baru)
return tx.Model(role).Association("Permissions").Replace(permissions)
})
}
// Delete deletes a role
func (r *RoleRepository) Delete(id uint) error {
// Hapus relasi di role_permissions dulu (GORM biasanya handle ini via foreign key constraint, tapi untuk aman bisa manual)
// Kita gunakan Unscoped atau Select clause jika perlu, tapi standard delete object sudah cukup jika constraint DB benar.
return r.db.Select("Permissions").Delete(&models.Role{ID: id}).Error
}

View File

@ -0,0 +1,41 @@
package repositories
import (
"log"
"gorm.io/gorm"
)
// TransactionManager struct untuk handle manual transaction
type TransactionManager struct {
db *gorm.DB
}
func NewTransactionManager(db *gorm.DB) *TransactionManager {
return &TransactionManager{db: db}
}
// Begin memulai transaksi database
func (m *TransactionManager) Begin() *gorm.DB {
return m.db.Begin()
}
// Rollback membatalkan transaksi jika terjadi error
func (m *TransactionManager) Rollback(tx *gorm.DB) {
// Rollback hanya jika transaksi belum di-commit/rollback
if r := recover(); r != nil {
tx.Rollback()
log.Printf("⚠️ Transaction panicked: %v", r)
} else if tx.Error != nil {
// Jika tx sudah error, rollback
tx.Rollback()
} else {
// Rollback aman
tx.Rollback()
}
}
// Commit menyimpan perubahan permanen ke database
func (m *TransactionManager) Commit(tx *gorm.DB) error {
return tx.Commit().Error
}

View File

@ -0,0 +1,160 @@
// internal/repositories/user_repo.go - FIXED for ENCRYPTED NRP
package repositories
import (
"errors"
"lost-and-found/internal/models"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
var user models.User
// ✅ Tambahkan .Preload("Role.Permissions")
err := r.db.Preload("Role").Preload("Role.Permissions").First(&user, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
var user models.User
// ✅ UPDATE: Preload Role DAN Role.Permissions
err := r.db.
Preload("Role").
Preload("Role.Permissions"). // Memuat daftar hak akses
Where("email = ?", email).
First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("user not found")
}
return nil, err
}
return &user, nil
}
// ✅ FIXED: FindByNRP now accepts ENCRYPTED NRP
func (r *UserRepository) FindByNRP(nrp string) (*models.User, error) {
var user models.User
// ✅ UPDATE: Preload Role DAN Role.Permissions
err := r.db.
Preload("Role").
Preload("Role.Permissions"). // Memuat daftar hak akses
Where("nrp = ?", nrp).
First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // Return nil jika tidak ditemukan (bukan error sistem)
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) FindAll(page, limit int) ([]models.User, int64, error) {
var users []models.User
var total int64
if err := r.db.Model(&models.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * limit
err := r.db.
Preload("Role"). // Cukup Role saja untuk list view
Order("created_at DESC").
Offset(offset).Limit(limit).
Find(&users).Error
if err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *UserRepository) Update(user *models.User) error {
return r.db.Save(user).Error
}
func (r *UserRepository) UpdateRole(userID, roleID uint) error {
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("role_id", roleID).Error
}
func (r *UserRepository) UpdateStatus(userID uint, status string) error {
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("status", status).Error
}
func (r *UserRepository) Delete(id uint) error {
return r.db.Delete(&models.User{}, id).Error
}
func (r *UserRepository) BlockUser(id uint) error {
return r.UpdateStatus(id, "blocked")
}
func (r *UserRepository) UnblockUser(id uint) error {
return r.UpdateStatus(id, "active")
}
func (r *UserRepository) CountByRole(roleID uint) (int64, error) {
var count int64
err := r.db.Model(&models.User{}).Where("role_id = ?", roleID).Count(&count).Error
return count, err
}
func (r *UserRepository) GetUserStats(userID uint) (map[string]interface{}, error) {
var stats map[string]interface{} = make(map[string]interface{})
// Items reported
var itemCount int64
if err := r.db.Model(&models.Item{}).
Where("reporter_id = ?", userID).Count(&itemCount).Error; err != nil {
return nil, err
}
stats["items_reported"] = itemCount
// Lost items
var lostItemCount int64
if err := r.db.Model(&models.LostItem{}).
Where("user_id = ?", userID).Count(&lostItemCount).Error; err != nil {
return nil, err
}
stats["lost_items_reported"] = lostItemCount
// Claims
var claimCount int64
if err := r.db.Model(&models.Claim{}).
Where("user_id = ?", userID).Count(&claimCount).Error; err != nil {
return nil, err
}
stats["claims_made"] = claimCount
// Approved claims
var approvedClaimCount int64
if err := r.db.Model(&models.Claim{}).
Where("user_id = ? AND status = ?", userID, models.ClaimStatusApproved).
Count(&approvedClaimCount).Error; err != nil {
return nil, err
}
stats["claims_approved"] = approvedClaimCount
return stats, nil
}

294
internal/routes/routes.go Normal file
View File

@ -0,0 +1,294 @@
package routes
import (
"lost-and-found/internal/controllers"
"lost-and-found/internal/middleware"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
// SetupRoutes configures all application routes
func SetupRoutes(router *gin.Engine, db *gorm.DB, logger *zap.Logger) {
// Initialize controllers
authController := controllers.NewAuthController(db, logger)
userController := controllers.NewUserController(db)
itemController := controllers.NewItemController(db)
lostItemController := controllers.NewLostItemController(db)
claimController := controllers.NewClaimController(db)
matchController := controllers.NewMatchController(db)
categoryController := controllers.NewCategoryController(db)
archiveController := controllers.NewArchiveController(db)
adminController := controllers.NewAdminController(db)
reportController := controllers.NewReportController(db)
uploadController := controllers.NewUploadController(db)
managerController := controllers.NewManagerController(db)
roleController := controllers.NewRoleController(db)
notificationController := controllers.NewNotificationController(db)
aiController := controllers.NewAIController(db)
// API group
api := router.Group("/api")
{
// ==========================================
// 1. Public Routes (No Auth)
// ==========================================
api.POST("/register", authController.Register)
api.POST("/login", authController.Login)
api.POST("/refresh-token", authController.RefreshToken)
api.GET("/categories", categoryController.GetAllCategories)
api.GET("/categories/:id", categoryController.GetCategoryByID)
// Optional Auth for Items (Public can view, Manager sees details)
itemsGroup := api.Group("/items")
itemsGroup.Use(middleware.OptionalJWTMiddleware(db))
{
itemsGroup.GET("", itemController.GetAllItems)
itemsGroup.GET("/:id", itemController.GetItemByID)
}
// ==========================================
// 2. Authenticated Routes (Basic User Access)
// ==========================================
// Middleware: Cek Token Valid & User Active
authenticated := api.Group("")
authenticated.Use(middleware.JWTMiddleware(db))
{
authenticated.POST("/ai/chat", aiController.Chat)
authenticated.GET("/ai/history", aiController.GetHistory)
authenticated.DELETE("/ai/history", aiController.ClearHistory)
// Profile (Siapapun yang login bisa akses ini)
authenticated.GET("/me", authController.GetMe)
authenticated.GET("/user/profile", userController.GetProfile)
authenticated.PUT("/user/profile", userController.UpdateProfile)
authenticated.POST("/user/change-password", userController.ChangePassword)
authenticated.GET("/user/stats", userController.GetStats)
authenticated.POST("/lost-items/:id/direct-claim", lostItemController.DirectClaimToOwner)
authenticated.POST("/user/lost-items/:id/direct-claim", lostItemController.DirectClaimToOwner)
authenticated.POST("/claims/:id/respond", claimController.UserApproveClaim)
authenticated.POST("/claims/:id/complete", claimController.UserConfirmCompletion)
// --- User Features (Permission: item:create) ---
authenticated.POST("/items",
middleware.RequirePermission("item:create"),
itemController.CreateItem)
authenticated.GET("/user/items",
middleware.RequirePermission("item:read"),
itemController.GetItemsByReporter)
// --- Lost Items (Permission: item:create) ---
// Asumsi: Hak akses lapor kehilangan sama dengan lapor temuan
authenticated.POST("/lost-items",
middleware.RequirePermission("item:create"),
lostItemController.CreateLostItem)
authenticated.GET("/user/lost-items", lostItemController.GetLostItemsByUser)
authenticated.GET("/lost-items", lostItemController.GetAllLostItems)
authenticated.GET("/lost-items/:id", lostItemController.GetLostItemByID)
authenticated.PUT("/lost-items/:id", lostItemController.UpdateLostItem)
authenticated.PATCH("/lost-items/:id/status", lostItemController.UpdateLostItemStatus)
// ✅ FIX: Delete Lost Item menggunakan controller yang benar
authenticated.DELETE("/lost-items/:id",
middleware.RequirePermission("lost_item:delete"), // Permission baru
lostItemController.DeleteLostItem)
// --- Claims (Permission: claim:create) ---
authenticated.POST("/claims",
middleware.RequirePermission("claim:create"),
middleware.IdempotencyMiddleware(), // Prevent double submit
claimController.CreateClaim)
authenticated.GET("/user/claims",
middleware.RequirePermission("claim:read"),
claimController.GetClaimsByUser)
// ✅ FIX: Tambahkan endpoint update dan delete claim user
authenticated.PUT("/claims/:id",
middleware.RequirePermission("claim:create"), // User boleh edit claim sendiri
claimController.UpdateClaim)
authenticated.DELETE("/claims/:id",
middleware.RequirePermission("claim:create"), // User boleh delete claim sendiri
claimController.DeleteClaim)
// --- Matching ---
authenticated.GET("/lost-items/:id/matches", matchController.GetMatchesForLostItem)
authenticated.POST("/lost-items/:id/find-similar", matchController.FindSimilarItems)
// --- Notifications ---
authenticated.GET("/notifications", notificationController.GetUserNotifications)
authenticated.PATCH("/notifications/:id/read", notificationController.MarkAsRead)
authenticated.PATCH("/notifications/read-all", notificationController.MarkAllAsRead)
authenticated.POST("/user/claims/:id/respond", claimController.UserApproveClaim)
authenticated.POST("/user/claims/:id/complete", claimController.UserConfirmCompletion)
// --- Uploads ---
upload := authenticated.Group("/upload")
{
upload.POST("/item-image", uploadController.UploadItemImage)
upload.POST("/claim-proof", uploadController.UploadClaimProof)
upload.POST("/multiple", uploadController.UploadMultipleImages)
upload.DELETE("/delete", uploadController.DeleteImage)
upload.GET("/info", uploadController.GetImageInfo)
}
}
// ==========================================
// 3. Protected Management Routes (Permission Based)
// ==========================================
// Area ini menggantikan Group Manager/Admin yang lama.
// Sekarang dikelompokkan berdasarkan FITUR, bukan ROLE.
management := api.Group("")
management.Use(middleware.JWTMiddleware(db))
{
// --- ITEM MANAGEMENT (Permission: item:update, item:delete) ---
management.PUT("/items/:id",
middleware.RequirePermission("item:update"),
itemController.UpdateItem)
management.PATCH("/items/:id/status",
middleware.RequirePermission("item:update"),
itemController.UpdateItemStatus)
management.DELETE("/items/:id",
middleware.RequirePermission("item:delete"),
itemController.DeleteItem)
management.GET("/items/:id/revisions",
middleware.RequirePermission("item:verify"),
itemController.GetItemRevisionHistory)
management.GET("/items/:id/matches",
middleware.RequirePermission("item:verify"),
matchController.GetMatchesForItem)
// --- CLAIM VERIFICATION (Permission: claim:approve, claim:reject) ---
management.GET("/claims",
middleware.RequirePermission("claim:read"), // Manager view all claims
claimController.GetAllClaims)
management.GET("/claims/:id",
middleware.RequirePermission("claim:read"),
claimController.GetClaimByID)
management.POST("/claims/:id/verify",
middleware.RequirePermission("claim:approve"),
middleware.IdempotencyMiddleware(),
claimController.VerifyClaim)
management.GET("/claims/:id/verification",
middleware.RequirePermission("claim:read"),
claimController.GetClaimVerification)
// ✅ FIX: Ganti endpoint CloseClaim -> CloseCase
management.POST("/claims/:id/close",
middleware.RequirePermission("claim:approve"),
claimController.CloseCase)
// ✅ FIX: Ganti endpoint ReopenClaim -> ReopenCase
management.POST("/claims/:id/reopen",
middleware.RequirePermission("claim:approve"),
claimController.ReopenCase)
// ✅ FIX: Ganti endpoint CancelApproval -> CancelClaimApproval
management.POST("/claims/:id/cancel-approval",
middleware.RequirePermission("claim:approve"),
claimController.CancelClaimApproval)
// --- ARCHIVES (Permission: item:read - for historical data) ---
management.GET("/archives",
middleware.RequirePermission("item:read"),
archiveController.GetAllArchives)
management.GET("/archives/:id",
middleware.RequirePermission("item:read"),
archiveController.GetArchiveByID)
management.GET("/archives/stats",
middleware.RequirePermission("item:read"),
archiveController.GetArchiveStats)
// --- REPORTS (Permission: report:export) ---
management.POST("/reports/export",
middleware.RequirePermission("report:export"),
reportController.ExportReport)
// --- DASHBOARD (Permission: user:read - as proxy for dashboard access) ---
management.GET("/manager/dashboard",
middleware.RequirePermission("item:verify"),
managerController.GetDashboardStats)
management.GET("/admin/dashboard",
middleware.RequirePermission("user:read"),
adminController.GetDashboardStats)
// --- USER MANAGEMENT (Permission: user:read, user:update, user:block) ---
// Biasa dilakukan oleh Admin
management.GET("/admin/users",
middleware.RequirePermission("user:read"),
userController.GetAllUsers)
management.GET("/admin/users/:id",
middleware.RequirePermission("user:read"),
userController.GetUserByID)
management.PATCH("/admin/users/:id/role",
middleware.RequirePermission("user:update"),
userController.UpdateUserRole)
management.POST("/admin/users/:id/block",
middleware.RequirePermission("user:block"),
userController.BlockUser)
management.POST("/admin/users/:id/unblock",
middleware.RequirePermission("user:block"),
userController.UnblockUser)
management.DELETE("/admin/users/:id",
middleware.RequirePermission("user:block"), // Menggunakan permission block untuk delete
userController.DeleteUser)
// --- AUDIT LOGS (Permission: user:read - biasanya Admin) ---
management.GET("/admin/audit-logs",
middleware.RequirePermission("user:read"),
adminController.GetAuditLogs)
// --- CATEGORY MANAGEMENT (Permission: item:update - Admin Only usually) ---
// Note: Anda mungkin perlu menambahkan permission 'category:create' di seed.sql
// Untuk sekarang kita gunakan 'item:update' atau 'user:update' sebagai proxy
management.POST("/categories",
middleware.RequirePermission("user:update"),
categoryController.CreateCategory)
management.PUT("/categories/:id",
middleware.RequirePermission("user:update"),
categoryController.UpdateCategory)
management.DELETE("/categories/:id",
middleware.RequirePermission("user:update"),
categoryController.DeleteCategory)
// ✅ Endpoint baru untuk test Procedure SQL
management.POST("/admin/archive/trigger",
middleware.RequireRole("admin"), // Hanya admin
adminController.TriggerAutoArchive)
management.GET("/admin/dashboard/fast",
middleware.RequireRole("admin", "manager"),
adminController.GetFastDashboardStats)
management.GET("/admin/roles", middleware.RequireRole("admin"), roleController.GetRoles)
management.GET("/admin/permissions", middleware.RequireRole("admin"), roleController.GetPermissions)
management.POST("/admin/roles", middleware.RequireRole("admin"), roleController.CreateRole)
management.PUT("/admin/roles/:id", middleware.RequireRole("admin"), roleController.UpdateRole)
management.DELETE("/admin/roles/:id", middleware.RequireRole("admin"), roleController.DeleteRole)
}
}
}

View File

@ -0,0 +1,300 @@
package services
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"net/http"
"os"
"strings"
"gorm.io/gorm"
)
type AIService struct {
db *gorm.DB
chatRepo *repositories.ChatRepository
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository
groqAPIKey string
groqModel string
}
func NewAIService(db *gorm.DB) *AIService {
model := os.Getenv("GROQ_MODEL")
if model == "" {
model = "llama-3.3-70b-versatile" // Default model
}
return &AIService{
db: db,
chatRepo: repositories.NewChatRepository(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db),
groqAPIKey: os.Getenv("GROQ_API_KEY"),
groqModel: model,
}
}
type ChatRequest struct {
Message string `json:"message" binding:"required"`
}
// Groq API Request Structure
type GroqRequest struct {
Model string `json:"model"`
Messages []GroqMessage `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream"`
}
type GroqMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Groq API Response Structure
type GroqResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func (s *AIService) ProcessChat(userID uint, message string) (*models.ChatMessage, error) {
// Build context from user data
context, err := s.buildUserContext(userID, message)
if err != nil {
return nil, err
}
// Detect intent
intent := s.detectIntent(message)
// Build prompt with context
systemPrompt := s.buildSystemPrompt()
userPrompt := s.buildUserPrompt(message, context, intent)
// Call Groq API
response, err := s.callGroqAPI(systemPrompt, userPrompt)
if err != nil {
return nil, err
}
// Save to database
chat := &models.ChatMessage{
UserID: userID,
Message: message,
Response: response,
Intent: intent,
ConfidenceScore: 85.0,
}
if err := s.chatRepo.Create(chat); err != nil {
return nil, err
}
return chat, nil
}
func (s *AIService) buildUserContext(userID uint, message string) (string, error) {
var context strings.Builder
// Get user's lost items
lostItems, _, _ := s.lostItemRepo.FindByUser(userID, 1, 5)
if len(lostItems) > 0 {
context.WriteString("\n📋 Barang yang dilaporkan hilang:\n")
for _, item := range lostItems {
context.WriteString(fmt.Sprintf("- %s (%s) - Status: %s\n",
item.Name, item.Category.Name, item.Status))
}
}
// Search for relevant found items if user is looking for something
if strings.Contains(strings.ToLower(message), "cari") ||
strings.Contains(strings.ToLower(message), "temukan") {
items, _, _ := s.itemRepo.FindAll(1, 5, "unclaimed", "", message)
if len(items) > 0 {
context.WriteString("\n🔍 Barang ditemukan yang relevan:\n")
for _, item := range items {
context.WriteString(fmt.Sprintf("- ID: %d, %s (%s) - Lokasi: %s\n",
item.ID, item.Name, item.Category.Name, item.Location))
}
}
}
return context.String(), nil
}
func (s *AIService) detectIntent(message string) string {
msgLower := strings.ToLower(message)
searchKeywords := []string{"cari", "temukan", "ada", "lihat", "ditemukan"}
reportKeywords := []string{"hilang", "kehilangan", "lapor", "laporkan"}
claimKeywords := []string{"klaim", "ambil", "punya saya", "milik saya"}
for _, kw := range searchKeywords {
if strings.Contains(msgLower, kw) {
return models.IntentSearchItem
}
}
for _, kw := range reportKeywords {
if strings.Contains(msgLower, kw) {
return models.IntentReportLost
}
}
for _, kw := range claimKeywords {
if strings.Contains(msgLower, kw) {
return models.IntentClaimHelp
}
}
return models.IntentGeneral
}
func (s *AIService) buildSystemPrompt() string {
return `Kamu adalah asisten AI untuk sistem Lost & Found kampus bernama "FindItBot".
Tugasmu adalah membantu mahasiswa dan staff dengan:
1. 🔍 Mencari barang yang hilang/ditemukan
2. 📝 Memandu proses pelaporan barang hilang
3. Menjelaskan proses klaim barang
4. Menjawab pertanyaan umum tentang sistem
Aturan penting:
- Jawab dengan ramah, profesional, dan membantu
- Gunakan Bahasa Indonesia yang jelas
- Jika ada data barang yang relevan, sebutkan ID dan detailnya
- Untuk pelaporan, tanyakan: nama barang, kategori, lokasi, tanggal hilang, deskripsi
- Untuk klaim, jelaskan proses verifikasi yang diperlukan
- Gunakan emoji yang sesuai untuk memperjelas informasi
- Prioritaskan informasi dari konteks yang diberikan
Contoh respons yang baik:
"🔍 Saya menemukan 2 barang yang mungkin cocok:
1. ID: 123 - Dompet Kulit (Kategori: Wallet) - Ditemukan di Perpustakaan
2. ID: 124 - Dompet Hitam (Kategori: Wallet) - Ditemukan di Kantin
Apakah salah satu dari ini milik Anda? Anda bisa klaim dengan menyebutkan ID barangnya."`
}
func (s *AIService) buildUserPrompt(message, context, intent string) string {
var prompt strings.Builder
if context != "" {
prompt.WriteString("KONTEKS PENGGUNA:\n")
prompt.WriteString(context)
prompt.WriteString("\n\n")
}
prompt.WriteString(fmt.Sprintf("INTENT TERDETEKSI: %s\n\n", intent))
prompt.WriteString(fmt.Sprintf("PERTANYAAN: %s\n\n", message))
prompt.WriteString("Berikan respons yang membantu berdasarkan konteks di atas.")
return prompt.String()
}
func (s *AIService) callGroqAPI(systemPrompt, userPrompt string) (string, error) {
if s.groqAPIKey == "" {
return "", errors.New("GROQ_API_KEY not configured")
}
url := "https://api.groq.com/openai/v1/chat/completions"
reqBody := GroqRequest{
Model: s.groqModel,
Messages: []GroqMessage{
{
Role: "system",
Content: systemPrompt,
},
{
Role: "user",
Content: userPrompt,
},
},
Temperature: 0.7,
MaxTokens: 1024,
TopP: 0.95,
Stream: false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %v", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+s.groqAPIKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to call Groq API: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %v", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Groq API error (status %d): %s", resp.StatusCode, string(body))
}
var groqResp GroqResponse
if err := json.Unmarshal(body, &groqResp); err != nil {
return "", fmt.Errorf("failed to parse response: %v", err)
}
if len(groqResp.Choices) == 0 {
return "", errors.New("no response from Groq API")
}
return groqResp.Choices[0].Message.Content, nil
}
func (s *AIService) GetChatHistory(userID uint, limit int) ([]models.ChatMessageResponse, error) {
chats, err := s.chatRepo.GetUserChatHistory(userID, limit)
if err != nil {
return nil, err
}
var responses []models.ChatMessageResponse
for _, chat := range chats {
responses = append(responses, chat.ToResponse())
}
return responses, nil
}
func (s *AIService) ClearChatHistory(userID uint) error {
return s.chatRepo.DeleteUserHistory(userID)
}

View File

@ -0,0 +1,68 @@
// internal/services/archive_service.go
package services
import (
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type ArchiveService struct {
archiveRepo *repositories.ArchiveRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewArchiveService(db *gorm.DB) *ArchiveService {
return &ArchiveService{
archiveRepo: repositories.NewArchiveRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// GetAllArchives gets all archived items
func (s *ArchiveService) GetAllArchives(page, limit int, reason, search string) ([]models.ArchiveResponse, int64, error) {
archives, total, err := s.archiveRepo.FindAll(page, limit, reason, search)
if err != nil {
return nil, 0, err
}
var responses []models.ArchiveResponse
for _, archive := range archives {
responses = append(responses, archive.ToResponse())
}
return responses, total, nil
}
// GetArchiveByID gets archive by ID
func (s *ArchiveService) GetArchiveByID(id uint) (*models.Archive, error) {
return s.archiveRepo.FindByID(id)
}
// GetArchiveByItemID gets archive by original item ID
func (s *ArchiveService) GetArchiveByItemID(itemID uint) (*models.Archive, error) {
return s.archiveRepo.FindByItemID(itemID)
}
// GetArchiveStats gets archive statistics
func (s *ArchiveService) GetArchiveStats() (map[string]interface{}, error) {
stats := make(map[string]interface{})
// Count by reason
expiredCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonExpired)
if err != nil {
return nil, err
}
stats["expired"] = expiredCount
caseClosedCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonCaseClosed)
if err != nil {
return nil, err
}
stats["case_closed"] = caseClosedCount
stats["total"] = expiredCount + caseClosedCount
return stats, nil
}

View File

@ -0,0 +1,69 @@
// internal/services/audit_service.go
package services
import (
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type AuditService struct {
auditLogRepo *repositories.AuditLogRepository
}
func NewAuditService(db *gorm.DB) *AuditService {
return &AuditService{
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// GetAllAuditLogs gets all audit logs
func (s *AuditService) GetAllAuditLogs(page, limit int, action, entityType string, userID *uint) ([]models.AuditLogResponse, int64, error) {
logs, total, err := s.auditLogRepo.FindAll(page, limit, action, entityType, userID)
if err != nil {
return nil, 0, err
}
var responses []models.AuditLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
// GetAuditLogsByUser gets audit logs by user
func (s *AuditService) GetAuditLogsByUser(userID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
logs, total, err := s.auditLogRepo.FindByUser(userID, page, limit)
if err != nil {
return nil, 0, err
}
var responses []models.AuditLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
// GetAuditLogsByEntity gets audit logs by entity
func (s *AuditService) GetAuditLogsByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
logs, total, err := s.auditLogRepo.FindByEntity(entityType, entityID, page, limit)
if err != nil {
return nil, 0, err
}
var responses []models.AuditLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}
// LogAction creates a new audit log entry
func (s *AuditService) LogAction(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
return s.auditLogRepo.Log(userID, action, entityType, entityID, details, ipAddress, userAgent)
}

View File

@ -0,0 +1,286 @@
package services
import (
"errors"
"lost-and-found/internal/config"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
// --- 1. Definisi Interface (Contract) ---
// Interface ini mendefinisikan method apa saja yang dibutuhkan oleh Service dari Repository.
// Dengan ini, kita bisa menukar Repository asli dengan Mock Repository saat testing.
type IUserRepository interface {
FindByEmail(email string) (*models.User, error)
FindByNRP(nrp string) (*models.User, error)
FindByID(id uint) (*models.User, error)
Create(user *models.User) error
}
type IRoleRepository interface {
FindByName(name string) (*models.Role, error)
}
type IAuditLogRepository interface {
Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error
}
// --- 2. Struct AuthService dengan Dependency Injection ---
type AuthService struct {
userRepo IUserRepository // Menggunakan Interface, bukan struct konkret *repositories.UserRepository
roleRepo IRoleRepository // Menggunakan Interface
auditLogRepo IAuditLogRepository // Menggunakan Interface
logger *zap.Logger
}
// NewAuthService menginisialisasi service dengan repository asli (untuk Production)
func NewAuthService(db *gorm.DB, logger *zap.Logger) *AuthService {
// ✅ Inisialisasi Enkripsi (Bonus Keamanan)
if err := utils.InitEncryption(); err != nil {
logger.Fatal("Failed to initialize encryption", zap.Error(err))
}
return &AuthService{
// Struct repository asli secara otomatis memenuhi interface (duck typing)
userRepo: repositories.NewUserRepository(db),
roleRepo: repositories.NewRoleRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
logger: logger,
}
}
// --- 3. Struct Request & Response ---
type RegisterRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
NRP string `json:"nrp"`
Phone string `json:"phone"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User models.UserResponse `json:"user"`
}
// --- 4. Implementasi Method Service ---
// Register menangani pendaftaran user baru
func (s *AuthService) Register(req RegisterRequest, ipAddress, userAgent string) (*AuthResponse, error) {
s.logger.Info("Registration attempt",
zap.String("email", req.Email),
zap.String("name", req.Name),
zap.String("ip_address", ipAddress),
)
// Cek apakah email sudah terdaftar
existingUser, _ := s.userRepo.FindByEmail(req.Email)
if existingUser != nil {
s.logger.Warn("Registration failed: email already exists",
zap.String("email", req.Email),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("email already registered")
}
// Cek apakah NRP sudah terdaftar (jika ada)
if req.NRP != "" {
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
if existingNRP != nil {
s.logger.Warn("Registration failed: NRP already exists",
zap.String("email", req.Email),
)
return nil, errors.New("NRP already registered")
}
}
// Hash password
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
s.logger.Error("Failed to hash password",
zap.String("email", req.Email),
zap.Error(err),
)
return nil, errors.New("failed to hash password")
}
// Ambil role default "user"
userRole, err := s.roleRepo.FindByName(models.RoleUser)
if err != nil {
s.logger.Error("Failed to get user role", zap.Error(err))
return nil, errors.New("failed to get user role")
}
// Buat objek User
user := &models.User{
Name: req.Name,
Email: req.Email,
Password: hashedPassword,
NRP: req.NRP, // Disimpan plain text (atau terenkripsi jika model mendukung hook)
Phone: req.Phone, // Disimpan plain text
RoleID: userRole.ID,
Status: "active",
}
// Simpan ke database
if err := s.userRepo.Create(user); err != nil {
s.logger.Error("Failed to create user",
zap.String("email", req.Email),
zap.Error(err),
)
return nil, errors.New("failed to create user")
}
// Muat ulang user untuk mendapatkan relasi Role yang lengkap
user, err = s.userRepo.FindByID(user.ID)
if err != nil {
s.logger.Error("Failed to load user",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
return nil, err
}
// Generate JWT Token
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
if err != nil {
s.logger.Error("Failed to generate token",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
return nil, errors.New("failed to generate token")
}
// Log Audit
s.auditLogRepo.Log(&user.ID, models.ActionCreate, models.EntityUser, &user.ID,
"User registered", ipAddress, userAgent)
s.logger.Info("Registration successful",
zap.Uint("user_id", user.ID),
zap.String("email", user.Email),
zap.String("ip_address", ipAddress),
)
return &AuthResponse{
Token: token,
User: user.ToResponse(),
}, nil
}
// Login menangani autentikasi user
func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) {
s.logger.Info("Login attempt",
zap.String("email", req.Email),
zap.String("ip_address", ipAddress),
)
// Cari user berdasarkan email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
s.logger.Warn("Login failed: user not found",
zap.String("email", req.Email),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("invalid email or password")
}
// Cek apakah akun diblokir
if user.IsBlocked() {
s.logger.Warn("Login failed: account blocked",
zap.String("email", user.Email),
zap.Uint("user_id", user.ID),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("account is blocked")
}
// Verifikasi password
passwordMatch := utils.CheckPasswordHash(req.Password, user.Password)
if !passwordMatch {
s.logger.Warn("Login failed: incorrect password",
zap.String("email", user.Email),
zap.Uint("user_id", user.ID),
zap.String("ip_address", ipAddress),
)
return nil, errors.New("invalid email or password")
}
// Pastikan Role ter-load (Reload jika perlu)
if user.Role.ID == 0 {
user, err = s.userRepo.FindByID(user.ID)
if err != nil {
return nil, errors.New("failed to load user data")
}
}
// Generate JWT Token
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
if err != nil {
s.logger.Error("Failed to generate token",
zap.Uint("user_id", user.ID),
zap.Error(err),
)
return nil, errors.New("failed to generate token")
}
// Log Audit
s.auditLogRepo.Log(&user.ID, models.ActionLogin, models.EntityUser, &user.ID,
"User logged in", ipAddress, userAgent)
s.logger.Info("Login successful",
zap.Uint("user_id", user.ID),
zap.String("email", user.Email),
zap.String("ip_address", ipAddress),
)
return &AuthResponse{
Token: token,
User: user.ToResponse(),
}, nil
}
// ValidateToken memvalidasi token JWT dan mengembalikan user terkait
func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) {
claims, err := config.ValidateToken(tokenString)
if err != nil {
s.logger.Warn("Token validation failed", zap.Error(err))
return nil, errors.New("invalid token")
}
user, err := s.userRepo.FindByID(claims.UserID)
if err != nil {
return nil, errors.New("user not found")
}
if user.IsBlocked() {
return nil, errors.New("account is blocked")
}
return user, nil
}
// RefreshToken memperbarui token JWT yang sudah ada
func (s *AuthService) RefreshToken(oldToken string) (string, error) {
s.logger.Info("Token refresh attempt")
newToken, err := config.RefreshToken(oldToken)
if err != nil {
s.logger.Error("Token refresh failed", zap.Error(err))
return "", err
}
s.logger.Info("Token refreshed successfully")
return newToken, nil
}

View File

@ -0,0 +1,148 @@
// internal/services/category_service.go
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"strings"
"gorm.io/gorm"
)
type CategoryService struct {
categoryRepo *repositories.CategoryRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewCategoryService(db *gorm.DB) *CategoryService {
return &CategoryService{
categoryRepo: repositories.NewCategoryRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// CreateCategoryRequest represents category creation data
type CreateCategoryRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
// UpdateCategoryRequest represents category update data
type UpdateCategoryRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
// GetAllCategories gets all categories
func (s *CategoryService) GetAllCategories() ([]models.CategoryResponse, error) {
return s.categoryRepo.GetAllWithItemCount()
}
// GetCategoryByID gets category by ID
func (s *CategoryService) GetCategoryByID(id uint) (*models.Category, error) {
return s.categoryRepo.FindByID(id)
}
// GetCategoryBySlug gets category by slug
func (s *CategoryService) GetCategoryBySlug(slug string) (*models.Category, error) {
return s.categoryRepo.FindBySlug(slug)
}
// CreateCategory creates a new category (admin only)
func (s *CategoryService) CreateCategory(adminID uint, req CreateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
// Generate slug from name
slug := s.generateSlug(req.Name)
// Check if slug already exists
existing, _ := s.categoryRepo.FindBySlug(slug)
if existing != nil {
return nil, errors.New("category with similar name already exists")
}
category := &models.Category{
Name: req.Name,
Slug: slug,
Description: req.Description,
}
if err := s.categoryRepo.Create(category); err != nil {
return nil, errors.New("failed to create category")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionCreate, models.EntityCategory, &category.ID,
"Category created: "+category.Name, ipAddress, userAgent)
return category, nil
}
// UpdateCategory updates a category (admin only)
func (s *CategoryService) UpdateCategory(adminID, categoryID uint, req UpdateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
category, err := s.categoryRepo.FindByID(categoryID)
if err != nil {
return nil, err
}
// Update fields
if req.Name != "" {
category.Name = req.Name
category.Slug = s.generateSlug(req.Name)
}
if req.Description != "" {
category.Description = req.Description
}
if err := s.categoryRepo.Update(category); err != nil {
return nil, errors.New("failed to update category")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityCategory, &categoryID,
"Category updated: "+category.Name, ipAddress, userAgent)
return category, nil
}
// DeleteCategory deletes a category (admin only)
func (s *CategoryService) DeleteCategory(adminID, categoryID uint, ipAddress, userAgent string) error {
category, err := s.categoryRepo.FindByID(categoryID)
if err != nil {
return err
}
// Check if category has items
_, count, err := s.categoryRepo.GetCategoryWithItemCount(categoryID)
if err != nil {
return err
}
if count > 0 {
return errors.New("cannot delete category with existing items")
}
if err := s.categoryRepo.Delete(categoryID); err != nil {
return errors.New("failed to delete category")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityCategory, &categoryID,
"Category deleted: "+category.Name, ipAddress, userAgent)
return nil
}
// generateSlug generates URL-friendly slug from name
func (s *CategoryService) generateSlug(name string) string {
slug := strings.ToLower(name)
slug = strings.ReplaceAll(slug, " ", "_")
slug = strings.ReplaceAll(slug, "/", "_")
// Remove special characters
validChars := "abcdefghijklmnopqrstuvwxyz0123456789_"
result := ""
for _, char := range slug {
if strings.ContainsRune(validChars, char) {
result += string(char)
}
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,276 @@
// internal/services/dashboard_service.go
package services
import (
"context"
"time"
"gorm.io/gorm"
"lost-and-found/internal/repositories"
)
// ✅ KRITERIA BASDAT: Menggunakan VIEWS dari enhancement.sql
type DashboardService struct {
db *gorm.DB
itemRepo *repositories.ItemRepository // ✅ Tambahkan ini
}
func NewDashboardService(db *gorm.DB) *DashboardService {
return &DashboardService{
db: db,
itemRepo: repositories.NewItemRepository(db), // ✅ Inisialisasi
}
}
// DashboardStats menggunakan VIEW vw_dashboard_stats
type DashboardStats struct {
TotalUnclaimed int64 `json:"total_unclaimed"`
TotalVerified int64 `json:"total_verified"`
TotalLostReports int64 `json:"total_lost_reports"`
PendingClaims int64 `json:"pending_claims"`
UnnotifiedMatches int64 `json:"unnotified_matches"`
}
// GetDashboardStats - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetDashboardStats() (*DashboardStats, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var stats DashboardStats
// ✅ QUERY VIEW vw_dashboard_stats (dari enhancement.sql)
err := s.db.WithContext(ctx).
Table("vw_dashboard_stats").
Select("*").
Scan(&stats).Error
if err != nil {
return nil, err
}
return &stats, nil
}
// ItemDetail menggunakan VIEW vw_items_detail
type ItemDetail struct {
ID uint `json:"id"`
Name string `json:"name"`
CategoryName string `json:"category_name"`
CategorySlug string `json:"category_slug"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
DateFound time.Time `json:"date_found"`
Status string `json:"status"`
ReporterName string `json:"reporter_name"`
ReporterContact string `json:"reporter_contact"`
ExpiresAt *time.Time `json:"expires_at"`
ReporterUserName string `json:"reporter_user_name"`
ReporterEmail string `json:"reporter_email"`
DaysUntilExpire int `json:"days_until_expire"`
CreatedAt time.Time `json:"created_at"`
}
// GetItemsWithDetails - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetItemsWithDetails(page, limit int, status string) ([]ItemDetail, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var items []ItemDetail
var total int64
query := s.db.WithContext(ctx).Table("vw_items_detail")
if status != "" {
query = query.Where("status = ?", status)
}
// Count
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Paginate
offset := (page - 1) * limit
err := query.Offset(offset).Limit(limit).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// ClaimDetail menggunakan VIEW vw_claims_detail
type ClaimDetail struct {
ID uint `json:"id"`
Status string `json:"status"`
ItemName string `json:"item_name"`
CategoryName string `json:"category_name"`
ClaimantName string `json:"claimant_name"`
ClaimantEmail string `json:"claimant_email"`
ClaimantPhone string `json:"claimant_phone"`
ClaimDescription string `json:"claim_description"`
Contact string `json:"contact"`
SimilarityScore *float64 `json:"similarity_score"`
VerifiedAt *time.Time `json:"verified_at"`
VerifiedByName string `json:"verified_by_name"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
}
// GetClaimsWithDetails - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetClaimsWithDetails(status string) ([]ClaimDetail, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var claims []ClaimDetail
query := s.db.WithContext(ctx).Table("vw_claims_detail")
if status != "" {
query = query.Where("status = ?", status)
}
err := query.Order("created_at DESC").Scan(&claims).Error
if err != nil {
return nil, err
}
return claims, nil
}
// MatchDetail menggunakan VIEW vw_match_results_detail
type MatchDetail struct {
ID uint `json:"id"`
LostItemName string `json:"lost_item_name"`
LostByUserID uint `json:"lost_by_user_id"`
LostByUserName string `json:"lost_by_user_name"`
LostByEmail string `json:"lost_by_email"`
FoundItemName string `json:"found_item_name"`
FoundByName string `json:"found_by_name"`
SimilarityScore float64 `json:"similarity_score"`
IsNotified bool `json:"is_notified"`
MatchedAt time.Time `json:"matched_at"`
FoundItemID uint `json:"found_item_id"`
LostItemID uint `json:"lost_item_id"`
}
// GetMatchesWithDetails - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetMatchesWithDetails(minScore float64) ([]MatchDetail, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var matches []MatchDetail
query := s.db.WithContext(ctx).Table("vw_match_results_detail")
if minScore > 0 {
query = query.Where("similarity_score >= ?", minScore)
}
err := query.Limit(100).Scan(&matches).Error
if err != nil {
return nil, err
}
return matches, nil
}
// CategoryStats menggunakan VIEW vw_category_stats
type CategoryStats struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
TotalItems int `json:"total_items"`
UnclaimedItems int `json:"unclaimed_items"`
VerifiedItems int `json:"verified_items"`
TotalLostReports int `json:"total_lost_reports"`
}
// GetCategoryStats - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetCategoryStats() ([]CategoryStats, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var stats []CategoryStats
err := s.db.WithContext(ctx).
Table("vw_category_stats").
Order("total_items DESC").
Scan(&stats).Error
if err != nil {
return nil, err
}
return stats, nil
}
// UserActivity menggunakan VIEW vw_user_activity
type UserActivity struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
RoleName string `json:"role_name"`
ItemsReported int `json:"items_reported"`
LostItemsReported int `json:"lost_items_reported"`
ClaimsMade int `json:"claims_made"`
ClaimsApproved int `json:"claims_approved"`
MemberSince time.Time `json:"member_since"`
}
// GetUserActivity - ✅ MENGGUNAKAN VIEW
func (s *DashboardService) GetUserActivity(limit int) ([]UserActivity, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var activities []UserActivity
err := s.db.WithContext(ctx).
Table("vw_user_activity").
Order("items_reported DESC").
Limit(limit).
Scan(&activities).Error
if err != nil {
return nil, err
}
return activities, nil
}
func (s *DashboardService) GetStatsFromSP() (map[string]int64, error) {
return s.itemRepo.GetDashboardStatsSP()
}
// RecentActivity menggunakan VIEW vw_recent_activities
type RecentActivity struct {
ID uint `json:"id"`
Action string `json:"action"`
EntityType string `json:"entity_type"`
EntityID *uint `json:"entity_id"`
Details string `json:"details"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
UserRole string `json:"user_role"`
IPAddress string `json:"ip_address"`
CreatedAt time.Time `json:"created_at"`
}
// GetRecentActivities - ✅ MENGGUNAKAN VIEW (limited to 100)
func (s *DashboardService) GetRecentActivities() ([]RecentActivity, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var activities []RecentActivity
err := s.db.WithContext(ctx).
Table("vw_recent_activities").
Scan(&activities).Error
if err != nil {
return nil, err
}
return activities, nil
}

View File

@ -0,0 +1,352 @@
// internal/services/etl_service.go
package services
import (
"context"
"encoding/csv"
"fmt"
"io"
"lost-and-found/internal/models"
"os"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ✅ KRITERIA BASDAT: ETL Implementation (15%)
type ETLService struct {
db *gorm.DB
logger *zap.Logger
}
func NewETLService(db *gorm.DB, logger *zap.Logger) *ETLService {
return &ETLService{
db: db,
logger: logger,
}
}
// ETLResult represents ETL operation result
type ETLResult struct {
TotalRecords int `json:"total_records"`
SuccessRecords int `json:"success_records"`
FailedRecords int `json:"failed_records"`
Duration time.Duration `json:"duration"`
Errors []string `json:"errors"`
TransformedData map[string]interface{} `json:"transformed_data"`
}
// ✅ EXTRACT - Extract data from CSV
func (s *ETLService) ExtractFromCSV(filepath string) ([]map[string]string, error) {
s.logger.Info("Starting EXTRACT phase", zap.String("file", filepath))
file, err := os.Open(filepath)
if err != nil {
s.logger.Error("Failed to open CSV file", zap.Error(err))
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
headers, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("failed to read headers: %w", err)
}
var records []map[string]string
lineNumber := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
s.logger.Warn("Skipping invalid line", zap.Int("line", lineNumber), zap.Error(err))
lineNumber++
continue
}
data := make(map[string]string)
for i, value := range record {
if i < len(headers) {
data[headers[i]] = strings.TrimSpace(value)
}
}
records = append(records, data)
lineNumber++
}
s.logger.Info("EXTRACT completed", zap.Int("records", len(records)))
return records, nil
}
// ✅ TRANSFORM - Transform and validate data
func (s *ETLService) TransformItemData(records []map[string]string) ([]models.Item, []string) {
s.logger.Info("Starting TRANSFORM phase", zap.Int("records", len(records)))
var items []models.Item
var errors []string
// Worker Pool Pattern for concurrent transformation
const numWorkers = 5
recordsChan := make(chan map[string]string, len(records))
resultsChan := make(chan struct {
item *models.Item
error string
}, len(records))
var wg sync.WaitGroup
// Start workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for record := range recordsChan {
item, err := s.transformSingleItem(record)
if err != nil {
resultsChan <- struct {
item *models.Item
error string
}{nil, err.Error()}
} else {
resultsChan <- struct {
item *models.Item
error string
}{item, ""}
}
}
}(i)
}
// Send records to workers
go func() {
for _, record := range records {
recordsChan <- record
}
close(recordsChan)
}()
// Wait for workers to finish
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect results
for result := range resultsChan {
if result.error != "" {
errors = append(errors, result.error)
} else if result.item != nil {
items = append(items, *result.item)
}
}
s.logger.Info("TRANSFORM completed",
zap.Int("success", len(items)),
zap.Int("failed", len(errors)))
return items, errors
}
func (s *ETLService) transformSingleItem(record map[string]string) (*models.Item, error) {
// Validate required fields
required := []string{"name", "category_id", "location", "description", "date_found", "reporter_name", "reporter_contact"}
for _, field := range required {
if record[field] == "" {
return nil, fmt.Errorf("missing required field: %s", field)
}
}
// Parse category_id
categoryID, err := strconv.ParseUint(record["category_id"], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid category_id: %s", record["category_id"])
}
// Parse date_found
dateFound, err := time.Parse("2006-01-02", record["date_found"])
if err != nil {
return nil, fmt.Errorf("invalid date_found format: %s", record["date_found"])
}
// Parse reporter_id
reporterID, err := strconv.ParseUint(record["reporter_id"], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid reporter_id: %s", record["reporter_id"])
}
// Create item
item := &models.Item{
Name: record["name"],
CategoryID: uint(categoryID),
PhotoURL: record["photo_url"],
Location: record["location"],
Description: record["description"],
DateFound: dateFound,
Status: models.ItemStatusUnclaimed,
ReporterID: uint(reporterID),
ReporterName: record["reporter_name"],
ReporterContact: record["reporter_contact"],
}
return item, nil
}
// ✅ LOAD - Load transformed data to database with TRANSACTION
func (s *ETLService) LoadItems(items []models.Item) (*ETLResult, error) {
s.logger.Info("Starting LOAD phase", zap.Int("items", len(items)))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
startTime := time.Now()
result := &ETLResult{
TotalRecords: len(items),
Errors: []string{},
}
// Batch insert with transaction
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
if err := tx.Create(&item).Error; err != nil {
result.FailedRecords++
result.Errors = append(result.Errors, fmt.Sprintf("Failed to insert %s: %v", item.Name, err))
s.logger.Warn("Failed to insert item",
zap.String("name", item.Name),
zap.Error(err))
} else {
result.SuccessRecords++
}
}
// If more than 50% failed, rollback
if result.FailedRecords > result.TotalRecords/2 {
return fmt.Errorf("too many failures (%d/%d), rolling back transaction",
result.FailedRecords, result.TotalRecords)
}
return nil
})
result.Duration = time.Since(startTime)
if err != nil {
s.logger.Error("LOAD failed", zap.Error(err))
return result, err
}
s.logger.Info("LOAD completed",
zap.Int("success", result.SuccessRecords),
zap.Int("failed", result.FailedRecords),
zap.Duration("duration", result.Duration))
return result, nil
}
// ✅ Full ETL Pipeline
func (s *ETLService) RunETLPipeline(csvPath string) (*ETLResult, error) {
s.logger.Info("Starting FULL ETL Pipeline", zap.String("source", csvPath))
// EXTRACT
records, err := s.ExtractFromCSV(csvPath)
if err != nil {
return nil, fmt.Errorf("extract failed: %w", err)
}
// TRANSFORM
items, transformErrors := s.TransformItemData(records)
// LOAD
result, err := s.LoadItems(items)
if err != nil {
return nil, fmt.Errorf("load failed: %w", err)
}
// Add transform errors to result
result.Errors = append(result.Errors, transformErrors...)
s.logger.Info("ETL Pipeline completed",
zap.Int("total", result.TotalRecords),
zap.Int("success", result.SuccessRecords),
zap.Int("failed", result.FailedRecords))
return result, nil
}
// ✅ Export data (Reverse ETL)
func (s *ETLService) ExportToCSV(filepath string, query string) error {
s.logger.Info("Exporting data to CSV", zap.String("file", filepath))
var items []models.Item
if err := s.db.Raw(query).Scan(&items).Error; err != nil {
return fmt.Errorf("failed to query data: %w", err)
}
file, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Write headers
headers := []string{"id", "name", "category_id", "location", "description", "date_found", "status"}
if err := writer.Write(headers); err != nil {
return err
}
// Write data
for _, item := range items {
record := []string{
strconv.Itoa(int(item.ID)),
item.Name,
strconv.Itoa(int(item.CategoryID)),
item.Location,
item.Description,
item.DateFound.Format("2006-01-02"),
item.Status,
}
if err := writer.Write(record); err != nil {
return err
}
}
s.logger.Info("Export completed", zap.Int("records", len(items)))
return nil
}
// ✅ Data Synchronization between databases
func (s *ETLService) SyncToExternalDB(externalDB *gorm.DB) error {
s.logger.Info("Starting database synchronization")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Get all items from source
var items []models.Item
if err := s.db.WithContext(ctx).Find(&items).Error; err != nil {
return fmt.Errorf("failed to fetch items: %w", err)
}
// Sync to external DB with transaction
return externalDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range items {
// Upsert logic
if err := tx.Save(&item).Error; err != nil {
s.logger.Warn("Failed to sync item",
zap.Uint("id", item.ID),
zap.Error(err))
return err
}
}
return nil
})
}

View File

@ -0,0 +1,267 @@
// internal/services/export_service.go
package services
import (
"bytes"
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"time"
"log"
"errors"
"gorm.io/gorm"
)
type ExportService struct {
itemRepo *repositories.ItemRepository
archiveRepo *repositories.ArchiveRepository
claimRepo *repositories.ClaimRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewExportService(db *gorm.DB) *ExportService {
return &ExportService{
itemRepo: repositories.NewItemRepository(db),
archiveRepo: repositories.NewArchiveRepository(db),
claimRepo: repositories.NewClaimRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// ExportRequest represents export request data
type ExportRequest struct {
Type string `json:"type"` // items, archives, claims, audit_logs
Format string `json:"format"` // pdf, excel
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
Status string `json:"status"`
}
// ExportItemsToPDF exports items to PDF
func (s *ExportService) ExportItemsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
log.Printf("DEBUG: Starting PDF export - Type: %s, Status: %s", req.Type, req.Status)
// Get items
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
if err != nil {
log.Printf("ERROR: Failed to fetch items: %v", err) // ← Tambahkan ini
return nil, err
}
log.Printf("DEBUG: Found %d items", len(items))
// Filter by date range if provided
var filteredItems []models.Item
for _, item := range items {
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
continue
}
filteredItems = append(filteredItems, item)
}
if len(filteredItems) == 0 {
return nil, errors.New("no data in specified date range")
}
// Generate PDF
pdf := utils.NewPDFExporter()
pdf.AddTitle("Laporan Barang Ditemukan")
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
formatDate(req.StartDate),
formatDate(req.EndDate)))
pdf.AddNewLine()
// Add table
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Tanggal Ditemukan", "Status"}
var data [][]string
for i, item := range filteredItems {
data = append(data, []string{
fmt.Sprintf("%d", i+1),
item.Name,
item.Category.Name,
item.Location,
item.DateFound.Format("02 Jan 2006"),
item.Status,
})
}
pdf.AddTable(headers, data)
// Add footer
pdf.AddNewLine()
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredItems)))
pdf.AddText(fmt.Sprintf("Dicetak pada: %s", time.Now().Format("02 January 2006 15:04")))
// Log audit
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported items report (PDF, %d items)", len(filteredItems)),
ipAddress, userAgent)
return pdf.Output(), nil
}
// ExportItemsToExcel exports items to Excel
func (s *ExportService) ExportItemsToExcel(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
// Get items
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
if err != nil {
return nil, err
}
// Filter by date range if provided
var filteredItems []models.Item
for _, item := range items {
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
continue
}
filteredItems = append(filteredItems, item)
}
// Generate Excel
excel := utils.NewExcelExporter()
excel.SetSheetName("Barang Ditemukan")
// Add headers
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Deskripsi",
"Tanggal Ditemukan", "Status", "Pelapor", "Kontak"}
excel.AddRow(headers)
// Add data
for i, item := range filteredItems {
excel.AddRow([]string{
fmt.Sprintf("%d", i+1),
item.Name,
item.Category.Name,
item.Location,
item.Description,
item.DateFound.Format("02 Jan 2006"),
item.Status,
item.ReporterName,
item.ReporterContact,
})
}
// Auto-size columns
excel.AutoSizeColumns(len(headers))
// Log audit
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported items report (Excel, %d items)", len(filteredItems)),
ipAddress, userAgent)
return excel.Output()
}
// ExportArchivesToPDF exports archives to PDF
func (s *ExportService) ExportArchivesToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
archives, _, err := s.archiveRepo.FindAll(1, 10000, "", "")
if err != nil {
return nil, err
}
// Filter by date range
var filteredArchives []models.Archive
for _, archive := range archives {
if req.StartDate != nil && archive.ArchivedAt.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && archive.ArchivedAt.After(*req.EndDate) {
continue
}
filteredArchives = append(filteredArchives, archive)
}
pdf := utils.NewPDFExporter()
pdf.AddTitle("Laporan Barang yang Diarsipkan")
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
formatDate(req.StartDate),
formatDate(req.EndDate)))
pdf.AddNewLine()
headers := []string{"No", "Nama Barang", "Kategori", "Alasan Arsip", "Tanggal Arsip"}
var data [][]string
for i, archive := range filteredArchives {
data = append(data, []string{
fmt.Sprintf("%d", i+1),
archive.Name,
archive.Category.Name,
archive.ArchivedReason,
archive.ArchivedAt.Format("02 Jan 2006"),
})
}
pdf.AddTable(headers, data)
pdf.AddNewLine()
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredArchives)))
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported archives report (PDF, %d items)", len(filteredArchives)),
ipAddress, userAgent)
return pdf.Output(), nil
}
// ExportClaimsToPDF exports claims to PDF
func (s *ExportService) ExportClaimsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
claims, _, err := s.claimRepo.FindAll(1, 10000, req.Status, nil, nil)
if err != nil {
return nil, err
}
// Filter by date range
var filteredClaims []models.Claim
for _, claim := range claims {
if req.StartDate != nil && claim.CreatedAt.Before(*req.StartDate) {
continue
}
if req.EndDate != nil && claim.CreatedAt.After(*req.EndDate) {
continue
}
filteredClaims = append(filteredClaims, claim)
}
pdf := utils.NewPDFExporter()
pdf.AddTitle("Laporan Klaim Barang")
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
formatDate(req.StartDate),
formatDate(req.EndDate)))
pdf.AddNewLine()
headers := []string{"No", "Barang", "Pengklaim", "Status", "Tanggal Klaim"}
var data [][]string
for i, claim := range filteredClaims {
data = append(data, []string{
fmt.Sprintf("%d", i+1),
claim.Item.Name,
claim.User.Name,
claim.Status,
claim.CreatedAt.Format("02 Jan 2006"),
})
}
pdf.AddTable(headers, data)
pdf.AddNewLine()
pdf.AddText(fmt.Sprintf("Total: %d klaim", len(filteredClaims)))
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
fmt.Sprintf("Exported claims report (PDF, %d claims)", len(filteredClaims)),
ipAddress, userAgent)
return pdf.Output(), nil
}
// Helper function to format date
func formatDate(date *time.Time) string {
if date == nil {
return "N/A"
}
return date.Format("02 Jan 2006")
}

View File

@ -0,0 +1,615 @@
// internal/services/item_service.go - FIXED VERSION
package services
import (
"context"
"errors"
"fmt"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"time"
"log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ItemService struct {
itemRepo *repositories.ItemRepository
categoryRepo *repositories.CategoryRepository
auditLogRepo *repositories.AuditLogRepository
revisionRepo *repositories.RevisionLogRepository
db *gorm.DB
lostItemRepo *repositories.LostItemRepository // FIXED: pointer type
notificationRepo *repositories.NotificationRepository // FIXED: pointer type
matchRepo *repositories.MatchResultRepository // TAMBAHAN
}
func NewItemService(db *gorm.DB) *ItemService {
itemRepo := repositories.NewItemRepository(db)
lostItemRepo := repositories.NewLostItemRepository(db)
notifRepo := repositories.NewNotificationRepository(db)
matchRepo := repositories.NewMatchResultRepository(db)
return &ItemService{
db: db,
itemRepo: itemRepo,
categoryRepo: repositories.NewCategoryRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
revisionRepo: repositories.NewRevisionLogRepository(db),
lostItemRepo: lostItemRepo,
notificationRepo: notifRepo,
matchRepo: matchRepo,
}
}
func (s *ItemService) CreateFoundItemLinked(reporterID uint, req CreateFoundItemLinkedRequest, ipAddress, userAgent string) (*models.Item, error) {
tx := s.db.Begin()
if tx.Error != nil {
return nil, tx.Error
}
// 1. Buat Item Awal (Status Default: unclaimed)
item := &models.Item{
Name: req.Name,
CategoryID: req.CategoryID,
Location: req.Location,
DateFound: time.Now(),
Description: req.Description,
ReporterID: reporterID,
ReporterName: req.ReporterName,
ReporterContact: req.ReporterContact,
Status: models.ItemStatusUnclaimed, // Default
}
if req.PhotoURL != "" {
item.PhotoURL = req.PhotoURL
}
if err := tx.Create(item).Error; err != nil {
tx.Rollback()
return nil, err
}
// 2. Logika "Langsung ke Pemilik"
// Pastikan req.LostItemID & req.IsDirectToOwner terisi (Cek tag JSON struct!)
if req.IsDirectToOwner && req.LostItemID != 0 {
var lostItem models.LostItem
// Gunakan Preload agar data User tidak nil saat dipakai nanti
if err := tx.Preload("User").First(&lostItem, req.LostItemID).Error; err != nil {
tx.Rollback()
return nil, errors.New("Laporan kehilangan tidak ditemukan")
}
// A. Update Status Lost Item jadi "claimed"
if err := tx.Model(&lostItem).Update("status", "claimed").Error; err != nil {
tx.Rollback()
return nil, err
}
// B. Update Status Item Temuan jadi "waiting_owner"
if err := tx.Model(item).Update("status", "waiting_owner").Error; err != nil {
tx.Rollback()
return nil, err
}
// ✅ PENTING: Update struct di memori supaya response API benar
itemID := item.ID
newClaim := &models.Claim{
ItemID: &itemID, // PENTING: Harus pointer
UserID: lostItem.UserID,
Description: "Auto-match: Ditemukan dan diserahkan langsung ke pemilik.",
Contact: lostItem.User.Phone,
Status: models.ClaimStatusWaitingOwner,
}
if err := tx.Create(newClaim).Error; err != nil {
tx.Rollback()
return nil, err
}
}
if err := tx.Commit().Error; err != nil {
return nil, err
}
return item, nil
}
type CreateItemRequest struct {
Name string `json:"name" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
PhotoURL string `json:"photo_url"`
Location string `json:"location" binding:"required"`
Description string `json:"description" binding:"required"`
SecretDetails string `json:"secret_details" binding:"required"`
DateFound time.Time `json:"date_found" binding:"required"`
ReporterName string `json:"reporter_name" binding:"required"`
ReporterContact string `json:"reporter_contact" binding:"required"`
}
type UpdateItemRequest struct {
Name string `json:"name"`
CategoryID uint `json:"category_id"`
PhotoURL string `json:"photo_url"`
Location string `json:"location"`
Description string `json:"description"`
SecretDetails string `json:"secret_details"`
DateFound time.Time `json:"date_found"`
ReporterName string `json:"reporter_name"`
ReporterContact string `json:"reporter_contact"`
Status string `json:"status"`
Reason string `json:"reason"`
}
type CreateFoundItemLinkedRequest struct {
CreateItemRequest
LostItemID uint `json:"lost_item_id" binding:"required"`
IsDirectToOwner bool `json:"is_direct_to_owner"`
}
// GetAllItems gets all items with CONTEXT TIMEOUT
func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
txRepo := repositories.NewItemRepository(s.db.WithContext(ctx))
items, total, err := txRepo.FindAll(page, limit, status, category, search)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, 0, errors.New("request timeout: query took too long")
}
return nil, 0, err
}
var responses []models.ItemPublicResponse
for _, item := range items {
responses = append(responses, item.ToPublicResponse())
}
return responses, total, nil
}
func (s *ItemService) RunAutoArchive(ipAddress, userAgent string) (int, error) {
// Panggil Repository
count, err := s.itemRepo.CallArchiveExpiredProcedure()
if err != nil {
return 0, err
}
// Log Audit jika ada yang diarsip
if count > 0 {
details := fmt.Sprintf("Auto-archived %d expired items using Stored Procedure", count)
// Gunakan ID 0 atau nil untuk system action
s.auditLogRepo.Log(nil, "auto_archive", "system", nil, details, ipAddress, userAgent)
}
return count, nil
}
// ✅ FIXED: CreateItem - NOW INCLUDES SecretDetails
func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var item *models.Item
// ✅ TRANSACTION untuk create item + audit log
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Verify category exists
var category models.Category
if err := tx.Where("id = ? AND deleted_at IS NULL", req.CategoryID).
First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("invalid category")
}
return fmt.Errorf("failed to verify category: %w", err)
}
// 2. Create item - ✅ SEKARANG INCLUDE SecretDetails
item = &models.Item{
Name: req.Name,
CategoryID: req.CategoryID,
PhotoURL: req.PhotoURL,
Location: req.Location,
Description: req.Description,
SecretDetails: req.SecretDetails, // ✅ TAMBAHKAN INI
DateFound: req.DateFound,
Status: models.ItemStatusUnclaimed,
ReporterID: reporterID,
ReporterName: req.ReporterName,
ReporterContact: req.ReporterContact,
}
if err := tx.Create(item).Error; err != nil {
return fmt.Errorf("failed to create item: %w", err)
}
// 3. Create audit log
auditLog := &models.AuditLog{
UserID: &reporterID,
Action: models.ActionCreate,
EntityType: models.EntityItem,
EntityID: &item.ID,
Details: fmt.Sprintf("Item created: %s", item.Name),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return item, nil
}
// ✅ FIXED: UpdateItem - NOW HANDLES SecretDetails
// internal/services/item_service.go
// UpdateItem updates an item with transaction, locking, and status change support
func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
// ✅ Tambahkan logging
log.Printf("🔍 UpdateItem Request:")
log.Printf(" ItemID: %d", itemID)
log.Printf(" Name: %s", req.Name)
log.Printf(" CategoryID: %d", req.CategoryID)
log.Printf(" Status: %s", req.Status)
log.Printf(" Location: %s", req.Location)
log.Printf(" Description: %s", req.Description)
log.Printf(" SecretDetails: %s", req.SecretDetails)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var updatedItem *models.Item
// ✅ TRANSACTION + LOCKING untuk update item
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item models.Item
// 1. Ambil data User beserta Role-nya (FIXED: Preload Role)
var user models.User
if err := tx.Preload("Role").First(&user, userID).Error; err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// 2. Lock item untuk mencegah race condition saat update
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("Category").
Where("id = ? AND deleted_at IS NULL", itemID).
First(&item).Error; err != nil {
return errors.New("item not found")
}
// 3. Cek Permission: Izinkan jika User adalah Owner ATAU Admin/Manager
isOwner := item.ReporterID == userID
isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager"
if !isOwner && !isManagerOrAdmin {
return errors.New("unauthorized to edit this item")
}
// 4. Validasi Status Item
// - Case Closed tetap permanen (tidak bisa diedit siapapun)
if item.Status == models.ItemStatusCaseClosed {
return errors.New("cannot edit item with status: " + item.Status)
}
// - Expired hanya bisa diedit oleh Manager/Admin
if !isManagerOrAdmin && item.IsExpired() {
return errors.New("cannot edit expired item")
}
// Track changes for revision log
revisionCreated := false
// --- Update Fields Logics ---
if req.Name != "" && req.Name != item.Name {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "name",
OldValue: item.Name,
NewValue: req.Name,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.Name = req.Name
revisionCreated = true
}
if req.CategoryID != 0 && req.CategoryID != item.CategoryID {
// Verify new category exists
var newCategory models.Category
if err := tx.Where("id = ?", req.CategoryID).First(&newCategory).Error; err != nil {
return errors.New("invalid category")
}
oldCatName := item.Category.Name
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "category",
OldValue: oldCatName,
NewValue: newCategory.Name,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.CategoryID = req.CategoryID
revisionCreated = true
}
if req.Location != "" && req.Location != item.Location {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "location",
OldValue: item.Location,
NewValue: req.Location,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.Location = req.Location
revisionCreated = true
}
if req.Description != "" && req.Description != item.Description {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "description",
OldValue: item.Description,
NewValue: req.Description,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.Description = req.Description
revisionCreated = true
}
// Handle SecretDetails update
if req.SecretDetails != "" && req.SecretDetails != item.SecretDetails {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "secret_details",
OldValue: item.SecretDetails,
NewValue: req.SecretDetails,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.SecretDetails = req.SecretDetails
revisionCreated = true
}
if req.PhotoURL != "" && req.PhotoURL != item.PhotoURL {
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "photo_url",
OldValue: item.PhotoURL,
NewValue: req.PhotoURL,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log: %w", err)
}
item.PhotoURL = req.PhotoURL
revisionCreated = true
}
// ✅ NEW: Handle Status Update (Khusus Manager/Admin)
if req.Status != "" && req.Status != item.Status {
// Validasi status yang diperbolehkan untuk di-set manual
validStatuses := map[string]bool{
models.ItemStatusUnclaimed: true,
models.ItemStatusVerified: true,
models.ItemStatusExpired: true,
models.ItemStatusCaseClosed: true,
}
if !validStatuses[req.Status] {
return errors.New("invalid status value")
}
revLog := &models.RevisionLog{
ItemID: itemID,
UserID: userID,
FieldName: "status",
OldValue: item.Status,
NewValue: req.Status,
Reason: req.Reason,
}
if err := tx.Create(revLog).Error; err != nil {
return fmt.Errorf("failed to create revision log for status: %w", err)
}
item.Status = req.Status
revisionCreated = true
}
if !revisionCreated {
return errors.New("no changes detected")
}
// Save updated item
if err := tx.Save(&item).Error; err != nil {
return fmt.Errorf("failed to update item: %w", err)
}
// Create audit log
auditLog := &models.AuditLog{
UserID: &userID,
Action: models.ActionUpdate,
EntityType: models.EntityItem,
EntityID: &itemID,
Details: fmt.Sprintf("Item updated: %s (Reason: %s)", item.Name, req.Reason),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
updatedItem = &item
return nil
})
if err != nil {
return nil, err
}
return updatedItem, nil
}
// UpdateItemStatus updates item status with TRANSACTION
func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Lock item
var item models.Item
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", itemID).
First(&item).Error; err != nil {
return fmt.Errorf("failed to lock item: %w", err)
}
// Update status
if err := tx.Model(&item).Update("status", status).Error; err != nil {
return fmt.Errorf("failed to update status: %w", err)
}
// Create audit log
auditLog := &models.AuditLog{
UserID: &userID,
Action: models.ActionUpdate,
EntityType: models.EntityItem,
EntityID: &itemID,
Details: fmt.Sprintf("Item status updated to: %s", status),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
})
}
// DeleteItem deletes an item with TRANSACTION
func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Ambil data User yang sedang request (untuk cek Role)
var user models.User
if err := tx.Preload("Role").First(&user, userID).Error; err != nil {
return errors.New("user not found")
}
// 2. Lock item
var item models.Item
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", itemID).
First(&item).Error; err != nil {
return fmt.Errorf("failed to lock item: %w", err)
}
// 3. PERMISSION CHECK: Izinkan jika Owner ATAU Manager/Admin
isOwner := item.ReporterID == userID
isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager"
if !isOwner && !isManagerOrAdmin {
return errors.New("unauthorized to delete this item")
}
// 4. ✅ (BARU) Validasi Active Claims - PENGGANTI TRIGGER trg_items_before_delete
// Cek apakah ada claims dengan status 'pending' untuk item ini
var activeClaims int64
if err := tx.Model(&models.Claim{}).
Where("item_id = ? AND status IN ? AND deleted_at IS NULL",
itemID, []string{models.ClaimStatusPending, models.ClaimStatusWaitingOwner}).
Count(&activeClaims).Error; err != nil {
return fmt.Errorf("failed to check active claims: %w", err)
}
if activeClaims > 0 {
return errors.New("cannot delete item with active claims (pending or waiting owner)")
}
if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed {
return errors.New("cannot delete item with status: " + item.Status)
}
if err := tx.Delete(&item).Error; err != nil {
return fmt.Errorf("failed to delete item: %w", err)
}
// 7. Create audit log
auditLog := &models.AuditLog{
UserID: &userID,
Action: models.ActionDelete,
EntityType: models.EntityItem,
EntityID: &itemID,
Details: fmt.Sprintf("Item deleted: %s", item.Name),
IPAddress: ipAddress,
UserAgent: userAgent,
}
if err := tx.Create(auditLog).Error; err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
})
}
// GetItemsByReporter gets items by reporter with CONTEXT
func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
txRepo := repositories.NewItemRepository(s.db.WithContext(ctx))
return txRepo.FindByReporter(reporterID, page, limit)
}
// GetItemRevisionHistory gets revision history with CONTEXT
func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
txRepo := repositories.NewRevisionLogRepository(s.db.WithContext(ctx))
logs, total, err := txRepo.FindByItem(itemID, page, limit)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, 0, errors.New("request timeout")
}
return nil, 0, err
}
var responses []models.RevisionLogResponse
for _, log := range logs {
responses = append(responses, log.ToResponse())
}
return responses, total, nil
}

View File

@ -0,0 +1,362 @@
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"time"
"fmt"
"gorm.io/gorm"
)
type LostItemService struct {
db *gorm.DB
lostItemRepo *repositories.LostItemRepository
categoryRepo *repositories.CategoryRepository
auditLogRepo *repositories.AuditLogRepository
userRepo *repositories.UserRepository
notificationRepo *repositories.NotificationRepository
claimRepo *repositories.ClaimRepository
}
func NewLostItemService(db *gorm.DB) *LostItemService {
return &LostItemService{
db: db,
lostItemRepo: repositories.NewLostItemRepository(db),
categoryRepo: repositories.NewCategoryRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
userRepo: repositories.NewUserRepository(db),
notificationRepo: repositories.NewNotificationRepository(db),
claimRepo: repositories.NewClaimRepository(db),
}
}
type CreateLostItemRequest struct {
Name string `json:"name" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
Color string `json:"color"`
Location string `json:"location"`
Description string `json:"description" binding:"required"`
DateLost time.Time `json:"date_lost" binding:"required"`
}
type UpdateLostItemRequest struct {
Name string `json:"name"`
CategoryID uint `json:"category_id"`
Color string `json:"color"`
Location string `json:"location"`
Description string `json:"description"`
DateLost time.Time `json:"date_lost"`
}
type CreateLostItemClaimRequest struct {
Description string `json:"description" binding:"required"`
Contact string `json:"contact" binding:"required"`
ProofURL string `json:"proof_url"`
}
func (s *LostItemService) GetAllLostItems(page, limit int, status, category, search string, userID *uint) ([]models.LostItemResponse, int64, error) {
lostItems, total, err := s.lostItemRepo.FindAll(page, limit, status, category, search, userID)
if err != nil {
return nil, 0, err
}
var responses []models.LostItemResponse
for _, lostItem := range lostItems {
response := lostItem.ToResponse()
if lostItem.DirectClaimID != nil {
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
if err == nil {
response.DirectClaimStatus = claim.Status
}
}
responses = append(responses, response)
}
return responses, total, nil
}
func (s *LostItemService) GetLostItemByID(id uint) (*models.LostItem, error) {
lostItem, err := s.lostItemRepo.FindByID(id)
if err != nil {
return nil, err
}
if lostItem.DirectClaimID != nil {
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
if err == nil {
lostItem.DirectClaim = claim
}
}
return lostItem, nil
}
func (s *LostItemService) CreateLostItem(userID uint, req CreateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
return nil, errors.New("invalid category")
}
lostItem := &models.LostItem{
UserID: userID,
Name: req.Name,
CategoryID: req.CategoryID,
Color: req.Color,
Location: req.Location,
Description: req.Description,
DateLost: req.DateLost,
Status: models.LostItemStatusActive,
}
if err := s.lostItemRepo.Create(lostItem); err != nil {
return nil, errors.New("failed to create lost item report")
}
s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityLostItem, &lostItem.ID,
"Lost item report created: "+lostItem.Name, ipAddress, userAgent)
return s.lostItemRepo.FindByID(lostItem.ID)
}
func (s *LostItemService) UpdateLostItem(userID, lostItemID uint, req UpdateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return nil, err
}
// Cek authorization
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, errors.New("user not found")
}
isOwner := lostItem.UserID == userID
isAdminOrManager := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
if !isOwner && !isAdminOrManager {
return nil, errors.New("unauthorized to update this lost item report")
}
if !isAdminOrManager && !lostItem.IsActive() {
return nil, errors.New("cannot update non-active lost item report")
}
// ✅ UPDATE: Prioritaskan category_id, update terlebih dahulu
if req.CategoryID != 0 {
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
return nil, errors.New("invalid category")
}
// LOG untuk debug
fmt.Printf("📝 Updating category from %d to %d\n", lostItem.CategoryID, req.CategoryID)
lostItem.CategoryID = req.CategoryID
}
if req.Name != "" {
lostItem.Name = req.Name
}
if req.Color != "" {
lostItem.Color = req.Color
}
if req.Location != "" {
lostItem.Location = req.Location
}
if req.Description != "" {
lostItem.Description = req.Description
}
if !req.DateLost.IsZero() {
lostItem.DateLost = req.DateLost
}
// ✅ PASTIKAN update tersimpan
if err := s.lostItemRepo.Update(lostItem); err != nil {
fmt.Printf("❌ Error updating lost item: %v\n", err)
return nil, errors.New("failed to update lost item report")
}
actionDesc := fmt.Sprintf("Lost item report updated: %s", lostItem.Name)
if !isOwner {
actionDesc = fmt.Sprintf("Lost item report updated by %s: %s", user.Role.Name, lostItem.Name)
}
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
actionDesc, ipAddress, userAgent)
// ✅ RELOAD dari database untuk memastikan data terbaru
return s.lostItemRepo.FindByID(lostItem.ID)
}
func (s *LostItemService) UpdateLostItemStatus(userID, lostItemID uint, status string, ipAddress, userAgent string) error {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return err
}
// TAMBAHKAN: Cek admin/manager permission
user, err := s.userRepo.FindByID(userID)
if err != nil {
return errors.New("user not found")
}
isOwner := lostItem.UserID == userID
isAdminOrManager := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
if !isOwner && !isAdminOrManager {
return errors.New("unauthorized to update this lost item report")
}
if err := s.lostItemRepo.UpdateStatus(lostItemID, status); err != nil {
return errors.New("failed to update lost item status")
}
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
"Lost item status updated to: "+status, ipAddress, userAgent)
return nil
}
func (s *LostItemService) DeleteLostItem(userID, lostItemID uint, ipAddress, userAgent string) error {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return err
}
user, err := s.userRepo.FindByID(userID)
if err != nil {
return errors.New("user not found")
}
isOwner := lostItem.UserID == userID
isManagerOrAdmin := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
if !isOwner && !isManagerOrAdmin {
return errors.New("unauthorized to delete this lost item report")
}
if err := s.lostItemRepo.Delete(lostItemID); err != nil {
return errors.New("failed to delete lost item report")
}
s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityLostItem, &lostItemID, "Lost item report deleted: "+lostItem.Name, ipAddress, userAgent)
return nil
}
func (s *LostItemService) GetLostItemsByUser(userID uint, page, limit int) ([]models.LostItemResponse, int64, error) {
lostItems, total, err := s.lostItemRepo.FindByUser(userID, page, limit)
if err != nil {
return nil, 0, err
}
var responses []models.LostItemResponse
for _, lostItem := range lostItems {
response := lostItem.ToResponse()
// ✅ PERBAIKAN: Load data claim lengkap jika ada direct claim
if lostItem.DirectClaimID != nil {
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
if err == nil {
// Load relasi user penemu agar namanya muncul di frontend
s.db.Preload("User").First(claim, claim.ID)
response.DirectClaimStatus = claim.Status
// Konversi claim ke response dan tempelkan ke lost item response
claimResp := claim.ToResponse()
response.DirectClaim = &claimResp // Pastikan struct LostItemResponse punya field ini
}
}
responses = append(responses, response)
}
return responses, total, nil
}
func (s *LostItemService) DirectClaimToOwner(
finderUserID uint,
lostItemID uint,
req CreateLostItemClaimRequest,
ipAddress string, // Tambahkan ini agar audit log valid
userAgent string, // Tambahkan ini agar audit log valid
) (*models.Claim, error) {
var createdClaim *models.Claim
err := s.db.Transaction(func(tx *gorm.DB) error {
// 1. Get Lost Item
var lostItem models.LostItem
if err := tx.First(&lostItem, lostItemID).Error; err != nil {
return errors.New("lost item not found")
}
// 2. Validasi
if lostItem.Status != models.LostItemStatusActive {
return errors.New("lost item is not active")
}
if lostItem.UserID == finderUserID {
return errors.New("cannot claim your own lost item")
}
if lostItem.DirectClaimID != nil {
return errors.New("this lost item already has a pending claim")
}
// 3. Create Claim (LOGIC OPSI A)
// Kita set ItemID nil, tapi isi LostItemID
claim := models.Claim{
ItemID: nil, // PENTING: Set nil (jangan 0)
LostItemID: &lostItemID, // PENTING: Link ke LostItem
UserID: finderUserID,
Description: req.Description,
Contact: req.Contact,
ProofURL: req.ProofURL,
Status: models.ClaimStatusWaitingOwner,
Notes: fmt.Sprintf("Direct claim for lost item #%d", lostItemID),
}
if err := tx.Create(&claim).Error; err != nil {
return err
}
createdClaim = &claim
// 4. Update Lost Item Status
// Status diganti agar tidak muncul di pencarian publik sementara waktu
// Pastikan constant ini ada di models, atau gunakan string "pending_verification"
lostItem.Status = "pending_verification"
lostItem.DirectClaimID = &claim.ID
if err := tx.Save(&lostItem).Error; err != nil {
return err
}
// 5. Notification to Owner
s.notificationRepo.Notify(
lostItem.UserID,
"direct_claim_received",
"🎯 Barang Anda Ditemukan?",
fmt.Sprintf("Seseorang mengklaim menemukan '%s'. Silakan verifikasi klaim ini.", lostItem.Name),
"claim", // Entity Type arahkan ke claim
&claim.ID, // Entity ID arahkan ke claim ID agar owner bisa klik detail claim
)
// 6. Audit Log
s.auditLogRepo.Log(
&finderUserID,
"create_direct_claim",
"claim",
&claim.ID,
fmt.Sprintf("Direct claim created for lost item #%d", lostItemID),
ipAddress,
userAgent,
)
return nil
})
if err != nil {
return nil, err
}
return createdClaim, nil
}

View File

@ -0,0 +1,205 @@
// internal/services/match_service.go
package services
import (
"encoding/json"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"gorm.io/gorm"
)
type MatchService struct {
db *gorm.DB // Tambahkan ini
matchRepo *repositories.MatchResultRepository
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository
notificationRepo *repositories.NotificationRepository
}
func NewMatchService(db *gorm.DB) *MatchService {
return &MatchService{
db: db, // Tambahkan ini
matchRepo: repositories.NewMatchResultRepository(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db),
notificationRepo: repositories.NewNotificationRepository(db),
}
}
// MatchedField represents a matched field between items
type MatchedField struct {
Field string `json:"field"`
LostValue string `json:"lost_value"`
FoundValue string `json:"found_value"`
Score float64 `json:"score"`
}
// FindSimilarItems finds similar items for a lost item report
func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) {
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
if err != nil {
return nil, err
}
// Search for items in same category
items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color)
if err != nil {
return nil, err
}
var results []models.MatchResultResponse
for _, item := range items {
// Calculate similarity
score, matchedFields := s.calculateSimilarity(lostItem, &item)
// Only include if score is reasonable (>= 30%)
if score >= 30.0 {
// Check if match already exists
exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID)
if !exists {
// Create match result
matchedFieldsJSON, _ := json.Marshal(matchedFields)
match := &models.MatchResult{
LostItemID: lostItemID,
ItemID: item.ID,
SimilarityScore: score,
MatchedFields: string(matchedFieldsJSON),
IsNotified: false,
}
s.matchRepo.Create(match)
// Reload with relations
match, _ = s.matchRepo.FindByID(match.ID)
results = append(results, match.ToResponse())
}
}
}
return results, nil
}
// GetMatchesForLostItem gets all matches for a lost item
func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) {
matches, err := s.matchRepo.FindByLostItem(lostItemID)
if err != nil {
return nil, err
}
var responses []models.MatchResultResponse
for _, match := range matches {
responses = append(responses, match.ToResponse())
}
return responses, nil
}
// GetMatchesForItem gets all matches for an item
func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) {
matches, err := s.matchRepo.FindByItem(itemID)
if err != nil {
return nil, err
}
var responses []models.MatchResultResponse
for _, match := range matches {
responses = append(responses, match.ToResponse())
}
return responses, nil
}
// AutoMatchNewItem automatically matches a new item with lost items
func (s *MatchService) AutoMatchNewItem(itemID uint) error {
item, err := s.itemRepo.FindByID(itemID)
if err != nil {
return err
}
// Find active lost items in same category
lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID)
if err != nil {
return err
}
for _, lostItem := range lostItems {
// Calculate similarity
score, matchedFields := s.calculateSimilarity(&lostItem, item)
// Create match if score is high enough (>= 50% for auto-match)
if score >= 50.0 {
// Check if match already exists
exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID)
if !exists {
matchedFieldsJSON, _ := json.Marshal(matchedFields)
match := &models.MatchResult{
LostItemID: lostItem.ID,
ItemID: itemID,
SimilarityScore: score,
MatchedFields: string(matchedFieldsJSON),
IsNotified: false,
}
s.matchRepo.Create(match)
// Send notification to lost item owner - PERBAIKAN DI SINI
models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID)
s.matchRepo.MarkAsNotified(match.ID)
}
}
}
return nil
}
// calculateSimilarity calculates similarity between lost item and found item
func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) {
var matchedFields []MatchedField
// 1. Hard Filter: Kategori HARUS sama.
if lostItem.CategoryID != item.CategoryID {
return 0.0, matchedFields
}
// --- A. Hitung Kecocokan Nama (50%) ---
nameSim := utils.CalculateStringSimilarity(lostItem.Name, item.Name)
nameScore := nameSim * 100.0
if nameScore > 40.0 {
matchedFields = append(matchedFields, MatchedField{
Field: "name",
LostValue: lostItem.Name,
FoundValue: item.Name,
Score: nameScore,
})
}
// --- B. Hitung Kecocokan Secret Details / Deskripsi (50%) ---
// Target: Utamakan Secret Details penemu, fallback ke Description
targetText := item.SecretDetails
foundValueType := "Secret Details"
if targetText == "" {
targetText = item.Description
foundValueType = "Description"
}
// Bandingkan Deskripsi User vs Target Penemu
descSim := utils.CalculateStringSimilarity(lostItem.Description, targetText)
descScore := descSim * 100.0
if descScore > 40.0 {
matchedFields = append(matchedFields, MatchedField{
Field: "secret_details",
LostValue: "User Description",
FoundValue: foundValueType,
Score: descScore,
})
}
// --- C. Hitung Skor Akhir ---
// Rumus: (Skor Nama * 0.5) + (Skor Deskripsi * 0.5)
finalScore := (nameScore * 0.5) + (descScore * 0.5)
return finalScore, matchedFields
}

View File

@ -0,0 +1,116 @@
// internal/services/notification_service.go
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type NotificationService struct {
db *gorm.DB // Tambahkan ini
notificationRepo *repositories.NotificationRepository
}
func NewNotificationService(db *gorm.DB) *NotificationService {
return &NotificationService{
db: db, // Tambahkan ini
notificationRepo: repositories.NewNotificationRepository(db),
}
}
// GetUserNotifications gets notifications for a user
func (s *NotificationService) GetUserNotifications(userID uint, page, limit int, onlyUnread bool) ([]models.NotificationResponse, int64, error) {
notifications, total, err := s.notificationRepo.FindByUser(userID, page, limit, onlyUnread)
if err != nil {
return nil, 0, err
}
var responses []models.NotificationResponse
for _, notification := range notifications {
responses = append(responses, notification.ToResponse())
}
return responses, total, nil
}
// GetNotificationByID gets notification by ID
func (s *NotificationService) GetNotificationByID(userID, notificationID uint) (*models.Notification, error) {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
return nil, err
}
// Check ownership
if notification.UserID != userID {
return nil, errors.New("unauthorized")
}
return notification, nil
}
// MarkAsRead marks a notification as read
func (s *NotificationService) MarkAsRead(userID, notificationID uint) error {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
return err
}
// Check ownership
if notification.UserID != userID {
return errors.New("unauthorized")
}
return s.notificationRepo.MarkAsRead(notificationID)
}
// MarkAllAsRead marks all notifications as read for a user
func (s *NotificationService) MarkAllAsRead(userID uint) error {
return s.notificationRepo.MarkAllAsRead(userID)
}
// DeleteNotification deletes a notification
func (s *NotificationService) DeleteNotification(userID, notificationID uint) error {
notification, err := s.notificationRepo.FindByID(notificationID)
if err != nil {
return err
}
// Check ownership
if notification.UserID != userID {
return errors.New("unauthorized")
}
return s.notificationRepo.Delete(notificationID)
}
// DeleteAllNotifications deletes all notifications for a user
func (s *NotificationService) DeleteAllNotifications(userID uint) error {
return s.notificationRepo.DeleteAllForUser(userID)
}
// CountUnread counts unread notifications for a user
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
return s.notificationRepo.CountUnread(userID)
}
// CreateNotification creates a new notification
func (s *NotificationService) CreateNotification(userID uint, notifType, title, message, entityType string, entityID *uint) error {
return s.notificationRepo.Notify(userID, notifType, title, message, entityType, entityID)
}
// SendMatchNotification sends notification when a match is found
func (s *NotificationService) SendMatchNotification(userID uint, itemName string, matchID uint) error {
return models.CreateMatchNotification(s.db, userID, itemName, matchID)
}
// SendClaimApprovedNotification sends notification when claim is approved
func (s *NotificationService) SendClaimApprovedNotification(userID uint, itemName string, claimID uint) error {
return models.CreateClaimApprovedNotification(s.db, userID, itemName, claimID)
}
// SendClaimRejectedNotification sends notification when claim is rejected
func (s *NotificationService) SendClaimRejectedNotification(userID uint, itemName, reason string, claimID uint) error {
return models.CreateClaimRejectedNotification(s.db, userID, itemName, reason, claimID)
}

View File

@ -0,0 +1,112 @@
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"gorm.io/gorm"
)
type RoleService struct {
roleRepo *repositories.RoleRepository
}
func NewRoleService(db *gorm.DB) *RoleService {
return &RoleService{
roleRepo: repositories.NewRoleRepository(db),
}
}
// Structs for Requests
type CreateRoleRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
PermissionIDs []uint `json:"permission_ids"`
}
type UpdateRoleRequest struct {
Name string `json:"name"`
Description string `json:"description"`
PermissionIDs []uint `json:"permission_ids"`
}
// GetAllRoles returns all roles with permissions
func (s *RoleService) GetAllRoles() ([]models.Role, error) {
return s.roleRepo.FindAllWithPermissions()
}
// GetAllPermissions returns list of all permissions
func (s *RoleService) GetAllPermissions() ([]models.Permission, error) {
return s.roleRepo.FindAllPermissions()
}
// CreateRole creates a new role and assigns permissions
func (s *RoleService) CreateRole(req CreateRoleRequest) (*models.Role, error) {
// Check if role name already exists
existing, _ := s.roleRepo.FindByName(req.Name)
if existing != nil {
return nil, errors.New("role name already exists")
}
role := &models.Role{
Name: req.Name,
Description: req.Description,
}
// 1. Create Role
if err := s.roleRepo.Create(role); err != nil {
return nil, err
}
// 2. Assign Permissions
if len(req.PermissionIDs) > 0 {
if err := s.roleRepo.UpdatePermissions(role, req.PermissionIDs); err != nil {
return nil, err
}
}
// Reload to return complete object
return s.roleRepo.FindByID(role.ID)
}
// UpdateRole updates role details and permissions
func (s *RoleService) UpdateRole(id uint, req UpdateRoleRequest) (*models.Role, error) {
role, err := s.roleRepo.FindByID(id)
if err != nil {
return nil, errors.New("role not found")
}
// Protect core roles from name changes
if (role.Name == "admin" || role.Name == "user" || role.Name == "manager") && req.Name != role.Name {
return nil, errors.New("cannot change name of system roles")
}
// Update fields
if req.Name != "" {
role.Name = req.Name
}
role.Description = req.Description
// Update Permissions
if err := s.roleRepo.UpdatePermissions(role, req.PermissionIDs); err != nil {
return nil, err
}
return s.roleRepo.FindByID(id)
}
// DeleteRole deletes a role
func (s *RoleService) DeleteRole(id uint) error {
role, err := s.roleRepo.FindByID(id)
if err != nil {
return errors.New("role not found")
}
// Prevent deleting core system roles
if role.Name == "admin" || role.Name == "user" || role.Name == "manager" {
return errors.New("cannot delete core system roles")
}
return s.roleRepo.Delete(id)
}

View File

@ -0,0 +1,234 @@
// internal/services/user_service.go
package services
import (
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"gorm.io/gorm"
)
type UserService struct {
db *gorm.DB // ✅ Tambahkan ini
userRepo *repositories.UserRepository
roleRepo *repositories.RoleRepository
auditLogRepo *repositories.AuditLogRepository
}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{
db: db, // ✅ Tambahkan ini
userRepo: repositories.NewUserRepository(db),
roleRepo: repositories.NewRoleRepository(db),
auditLogRepo: repositories.NewAuditLogRepository(db),
}
}
// UpdateProfileRequest represents profile update data
type UpdateProfileRequest struct {
Name string `json:"name"`
Phone string `json:"phone"`
NRP string `json:"nrp"`
}
// ChangePasswordRequest represents password change data
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
// GetProfile gets user profile
func (s *UserService) GetProfile(userID uint) (*models.User, error) {
return s.userRepo.FindByID(userID)
}
// UpdateProfile - TANPA enkripsi
func (s *UserService) UpdateProfile(userID uint, req UpdateProfileRequest, ipAddress, userAgent string) (*models.User, error) {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return nil, err
}
// Update fields
if req.Name != "" {
user.Name = req.Name
}
// ✅ Update phone TANPA enkripsi
if req.Phone != "" {
user.Phone = req.Phone
}
// ✅ Update NRP TANPA enkripsi
if req.NRP != "" {
// Check if NRP already exists for another user
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
if existingNRP != nil && existingNRP.ID != userID {
return nil, errors.New("NRP already used by another user")
}
user.NRP = req.NRP
}
if err := s.userRepo.Update(user); err != nil {
return nil, errors.New("failed to update profile")
}
// Log audit
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
"Profile updated", ipAddress, userAgent)
return user, nil
}
// ChangePassword changes user password
func (s *UserService) ChangePassword(userID uint, req ChangePasswordRequest, ipAddress, userAgent string) error {
user, err := s.userRepo.FindByID(userID)
if err != nil {
return err
}
// Verify old password
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
return errors.New("invalid old password")
}
// Hash new password
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
return errors.New("failed to hash password")
}
user.Password = hashedPassword
if err := s.userRepo.Update(user); err != nil {
return errors.New("failed to change password")
}
// Log audit
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
"Password changed", ipAddress, userAgent)
return nil
}
// GetUserStats gets user statistics
func (s *UserService) GetUserStats(userID uint) (map[string]interface{}, error) {
stats := make(map[string]interface{})
// ✅ Count items reported by user
var itemCount int64
if err := s.db.Model(&models.Item{}). // ← Ganti dari s.userRepo.db
Where("reporter_id = ? AND deleted_at IS NULL", userID).
Count(&itemCount).Error; err != nil {
return nil, err
}
stats["items_reported"] = itemCount
// ✅ Count lost items reported
var lostItemCount int64
if err := s.db.Model(&models.LostItem{}). // ← Ganti dari s.userRepo.db
Where("user_id = ? AND deleted_at IS NULL", userID).
Count(&lostItemCount).Error; err != nil {
return nil, err
}
stats["lost_items_reported"] = lostItemCount
// ✅ Count claims made
var claimCount int64
if err := s.db.Model(&models.Claim{}). // ← Ganti dari s.userRepo.db
Where("user_id = ? AND deleted_at IS NULL", userID).
Count(&claimCount).Error; err != nil {
return nil, err
}
stats["claims_made"] = claimCount
// ✅ Count approved claims
var approvedClaimCount int64
if err := s.db.Model(&models.Claim{}). // ← Ganti dari s.userRepo.db
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, models.ClaimStatusApproved).
Count(&approvedClaimCount).Error; err != nil {
return nil, err
}
stats["claims_approved"] = approvedClaimCount
return stats, nil
}
// UpdateUserRole updates user role (admin only)
func (s *UserService) UpdateUserRole(adminID, userID, roleID uint, ipAddress, userAgent string) error {
// Verify role exists
role, err := s.roleRepo.FindByID(roleID)
if err != nil {
return errors.New("invalid role")
}
// Update role
if err := s.userRepo.UpdateRole(userID, roleID); err != nil {
return errors.New("failed to update user role")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityUser, &userID,
"Role updated to: "+role.Name, ipAddress, userAgent)
return nil
}
// BlockUser blocks a user (admin only)
func (s *UserService) BlockUser(adminID, userID uint, ipAddress, userAgent string) error {
// Cannot block self
if adminID == userID {
return errors.New("cannot block yourself")
}
if err := s.userRepo.BlockUser(userID); err != nil {
return errors.New("failed to block user")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionBlock, models.EntityUser, &userID,
"User blocked", ipAddress, userAgent)
return nil
}
// UnblockUser unblocks a user (admin only)
func (s *UserService) UnblockUser(adminID, userID uint, ipAddress, userAgent string) error {
if err := s.userRepo.UnblockUser(userID); err != nil {
return errors.New("failed to unblock user")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionUnblock, models.EntityUser, &userID,
"User unblocked", ipAddress, userAgent)
return nil
}
// DeleteUser deletes a user (admin only)
func (s *UserService) DeleteUser(adminID, userID uint, ipAddress, userAgent string) error {
// Cannot delete self
if adminID == userID {
return errors.New("cannot delete yourself")
}
if err := s.userRepo.Delete(userID); err != nil {
return errors.New("failed to delete user")
}
// Log audit
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityUser, &userID,
"User deleted", ipAddress, userAgent)
return nil
}
func (s *UserService) GetAllUsers(page, limit int) ([]models.User, int64, error) {
return s.userRepo.FindAll(page, limit)
}
// GetUserByID gets user by ID
func (s *UserService) GetUserByID(userID uint) (*models.User, error) {
return s.userRepo.FindByID(userID)
}

View File

@ -0,0 +1,186 @@
// internal/services/verification_service.go
package services
import (
"encoding/json"
"errors"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"lost-and-found/internal/utils"
"strconv"
"gorm.io/gorm"
)
type VerificationService struct {
verificationRepo *repositories.ClaimVerificationRepository
claimRepo *repositories.ClaimRepository
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository // ✅ TAMBAHAN: Untuk Direct Claim
}
func NewVerificationService(db *gorm.DB) *VerificationService {
return &VerificationService{
verificationRepo: repositories.NewClaimVerificationRepository(db),
claimRepo: repositories.NewClaimRepository(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db), // ✅ TAMBAHAN
}
}
// VerificationResult represents the verification result
type VerificationResult struct {
SimilarityScore float64 `json:"similarity_score"`
MatchLevel string `json:"match_level"`
MatchedKeywords []string `json:"matched_keywords"`
Details map[string]string `json:"details"`
Recommendation string `json:"recommendation"`
}
// VerifyClaimDescription verifies claim description against item/lost_item description
func (s *VerificationService) VerifyClaimDescription(claimID uint) (*VerificationResult, error) {
// 1. Get Claim
claim, err := s.claimRepo.FindByID(claimID)
if err != nil {
return nil, err
}
var targetDescription string
var sourceName string
// 2. Tentukan Target Deskripsi (Regular vs Direct Claim)
if claim.ItemID != nil {
// --- REGULAR CLAIM (Barang Temuan) ---
item, err := s.itemRepo.FindByID(*claim.ItemID) // ✅ Dereference pointer
if err != nil {
return nil, err
}
// Bandingkan dengan Secret Details (prioritas) atau Description
targetDescription = item.SecretDetails
if targetDescription == "" {
targetDescription = item.Description
}
sourceName = "item_secret_details"
} else if claim.LostItemID != nil {
// --- DIRECT CLAIM (Barang Hilang) ---
lostItem, err := s.lostItemRepo.FindByID(*claim.LostItemID) // ✅ Dereference pointer
if err != nil {
return nil, err
}
// Untuk direct claim, deskripsi Finder (Claim) dibandingkan dengan deskripsi Owner (LostItem)
targetDescription = lostItem.Description
sourceName = "lost_item_description"
} else {
return nil, errors.New("invalid claim: no item or lost_item attached")
}
// 3. Calculate similarity
similarity := utils.CalculateStringSimilarity(claim.Description, targetDescription)
similarityPercent := similarity * 100
// 4. Extract matched keywords
claimKeywords := utils.ExtractKeywords(claim.Description)
itemKeywords := utils.ExtractKeywords(targetDescription)
matchedKeywords := utils.FindMatchedKeywords(claimKeywords, itemKeywords)
// 5. Determine match level
matchLevel := "low"
recommendation := "REJECT - Ciri khusus tidak cocok"
if similarityPercent >= 75.0 {
matchLevel = "high"
recommendation = "APPROVE - Ciri khusus sangat cocok"
} else if similarityPercent >= 50.0 {
matchLevel = "medium"
recommendation = "REVIEW - Perlu verifikasi fisik/tanya jawab"
}
// 6. Create or update verification record
verification, _ := s.verificationRepo.FindByClaimID(claimID)
if verification == nil {
verification = &models.ClaimVerification{
ClaimID: claimID,
SimilarityScore: similarityPercent,
MatchedKeywords: stringSliceToJSON(matchedKeywords),
IsAutoMatched: false,
}
s.verificationRepo.Create(verification)
} else {
verification.SimilarityScore = similarityPercent
verification.MatchedKeywords = stringSliceToJSON(matchedKeywords)
s.verificationRepo.Update(verification)
}
return &VerificationResult{
SimilarityScore: similarityPercent,
MatchLevel: matchLevel,
MatchedKeywords: matchedKeywords,
Details: map[string]string{
"claim_description": claim.Description,
sourceName: targetDescription, // Dinamis (item atau lost_item)
"matched_count": strconv.Itoa(len(matchedKeywords)),
},
Recommendation: recommendation,
}, nil
}
// GetVerificationByClaimID gets verification data for a claim
func (s *VerificationService) GetVerificationByClaimID(claimID uint) (*models.ClaimVerification, error) {
verification, err := s.verificationRepo.FindByClaimID(claimID)
if err != nil {
return nil, err
}
if verification == nil {
return nil, errors.New("verification not found")
}
return verification, nil
}
// GetHighMatchVerifications gets all high match verifications
func (s *VerificationService) GetHighMatchVerifications() ([]models.ClaimVerificationResponse, error) {
verifications, err := s.verificationRepo.FindHighMatches()
if err != nil {
return nil, err
}
var responses []models.ClaimVerificationResponse
for _, v := range verifications {
responses = append(responses, v.ToResponse())
}
return responses, nil
}
// CompareDescriptions provides detailed comparison between two descriptions
func (s *VerificationService) CompareDescriptions(desc1, desc2 string) map[string]interface{} {
similarity := utils.CalculateStringSimilarity(desc1, desc2)
keywords1 := utils.ExtractKeywords(desc1)
keywords2 := utils.ExtractKeywords(desc2)
matchedKeywords := utils.FindMatchedKeywords(keywords1, keywords2)
return map[string]interface{}{
"similarity_score": similarity * 100,
"description_1": desc1,
"description_2": desc2,
"keywords_1": keywords1,
"keywords_2": keywords2,
"matched_keywords": matchedKeywords,
"total_keywords_1": len(keywords1),
"total_keywords_2": len(keywords2),
"matched_count": len(matchedKeywords),
}
}
// Helper function to convert string slice to JSON
func stringSliceToJSON(slice []string) string {
if len(slice) == 0 {
return "[]"
}
data, _ := json.Marshal(slice)
return string(data)
}

View File

@ -0,0 +1,183 @@
// internal/utils/encryption.go
package utils
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"os"
)
// ✅ KRITERIA BASDAT: Keamanan Data - Enkripsi untuk Data Sensitif (5%)
var encryptionKey []byte
// InitEncryption inisialisasi encryption key dari environment
func InitEncryption() error {
key := os.Getenv("ENCRYPTION_KEY")
if key == "" {
// Generate random key untuk development (TIDAK untuk production!)
key = "32-byte-long-encryption-key!!" // 32 bytes untuk AES-256
}
if len(key) != 32 {
return errors.New("encryption key must be exactly 32 bytes for AES-256")
}
encryptionKey = []byte(key)
return nil
}
// EncryptString mengenkripsi string menggunakan AES-256-GCM
// Digunakan untuk data sensitif: password, NRP, phone, contact info
func EncryptString(plaintext string) (string, error) {
if encryptionKey == nil {
if err := InitEncryption(); err != nil {
return "", err
}
}
// Create AES cipher
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
// Create GCM mode (Galois/Counter Mode) - authenticated encryption
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Generate nonce (number used once)
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// Encrypt and authenticate
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
// Encode to base64 untuk storage di database
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// DecryptString mendekripsi string yang sudah dienkripsi
func DecryptString(encrypted string) (string, error) {
if encryptionKey == nil {
if err := InitEncryption(); err != nil {
return "", err
}
}
// Decode from base64
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
// Create AES cipher
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return "", err
}
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
// Extract nonce
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// Decrypt and verify
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// EncryptSensitiveFields helper untuk encrypt multiple fields
func EncryptSensitiveFields(fields map[string]string) (map[string]string, error) {
encrypted := make(map[string]string)
for key, value := range fields {
if value == "" {
encrypted[key] = ""
continue
}
encValue, err := EncryptString(value)
if err != nil {
return nil, err
}
encrypted[key] = encValue
}
return encrypted, nil
}
// DecryptSensitiveFields helper untuk decrypt multiple fields
func DecryptSensitiveFields(fields map[string]string) (map[string]string, error) {
decrypted := make(map[string]string)
for key, value := range fields {
if value == "" {
decrypted[key] = ""
continue
}
decValue, err := DecryptString(value)
if err != nil {
return nil, err
}
decrypted[key] = decValue
}
return decrypted, nil
}
// MaskSensitiveData untuk logging (mask sebagian data)
// Contoh: "081234567890" -> "0812****7890"
func MaskSensitiveData(data string) string {
if len(data) <= 8 {
return "****"
}
prefix := data[:4]
suffix := data[len(data)-4:]
return prefix + "****" + suffix
}
// ValidateEncryption test apakah encryption bekerja
func ValidateEncryption() error {
testData := "test-sensitive-data-12345"
encrypted, err := EncryptString(testData)
if err != nil {
return err
}
decrypted, err := DecryptString(encrypted)
if err != nil {
return err
}
if decrypted != testData {
return errors.New("encryption validation failed: data mismatch")
}
return nil
}

67
internal/utils/error.go Normal file
View File

@ -0,0 +1,67 @@
// internal/utils/error.go
package utils
import "fmt"
// AppError represents a custom application error
type AppError struct {
Code string
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
// NewAppError creates a new application error
func NewAppError(code, message string, err error) *AppError {
return &AppError{
Code: code,
Message: message,
Err: err,
}
}
// Common error codes
const (
ErrCodeValidation = "VALIDATION_ERROR"
ErrCodeNotFound = "NOT_FOUND"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeInternal = "INTERNAL_ERROR"
ErrCodeDuplicate = "DUPLICATE_ERROR"
ErrCodeBadRequest = "BAD_REQUEST"
)
// Error constructors
func ValidationError(message string) *AppError {
return NewAppError(ErrCodeValidation, message, nil)
}
func NotFoundError(message string) *AppError {
return NewAppError(ErrCodeNotFound, message, nil)
}
func UnauthorizedError(message string) *AppError {
return NewAppError(ErrCodeUnauthorized, message, nil)
}
func ForbiddenError(message string) *AppError {
return NewAppError(ErrCodeForbidden, message, nil)
}
func InternalError(message string, err error) *AppError {
return NewAppError(ErrCodeInternal, message, err)
}
func DuplicateError(message string) *AppError {
return NewAppError(ErrCodeDuplicate, message, nil)
}
func BadRequestError(message string) *AppError {
return NewAppError(ErrCodeBadRequest, message, nil)
}

View File

@ -0,0 +1,100 @@
// internal/utils/excel_export.go
package utils
import (
"bytes"
"fmt"
"github.com/xuri/excelize/v2"
)
// ExcelExporter handles Excel file generation
type ExcelExporter struct {
file *excelize.File
sheetName string
rowIndex int
}
// NewExcelExporter creates a new Excel exporter
func NewExcelExporter() *ExcelExporter {
f := excelize.NewFile()
return &ExcelExporter{
file: f,
sheetName: "Sheet1",
rowIndex: 1,
}
}
// SetSheetName sets the active sheet name
func (e *ExcelExporter) SetSheetName(name string) {
e.file.SetSheetName("Sheet1", name)
e.sheetName = name
}
// AddRow adds a row of data
func (e *ExcelExporter) AddRow(data []string) error {
for colIndex, value := range data {
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
if err := e.file.SetCellValue(e.sheetName, cell, value); err != nil {
return err
}
}
e.rowIndex++
return nil
}
// AddHeader adds a header row with bold style
func (e *ExcelExporter) AddHeader(headers []string) error {
style, err := e.file.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
},
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#D3D3D3"},
Pattern: 1,
},
})
if err != nil {
return err
}
for colIndex, header := range headers {
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
if err := e.file.SetCellValue(e.sheetName, cell, header); err != nil {
return err
}
if err := e.file.SetCellStyle(e.sheetName, cell, cell, style); err != nil {
return err
}
}
e.rowIndex++
return nil
}
// AutoSizeColumns auto-sizes columns
func (e *ExcelExporter) AutoSizeColumns(columnCount int) {
for i := 0; i < columnCount; i++ {
col := getColumnName(i)
e.file.SetColWidth(e.sheetName, col, col, 15)
}
}
// Output returns the Excel file as bytes
func (e *ExcelExporter) Output() (*bytes.Buffer, error) {
buf := new(bytes.Buffer)
if err := e.file.Write(buf); err != nil {
return nil, err
}
return buf, nil
}
// getColumnName converts column index to Excel column name (A, B, C, ..., AA, AB, ...)
func getColumnName(index int) string {
name := ""
for index >= 0 {
name = string(rune('A'+(index%26))) + name
index = index/26 - 1
}
return name
}

18
internal/utils/hash.go Normal file
View File

@ -0,0 +1,18 @@
// internal/utils/hash.go
package utils
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword hashes a password using bcrypt
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash compares a password with a hash
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@ -0,0 +1,210 @@
// internal/utils/image_handler.go
package utils
import (
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"time"
"regexp"
"github.com/nfnt/resize"
)
// ImageHandler handles image upload and processing
type ImageHandler struct {
uploadPath string
maxSize int64
allowedTypes []string
maxWidth uint
maxHeight uint
}
// NewImageHandler creates a new image handler
func NewImageHandler(uploadPath string) *ImageHandler {
return &ImageHandler{
uploadPath: uploadPath,
maxSize: 10 * 1024 * 1024, // 10MB
allowedTypes: []string{"image/jpeg", "image/jpg", "image/png"},
maxWidth: 1920,
maxHeight: 1080,
}
}
// UploadImage uploads and processes an image
// UploadImage uploads and processes an image
func (h *ImageHandler) UploadImage(file *multipart.FileHeader, subfolder string) (string, error) {
// Check file size
if file.Size > h.maxSize {
return "", errors.New("file size exceeds maximum allowed size")
}
// Check file type
if !h.isAllowedType(file.Header.Get("Content-Type")) {
return "", errors.New("file type not allowed")
}
// Open uploaded file
src, err := file.Open()
if err != nil {
return "", err
}
defer src.Close()
// ✅ GENERATE FILENAME DENGAN TIMESTAMP
ext := filepath.Ext(file.Filename)
timestamp := time.Now().Format("20060102_150405") // Format: YYYYMMDD_HHMMSS
baseFilename := strings.TrimSuffix(file.Filename, ext)
// Remove special characters from filename
baseFilename = sanitizeFilename(baseFilename)
filename := fmt.Sprintf("%s_%s%s", baseFilename, timestamp, ext)
// Create upload directory if not exists
uploadDir := filepath.Join(h.uploadPath, subfolder)
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
return "", err
}
// Full file path
filePath := filepath.Join(uploadDir, filename)
// Decode image
img, format, err := image.Decode(src)
if err != nil {
return "", errors.New("invalid image file")
}
// Resize if necessary
if uint(img.Bounds().Dx()) > h.maxWidth || uint(img.Bounds().Dy()) > h.maxHeight {
img = resize.Thumbnail(h.maxWidth, h.maxHeight, img, resize.Lanczos3)
}
// Create destination file
dst, err := os.Create(filePath)
if err != nil {
return "", err
}
defer dst.Close()
// Encode and save image
switch format {
case "jpeg", "jpg":
if err := jpeg.Encode(dst, img, &jpeg.Options{Quality: 90}); err != nil {
return "", err
}
case "png":
if err := png.Encode(dst, img); err != nil {
return "", err
}
default:
return "", errors.New("unsupported image format")
}
// Return relative path
return filepath.Join(subfolder, filename), nil
}
// ✅ TAMBAHKAN HELPER FUNCTION INI
func sanitizeFilename(filename string) string {
// Remove special characters, keep only alphanumeric and hyphens
reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
sanitized := reg.ReplaceAllString(filename, "")
// Limit length
if len(sanitized) > 50 {
sanitized = sanitized[:50]
}
return sanitized
}
// UploadImageSimple uploads image without processing
func (h *ImageHandler) UploadImageSimple(file *multipart.FileHeader, subfolder string) (string, error) {
// Check file size
if file.Size > h.maxSize {
return "", errors.New("file size exceeds maximum allowed size")
}
// Check file type
if !h.isAllowedType(file.Header.Get("Content-Type")) {
return "", errors.New("file type not allowed")
}
// Open uploaded file
src, err := file.Open()
if err != nil {
return "", err
}
defer src.Close()
// Generate unique filename
ext := filepath.Ext(file.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext)
// Create upload directory if not exists
uploadDir := filepath.Join(h.uploadPath, subfolder)
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
return "", err
}
// Full file path
filePath := filepath.Join(uploadDir, filename)
// Create destination file
dst, err := os.Create(filePath)
if err != nil {
return "", err
}
defer dst.Close()
// Copy file
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
// Return relative path
return filepath.Join(subfolder, filename), nil
}
// DeleteImage deletes an image file
func (h *ImageHandler) DeleteImage(relativePath string) error {
if relativePath == "" {
return nil
}
filePath := filepath.Join(h.uploadPath, relativePath)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil // File doesn't exist, no error
}
return os.Remove(filePath)
}
// isAllowedType checks if file type is allowed
func (h *ImageHandler) isAllowedType(contentType string) bool {
for _, allowed := range h.allowedTypes {
if strings.EqualFold(contentType, allowed) {
return true
}
}
return false
}
// generateRandomString generates a random string
func generateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
}
return string(b)
}

View File

@ -0,0 +1,76 @@
// internal/utils/matching.go
package utils
// CalculateMatchScore calculates match score between two items
func CalculateMatchScore(item1, item2 map[string]interface{}) float64 {
nameScore := 0.0
descScore := 0.0
// 1. Name Matching (Bobot 50%)
if name1, ok1 := item1["name"].(string); ok1 {
if name2, ok2 := item2["name"].(string); ok2 {
nameScore = CalculateStringSimilarity(name1, name2) * 100
}
}
// 2. Description/Secret Matching (Bobot 50%)
// Cek apakah ada secret_details, jika tidak pakai description
text1 := ""
if val, ok := item1["secret_details"].(string); ok && val != "" {
text1 = val
} else if val, ok := item1["description"].(string); ok {
text1 = val
}
text2 := ""
if val, ok := item2["secret_details"].(string); ok && val != "" {
text2 = val
} else if val, ok := item2["description"].(string); ok {
text2 = val
}
if text1 != "" && text2 != "" {
descScore = CalculateStringSimilarity(text1, text2) * 100
}
// Total Score = Rata-rata dari keduanya
totalScore := (nameScore * 0.5) + (descScore * 0.5)
return totalScore
}
// MatchItems matches items based on criteria
func MatchItems(lostItem, foundItems []map[string]interface{}, threshold float64) []map[string]interface{} {
var matches []map[string]interface{}
if len(lostItem) == 0 || len(foundItems) == 0 {
return matches
}
lost := lostItem[0]
for _, found := range foundItems {
score := CalculateMatchScore(lost, found)
if score >= threshold {
match := make(map[string]interface{})
match["item"] = found
match["score"] = score
match["level"] = getMatchLevel(score)
matches = append(matches, match)
}
}
return matches
}
// getMatchLevel returns match level based on score
func getMatchLevel(score float64) string {
if score >= 70 {
return "high"
} else if score >= 50 {
return "medium"
}
return "low"
}

View File

@ -0,0 +1,108 @@
// internal/utils/pdf_export.go
package utils
import (
"bytes"
"fmt"
"github.com/jung-kurt/gofpdf"
)
// PDFExporter handles PDF generation
type PDFExporter struct {
pdf *gofpdf.Fpdf
}
// NewPDFExporter creates a new PDF exporter
func NewPDFExporter() *PDFExporter {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// ✅ Coba gunakan font built-in yang pasti ada
pdf.SetFont("Helvetica", "", 12) // Ganti dari "Arial" ke "Helvetica"
return &PDFExporter{
pdf: pdf,
}
}
// AddTitle adds a title to the PDF
func (e *PDFExporter) AddTitle(title string) {
e.pdf.SetFont("Arial", "B", 16)
e.pdf.Cell(0, 10, title)
e.pdf.Ln(12)
e.pdf.SetFont("Arial", "", 12)
}
// AddSubtitle adds a subtitle to the PDF
func (e *PDFExporter) AddSubtitle(subtitle string) {
e.pdf.SetFont("Arial", "I", 11)
e.pdf.Cell(0, 8, subtitle)
e.pdf.Ln(10)
e.pdf.SetFont("Arial", "", 12)
}
// AddText adds regular text
func (e *PDFExporter) AddText(text string) {
e.pdf.SetFont("Arial", "", 10)
e.pdf.MultiCell(0, 6, text, "", "", false)
e.pdf.Ln(4)
}
// AddNewLine adds a new line
func (e *PDFExporter) AddNewLine() {
e.pdf.Ln(6)
}
// AddTable adds a table to the PDF
func (e *PDFExporter) AddTable(headers []string, data [][]string) {
// Calculate column widths
pageWidth, _ := e.pdf.GetPageSize()
margins := 20.0 // Left + Right margins
tableWidth := pageWidth - margins
colWidth := tableWidth / float64(len(headers))
// Add headers
e.pdf.SetFont("Arial", "B", 10)
e.pdf.SetFillColor(200, 200, 200)
for _, header := range headers {
e.pdf.CellFormat(colWidth, 8, header, "1", 0, "C", true, 0, "")
}
e.pdf.Ln(-1)
// Add data rows
e.pdf.SetFont("Arial", "", 9)
e.pdf.SetFillColor(255, 255, 255)
fill := false
for _, row := range data {
for _, cell := range row {
if fill {
e.pdf.SetFillColor(245, 245, 245)
} else {
e.pdf.SetFillColor(255, 255, 255)
}
e.pdf.CellFormat(colWidth, 7, cell, "1", 0, "L", true, 0, "")
}
e.pdf.Ln(-1)
fill = !fill
}
}
// AddPageNumber adds page numbers
func (e *PDFExporter) AddPageNumber() {
e.pdf.AliasNbPages("")
e.pdf.SetY(-15)
e.pdf.SetFont("Arial", "I", 8)
e.pdf.CellFormat(0, 10, fmt.Sprintf("Halaman %d/{nb}", e.pdf.PageNo()), "", 0, "C", false, 0, "")
}
// Output returns the PDF as bytes
func (e *PDFExporter) Output() *bytes.Buffer {
var buf bytes.Buffer
err := e.pdf.Output(&buf)
if err != nil {
return nil
}
return &buf
}

View File

@ -0,0 +1,68 @@
// internal/utils/response.go
package utils
import (
"github.com/gin-gonic/gin"
)
// Response represents a standard API response
type Response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// PaginatedResponse represents a paginated API response
type PaginatedResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data interface{} `json:"data"`
Pagination Pagination `json:"pagination"`
}
// Pagination represents pagination metadata
type Pagination struct {
CurrentPage int `json:"current_page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
TotalRecords int64 `json:"total_records"`
}
// SuccessResponse sends a success response
func SuccessResponse(ctx *gin.Context, statusCode int, message string, data interface{}) {
ctx.JSON(statusCode, Response{
Success: true,
Message: message,
Data: data,
})
}
// ErrorResponse sends an error response
func ErrorResponse(ctx *gin.Context, statusCode int, message string, error string) {
ctx.JSON(statusCode, Response{
Success: false,
Message: message,
Error: error,
})
}
// SendPaginatedResponse sends a paginated response (nama fungsi diubah untuk menghindari konflik)
func SendPaginatedResponse(ctx *gin.Context, statusCode int, message string, data interface{}, total int64, page, limit int) {
totalPages := int(total) / limit
if int(total)%limit != 0 {
totalPages++
}
ctx.JSON(statusCode, PaginatedResponse{
Success: true,
Message: message,
Data: data,
Pagination: Pagination{
CurrentPage: page,
PerPage: limit,
TotalPages: totalPages,
TotalRecords: total,
},
})
}

View File

@ -0,0 +1,160 @@
// internal/utils/similarity.go
package utils
import (
"math"
"strings"
"unicode"
)
// CalculateStringSimilarity calculates similarity between two strings using Levenshtein distance
func CalculateStringSimilarity(s1, s2 string) float64 {
// Normalize strings
s1 = normalizeString(s1)
s2 = normalizeString(s2)
if s1 == s2 {
return 1.0
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
// Calculate Levenshtein distance
distance := levenshteinDistance(s1, s2)
maxLen := math.Max(float64(len(s1)), float64(len(s2)))
similarity := 1.0 - (float64(distance) / maxLen)
return math.Max(0, similarity)
}
// levenshteinDistance calculates the Levenshtein distance between two strings
func levenshteinDistance(s1, s2 string) int {
len1 := len(s1)
len2 := len(s2)
// Create a 2D slice for dynamic programming
dp := make([][]int, len1+1)
for i := range dp {
dp[i] = make([]int, len2+1)
}
// Initialize first row and column
for i := 0; i <= len1; i++ {
dp[i][0] = i
}
for j := 0; j <= len2; j++ {
dp[0][j] = j
}
// Fill the dp table
for i := 1; i <= len1; i++ {
for j := 1; j <= len2; j++ {
cost := 0
if s1[i-1] != s2[j-1] {
cost = 1
}
dp[i][j] = min3(
dp[i-1][j]+1, // deletion
dp[i][j-1]+1, // insertion
dp[i-1][j-1]+cost, // substitution
)
}
}
return dp[len1][len2]
}
// ExtractKeywords extracts keywords from a string
func ExtractKeywords(text string) []string {
// Normalize and split text
text = normalizeString(text)
words := strings.Fields(text)
// Filter stopwords and short words
var keywords []string
stopwords := getStopwords()
for _, word := range words {
if len(word) > 2 && !contains(stopwords, word) {
keywords = append(keywords, word)
}
}
return keywords
}
// FindMatchedKeywords finds common keywords between two lists
func FindMatchedKeywords(keywords1, keywords2 []string) []string {
var matched []string
for _, k1 := range keywords1 {
for _, k2 := range keywords2 {
if strings.EqualFold(k1, k2) || CalculateStringSimilarity(k1, k2) > 0.8 {
if !contains(matched, k1) {
matched = append(matched, k1)
}
break
}
}
}
return matched
}
// normalizeString normalizes a string (lowercase, remove extra spaces)
func normalizeString(s string) string {
// Convert to lowercase
s = strings.ToLower(s)
// Remove punctuation and extra spaces
var result strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) {
result.WriteRune(r)
} else {
result.WriteRune(' ')
}
}
// Remove multiple spaces
s = strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(s)
}
// getStopwords returns common Indonesian stopwords
func getStopwords() []string {
return []string{
"dan", "atau", "dengan", "untuk", "dari", "ke", "di", "yang", "ini", "itu",
"ada", "adalah", "akan", "telah", "sudah", "pada", "oleh", "sebagai", "dalam",
"juga", "saya", "kamu", "dia", "kita", "mereka", "kami", "the", "a", "an",
"of", "to", "in", "for", "on", "at", "by", "with", "from",
}
}
// contains checks if a slice contains a string
func contains(slice []string, str string) bool {
for _, s := range slice {
if strings.EqualFold(s, str) {
return true
}
}
return false
}
// min3 returns the minimum of three integers
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}

View File

@ -0,0 +1,85 @@
// internal/utils/validator.go
package utils
import (
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
var validate *validator.Validate
// InitValidator initializes the validator
func InitValidator() {
validate = validator.New()
// Register custom validators
validate.RegisterValidation("phone", validatePhone)
validate.RegisterValidation("nrp", validateNRP)
}
// ValidateStruct validates a struct
func ValidateStruct(s interface{}) error {
if validate == nil {
InitValidator()
}
return validate.Struct(s)
}
// validatePhone validates Indonesian phone numbers
func validatePhone(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if phone == "" {
return true // Allow empty (use required tag separately)
}
// Remove spaces and dashes
phone = strings.ReplaceAll(phone, " ", "")
phone = strings.ReplaceAll(phone, "-", "")
// Check if starts with 0 or +62 or 62
pattern := `^(0|\+?62)[0-9]{8,12}$`
matched, _ := regexp.MatchString(pattern, phone)
return matched
}
// validateNRP validates NRP (Nomor Registrasi Pokok)
func validateNRP(fl validator.FieldLevel) bool {
nrp := fl.Field().String()
if nrp == "" {
return true // Allow empty (use required tag separately)
}
// NRP format: 10 digits
pattern := `^[0-9]{10}$`
matched, _ := regexp.MatchString(pattern, nrp)
return matched
}
// IsValidEmail checks if email is valid
func IsValidEmail(email string) bool {
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(pattern, email)
return matched
}
// IsValidURL checks if URL is valid
func IsValidURL(url string) bool {
pattern := `^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$`
matched, _ := regexp.MatchString(pattern, url)
return matched
}
// SanitizeString removes potentially dangerous characters
func SanitizeString(s string) string {
// Remove control characters
s = strings.Map(func(r rune) rune {
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
return -1
}
return r
}, s)
return strings.TrimSpace(s)
}

View File

@ -0,0 +1,78 @@
// internal/workers/audit_worker.go - FIXED
package workers
import (
"log"
"lost-and-found/internal/repositories"
"time"
"gorm.io/gorm"
)
// AuditWorker handles audit log background tasks
type AuditWorker struct {
db *gorm.DB
auditLogRepo *repositories.AuditLogRepository
stopChan chan bool
}
// NewAuditWorker creates a new audit worker
func NewAuditWorker(db *gorm.DB) *AuditWorker {
return &AuditWorker{
db: db,
auditLogRepo: repositories.NewAuditLogRepository(db),
stopChan: make(chan bool),
}
}
// Start starts the audit worker
// ✅ FIXED: Now runs in goroutine (non-blocking)
func (w *AuditWorker) Start() {
log.Println("🔍 Audit Worker started")
// ✅ Run in goroutine to avoid blocking
go func() {
ticker := time.NewTicker(24 * time.Hour) // Run daily
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.cleanupOldLogs()
case <-w.stopChan:
log.Println("🔍 Audit Worker stopped")
return
}
}
}()
// ✅ Return immediately after starting goroutine
}
// Stop stops the audit worker
func (w *AuditWorker) Stop() {
close(w.stopChan)
}
// cleanupOldLogs removes audit logs older than 1 year
func (w *AuditWorker) cleanupOldLogs() {
log.Println("🧹 Cleaning up old audit logs...")
cutoffDate := time.Now().AddDate(-1, 0, 0) // 1 year ago
result := w.db.Unscoped().Where("created_at < ?", cutoffDate).Delete(&struct {
tableName struct{} `gorm:"audit_logs"`
}{})
if result.Error != nil {
log.Printf("❌ Failed to cleanup audit logs: %v", result.Error)
return
}
log.Printf("✅ Cleaned up %d old audit log entries", result.RowsAffected)
}
// RunNow runs cleanup immediately (for testing)
func (w *AuditWorker) RunNow() {
log.Println("▶️ Running audit cleanup manually...")
w.cleanupOldLogs()
}

View File

@ -0,0 +1,223 @@
// internal/workers/expire_worker.go - IMPROVED VERSION
package workers
import (
"context"
"log"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ExpireWorker struct {
db *gorm.DB
itemRepo *repositories.ItemRepository
archiveRepo *repositories.ArchiveRepository
stopChan chan bool
wg sync.WaitGroup
mu sync.Mutex
// ✅ Worker pool configuration
maxWorkers int
taskQueue chan *models.Item
}
func NewExpireWorker(db *gorm.DB) *ExpireWorker {
return &ExpireWorker{
db: db,
itemRepo: repositories.NewItemRepository(db),
archiveRepo: repositories.NewArchiveRepository(db),
stopChan: make(chan bool),
maxWorkers: 5, // ✅ Configurable worker count
taskQueue: make(chan *models.Item, 100), // ✅ Buffered channel
}
}
// ✅ Start with proper worker pool
func (w *ExpireWorker) Start() {
w.wg.Add(1)
go func() {
defer w.wg.Done()
log.Println("⏰ Expire Worker started with", w.maxWorkers, "workers")
// ✅ Start worker pool
w.startWorkerPool()
// Run immediately on start
w.expireItems()
// Then run every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.expireItems()
case <-w.stopChan:
log.Println("⏰ Stopping Expire Worker...")
close(w.taskQueue) // ✅ Close task queue to signal workers
return
}
}
}()
}
// ✅ Worker Pool Implementation
func (w *ExpireWorker) startWorkerPool() {
for i := 0; i < w.maxWorkers; i++ {
w.wg.Add(1)
go w.worker(i)
}
}
// ✅ Individual Worker
func (w *ExpireWorker) worker(id int) {
defer w.wg.Done()
log.Printf("🔧 Worker %d started", id)
for item := range w.taskQueue {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
success := w.archiveExpiredItem(ctx, item)
if success {
log.Printf("✅ Worker %d: Archived item ID %d (%s)", id, item.ID, item.Name)
} else {
log.Printf("❌ Worker %d: Failed to archive item ID %d", id, item.ID)
}
cancel()
}
log.Printf("🔧 Worker %d stopped", id)
}
// ✅ Improved expireItems
func (w *ExpireWorker) expireItems() {
log.Println("🔍 Checking for expired items (Using Stored Procedure)...")
// Panggil method repository yang mengeksekusi SP
// Pastikan Anda sudah menambahkan method CallArchiveExpiredProcedure di ItemRepository
// seperti pada langkah 1 jawaban sebelumnya.
count, err := w.itemRepo.CallArchiveExpiredProcedure()
if err != nil {
log.Printf("❌ Error executing archive SP: %v", err)
return
}
if count > 0 {
log.Printf("✅ Successfully archived %d items via DB Procedure", count)
} else {
log.Println("✅ No expired items found to archive")
}
}
func (w *ExpireWorker) findExpiredItems(ctx context.Context) ([]models.Item, error) {
var items []models.Item
err := w.db.WithContext(ctx).
Where("expires_at <= ? AND status = ? AND deleted_at IS NULL",
time.Now(), models.ItemStatusUnclaimed).
Preload("Category").
Find(&items).Error
return items, err
}
// ✅ Archive with proper transaction & locking
func (w *ExpireWorker) archiveExpiredItem(ctx context.Context, item *models.Item) bool {
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// ✅ Lock the item
var lockedItem models.Item
// Locking/Isolation: Menerapkan Pessimistic Lock (FOR UPDATE) pada baris item yang akan diproses, mencegah worker lain atau API call lain memodifikasi item ini selama transaksi berlangsung, menjaga Konsistensi dan Pemulihan.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND deleted_at IS NULL", item.ID).
First(&lockedItem).Error; err != nil {
return err
}
// Double check it's still unclaimed
if lockedItem.Status != models.ItemStatusUnclaimed {
return nil // Already processed
}
// 1. Create archive record
archive := &models.Archive{
ItemID: item.ID,
Name: item.Name,
CategoryID: item.CategoryID,
PhotoURL: item.PhotoURL,
Location: item.Location,
Description: item.Description,
DateFound: item.DateFound,
Status: models.ItemStatusExpired,
ReporterName: item.ReporterName,
ReporterContact: item.ReporterContact,
ArchivedReason: models.ArchiveReasonExpired,
ClaimedBy: nil,
ArchivedAt: time.Now(),
}
if err := tx.Create(archive).Error; err != nil {
return err
}
// 2. Update item status to expired
if err := tx.Model(&lockedItem).Update("status", models.ItemStatusExpired).Error; err != nil {
return err
}
// 3. Create audit log
auditLog := &models.AuditLog{
UserID: nil,
Action: "expire",
EntityType: models.EntityItem,
EntityID: &item.ID,
Details: "Item automatically expired and archived by system",
IPAddress: "system",
UserAgent: "expire_worker",
}
if err := tx.Create(auditLog).Error; err != nil {
log.Printf("Warning: failed to create audit log for item %d: %v", item.ID, err)
}
return nil
})
return err == nil
}
// ✅ Graceful Stop with WaitGroup
func (w *ExpireWorker) Stop() {
log.Println("🛑 Signaling Expire Worker to stop...")
w.stopChan <- true
log.Println("⏳ Waiting for all workers to finish...")
w.wg.Wait()
log.Println("✅ Expire Worker gracefully stopped")
}
// RunNow for manual trigger
func (w *ExpireWorker) RunNow() {
log.Println("▶️ Running expiration check manually...")
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
w.expireItems()
}()
wg.Wait()
log.Println("✅ Manual expiration check completed")
}

View File

@ -0,0 +1,95 @@
// internal/workers/matching_worker.go - FIXED
package workers
import (
"log"
"lost-and-found/internal/repositories"
"lost-and-found/internal/services"
"time"
"gorm.io/gorm"
)
// MatchingWorker handles automatic matching of lost and found items
type MatchingWorker struct {
db *gorm.DB
matchService *services.MatchService
itemRepo *repositories.ItemRepository
lostItemRepo *repositories.LostItemRepository
stopChan chan bool
}
// NewMatchingWorker creates a new matching worker
func NewMatchingWorker(db *gorm.DB) *MatchingWorker {
return &MatchingWorker{
db: db,
matchService: services.NewMatchService(db),
itemRepo: repositories.NewItemRepository(db),
lostItemRepo: repositories.NewLostItemRepository(db),
stopChan: make(chan bool),
}
}
// Start starts the matching worker
// ✅ FIXED: Now runs in goroutine (non-blocking)
func (w *MatchingWorker) Start() {
log.Println("🔗 Matching Worker started")
// ✅ Run in goroutine to avoid blocking
go func() {
// Run every 30 minutes
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.performMatching()
case <-w.stopChan:
log.Println("🔗 Matching Worker stopped")
return
}
}
}()
// ✅ Return immediately after starting goroutine
}
// Stop stops the matching worker
func (w *MatchingWorker) Stop() {
close(w.stopChan)
}
// performMatching performs automatic matching between lost and found items
func (w *MatchingWorker) performMatching() {
log.Println("🔍 Performing automatic matching...")
// Get all unclaimed items
items, _, err := w.itemRepo.FindAll(1, 1000, "unclaimed", "", "")
if err != nil {
log.Printf("❌ Error fetching items: %v", err)
return
}
if len(items) == 0 {
log.Println("✅ No unclaimed items to match")
return
}
matchCount := 0
for _, item := range items {
// Auto-match with lost items
if err := w.matchService.AutoMatchNewItem(item.ID); err != nil {
log.Printf("❌ Failed to match item ID %d: %v", item.ID, err)
continue
}
matchCount++
}
log.Printf("✅ Completed matching for %d items", matchCount)
}
// RunNow runs matching immediately (for testing)
func (w *MatchingWorker) RunNow() {
log.Println("▶️ Running matching manually...")
w.performMatching()
}

View File

@ -0,0 +1,122 @@
// internal/workers/notification_worker.go - FIXED
package workers
import (
"log"
"lost-and-found/internal/models"
"lost-and-found/internal/repositories"
"time"
"gorm.io/gorm"
)
// NotificationWorker handles sending notifications asynchronously
type NotificationWorker struct {
db *gorm.DB
notificationRepo *repositories.NotificationRepository
matchRepo *repositories.MatchResultRepository
stopChan chan bool
}
// NewNotificationWorker creates a new notification worker
func NewNotificationWorker(db *gorm.DB) *NotificationWorker {
return &NotificationWorker{
db: db,
notificationRepo: repositories.NewNotificationRepository(db),
matchRepo: repositories.NewMatchResultRepository(db),
stopChan: make(chan bool),
}
}
// Start starts the notification worker
// ✅ FIXED: Now runs in goroutine (non-blocking)
func (w *NotificationWorker) Start() {
log.Println("📬 Notification Worker started")
// ✅ Run in goroutine to avoid blocking
go func() {
// Run every 5 minutes
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.sendPendingNotifications()
case <-w.stopChan:
log.Println("📬 Notification Worker stopped")
return
}
}
}()
// ✅ Return immediately after starting goroutine
}
// Stop stops the notification worker
func (w *NotificationWorker) Stop() {
close(w.stopChan)
}
// sendPendingNotifications sends notifications for unnotified matches
func (w *NotificationWorker) sendPendingNotifications() {
log.Println("📧 Checking for pending notifications...")
// Get unnotified matches
matches, err := w.matchRepo.FindUnnotifiedMatches()
if err != nil {
log.Printf("❌ Error fetching unnotified matches: %v", err)
return
}
if len(matches) == 0 {
log.Println("✅ No pending notifications")
return
}
log.Printf("📧 Found %d pending notifications", len(matches))
sentCount := 0
for _, match := range matches {
// Send notification to lost item owner
if err := w.sendMatchNotification(&match); err != nil {
log.Printf("❌ Failed to send notification for match ID %d: %v", match.ID, err)
continue
}
// Mark as notified
if err := w.matchRepo.MarkAsNotified(match.ID); err != nil {
log.Printf("❌ Failed to mark match ID %d as notified: %v", match.ID, err)
continue
}
sentCount++
}
log.Printf("✅ Sent %d notifications", sentCount)
}
// sendMatchNotification sends a match notification
func (w *NotificationWorker) sendMatchNotification(match *models.MatchResult) error {
// Create notification
err := models.CreateMatchNotification(
w.db,
match.LostItem.UserID,
match.Item.Name,
match.ID,
)
if err != nil {
return err
}
log.Printf("📧 Sent match notification to user ID %d for item: %s",
match.LostItem.UserID, match.Item.Name)
return nil
}
// RunNow runs notification sending immediately (for testing)
func (w *NotificationWorker) RunNow() {
log.Println("▶️ Running notification sending manually...")
w.sendPendingNotifications()
}

0
uploads/claims/.gitkeep Normal file
View File

0
uploads/items/.gitkeep Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Some files were not shown because too many files have changed in this diff Show More