diff --git a/lost-and-found/.env b/lost-and-found/.env
new file mode 100644
index 0000000..67e0643
--- /dev/null
+++ b/lost-and-found/.env
@@ -0,0 +1,23 @@
+# Server Configuration
+PORT=8080
+ENVIRONMENT=development
+
+# Database Configuration (MySQL/MariaDB)
+DB_HOST=localhost
+DB_PORT=3306
+DB_USER=root
+DB_PASSWORD=
+DB_NAME=lost_and_found
+DB_CHARSET=utf8mb4
+DB_PARSE_TIME=True
+DB_LOC=Local
+
+# JWT Configuration
+JWT_SECRET_KEY=L0stF0und$ecureK3y2024!@#M4h4s1sw4UAS*Pr0j3ct&BackendD3v
+
+# Upload Configuration
+UPLOAD_PATH=./uploads
+MAX_UPLOAD_SIZE=10485760
+
+# CORS Configuration
+ALLOWED_ORIGINS=*
\ No newline at end of file
diff --git a/lost-and-found/.vscode/code-counter/code-counter.db b/lost-and-found/.vscode/code-counter/code-counter.db
new file mode 100644
index 0000000..62024f1
Binary files /dev/null and b/lost-and-found/.vscode/code-counter/code-counter.db differ
diff --git a/lost-and-found/.vscode/code-counter/reports/code-counter-report.html b/lost-and-found/.vscode/code-counter/reports/code-counter-report.html
new file mode 100644
index 0000000..ccd1b17
--- /dev/null
+++ b/lost-and-found/.vscode/code-counter/reports/code-counter-report.html
@@ -0,0 +1 @@
+
Code Counter ReportError:
📁 Files
\ No newline at end of file
diff --git a/lost-and-found/Makefile b/lost-and-found/Makefile
new file mode 100644
index 0000000..e69de29
diff --git a/lost-and-found/README.md b/lost-and-found/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/lost-and-found/cmd/server/main.go b/lost-and-found/cmd/server/main.go
new file mode 100644
index 0000000..14f61ad
--- /dev/null
+++ b/lost-and-found/cmd/server/main.go
@@ -0,0 +1,121 @@
+// main.go
+package main
+
+import (
+ "log"
+ "lost-and-found/internal/config"
+ "lost-and-found/internal/middleware"
+ "lost-and-found/internal/routes"
+ "lost-and-found/internal/workers"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/gin-gonic/gin"
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ // Load .env file
+ if err := godotenv.Load(); err != nil {
+ log.Println("⚠️ No .env file found, using environment variables")
+ }
+
+ // Initialize JWT config
+ config.InitJWT()
+
+ // Initialize database
+ if err := config.InitDB(); err != nil {
+ log.Fatalf("❌ Failed to initialize database: %v", err)
+ }
+ defer config.CloseDB()
+
+ // Run migrations
+ db := config.GetDB()
+ if err := config.RunMigrations(db); err != nil {
+ log.Fatalf("❌ Failed to run migrations: %v", 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())
+
+ // Serve static files (uploads)
+ router.Static("/uploads", "./uploads")
+ router.Static("/css", "./web/css")
+ router.Static("/js", "./web/js")
+
+ // Frontend routes - REFACTORED: nama file lebih simple
+ 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("/register", func(c *gin.Context) {
+ c.File("./web/register.html")
+ })
+ // ✅ REFACTORED: URL dan nama file lebih clean
+ router.GET("/admin", func(c *gin.Context) {
+ c.File("./web/admin.html")
+ })
+ router.GET("/manager", func(c *gin.Context) {
+ c.File("./web/manager.html")
+ })
+ router.GET("/user", func(c *gin.Context) {
+ c.File("./web/user.html")
+ })
+
+ // Setup API routes
+ routes.SetupRoutes(router, db)
+
+ // Start background workers
+ expireWorker := workers.NewExpireWorker(db)
+ auditWorker := workers.NewAuditWorker(db)
+ matchingWorker := workers.NewMatchingWorker(db)
+ notificationWorker := workers.NewNotificationWorker(db)
+
+ go expireWorker.Start()
+ go auditWorker.Start()
+ go matchingWorker.Start()
+ go notificationWorker.Start()
+
+ // Setup graceful shutdown
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ // Get server config
+ serverConfig := config.GetServerConfig()
+ port := serverConfig.Port
+
+ // Start server
+ go func() {
+ log.Printf("🚀 Server started on http://localhost:%s", port)
+ log.Printf("🔌 API available at http://localhost:%s/api", port)
+ log.Printf("🌐 Frontend available at http://localhost:%s", port)
+
+ if err := router.Run(":" + port); err != nil {
+ log.Fatalf("❌ Failed to start server: %v", err)
+ }
+ }()
+
+ // Wait for interrupt signal
+ <-quit
+ log.Println("\n🛑 Shutting down server...")
+
+ // Stop workers
+ expireWorker.Stop()
+ auditWorker.Stop()
+ matchingWorker.Stop()
+ notificationWorker.Stop()
+
+ log.Println("✅ Server exited gracefully")
+}
\ No newline at end of file
diff --git a/lost-and-found/database/schema.sql b/lost-and-found/database/schema.sql
new file mode 100644
index 0000000..241f7ae
--- /dev/null
+++ b/lost-and-found/database/schema.sql
@@ -0,0 +1,300 @@
+-- Lost & Found Database Schema
+-- 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 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 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,
+ 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) UNIQUE DEFAULT NULL,
+ phone VARCHAR(20) DEFAULT NULL,
+ role_id INT UNSIGNED NOT NULL DEFAULT 3,
+ status VARCHAR(20) DEFAULT 'active',
+ 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),
+ 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,
+ 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 'RAHASIA - untuk verifikasi',
+ date_found TIMESTAMP NOT NULL,
+ status VARCHAR(50) DEFAULT 'unclaimed',
+ reporter_id INT UNSIGNED NOT NULL,
+ reporter_name VARCHAR(100) NOT NULL,
+ reporter_contact VARCHAR(50) NOT NULL,
+ expires_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 (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,
+ date_lost TIMESTAMP NOT NULL,
+ status VARCHAR(50) DEFAULT 'active',
+ 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;
+
+-- ============================================
+-- 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',
+ notes TEXT DEFAULT NULL,
+ 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,
+ matched_keywords TEXT DEFAULT NULL,
+ verification_notes TEXT DEFAULT NULL,
+ is_auto_matched BOOLEAN DEFAULT FALSE,
+ 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;
+
+-- ============================================
+-- 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,
+ matched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ is_notified BOOLEAN DEFAULT FALSE,
+ 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 TIMESTAMP NULL DEFAULT NULL,
+ 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;
+
+-- ============================================
+-- 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,
+ 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: 13' AS Info;
+SELECT '🔑 Indexes created on all tables' AS Info;
+SELECT '📝 Next step: Run seed.sql to populate initial data' AS NextStep;
\ No newline at end of file
diff --git a/lost-and-found/database/seed.sql b/lost-and-found/database/seed.sql
new file mode 100644
index 0000000..db4c790
--- /dev/null
+++ b/lost-and-found/database/seed.sql
@@ -0,0 +1,202 @@
+-- Lost & Found Database Seed Data
+-- MySQL/MariaDB Database
+
+SET NAMES utf8mb4;
+SET CHARACTER SET utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ============================================
+-- CLEAR EXISTING DATA (Optional - untuk re-seed)
+-- ============================================
+-- TRUNCATE TABLE notifications;
+-- TRUNCATE TABLE revision_logs;
+-- TRUNCATE TABLE match_results;
+-- TRUNCATE TABLE claim_verifications;
+-- TRUNCATE TABLE audit_logs;
+-- TRUNCATE TABLE archives;
+-- TRUNCATE TABLE claims;
+-- TRUNCATE TABLE items;
+-- TRUNCATE TABLE lost_items;
+-- TRUNCATE TABLE categories;
+-- TRUNCATE TABLE users;
+-- TRUNCATE TABLE roles;
+
+-- ============================================
+-- SEED ROLES
+-- ============================================
+INSERT INTO roles (name, description) VALUES
+('admin', 'Administrator with full access'),
+('manager', 'Manager/Cleaning Service - can verify claims'),
+('user', 'Regular user/student');
+
+-- ============================================
+-- SEED CATEGORIES
+-- ============================================
+INSERT INTO categories (name, slug, description) VALUES
+('Pakaian', 'pakaian', 'Jaket, baju, celana, dll'),
+('Alat Makan', 'alat_makan', 'Botol minum, lunchbox, dll'),
+('Aksesoris', 'aksesoris', 'Jam tangan, gelang, kacamata, dll'),
+('Elektronik', 'elektronik', 'Kalkulator, mouse, headset, charger, dll'),
+('Alat Tulis', 'alat_tulis', 'Buku, pulpen, tipe-x, dll'),
+('Lainnya', 'lainnya', 'Kategori lainnya');
+
+-- ============================================
+-- SEED USERS
+-- Password untuk semua user: "password123"
+-- Hash bcrypt: $2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
+-- ============================================
+
+-- Admin User
+INSERT INTO users (name, email, password, nrp, phone, role_id, status) VALUES
+('Admin System', 'admin@lostandfound.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '1234567890', '081234567890', 1, 'active');
+
+-- Manager Users
+INSERT INTO users (name, email, password, nrp, phone, role_id, status) VALUES
+('Pak Budi', 'manager1@lostandfound.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '2234567890', '081234567891', 2, 'active'),
+('Bu Siti', 'manager2@lostandfound.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '2234567891', '081234567892', 2, 'active');
+
+-- Regular Users (Students)
+INSERT INTO users (name, email, password, nrp, phone, role_id, status) VALUES
+('Ahmad Rizki', 'ahmad@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211001', '081234567893', 3, 'active'),
+('Siti Nurhaliza', 'siti@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211002', '081234567894', 3, 'active'),
+('Budi Santoso', 'budi@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211003', '081234567895', 3, 'active'),
+('Dewi Lestari', 'dewi@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211004', '081234567896', 3, 'active'),
+('Eko Prasetyo', 'eko@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211005', '081234567897', 3, 'active');
+
+-- ============================================
+-- SEED ITEMS (Barang Ditemukan)
+-- ============================================
+INSERT INTO items (name, category_id, photo_url, location, description, date_found, status, reporter_id, reporter_name, reporter_contact, expires_at) VALUES
+-- Pakaian
+('Jaket Hitam Nike', 1, '', 'Gedung A lantai 2', 'Jaket hitam merk Nike ukuran M, ada logo putih di dada kiri, resleting berwarna silver', '2024-01-15 09:30:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-04-15 09:30:00'),
+('Sweater Abu-abu', 1, '', 'Perpustakaan', 'Sweater abu-abu polos, ada tulisan "ITS" di punggung dengan huruf biru', '2024-01-18 14:20:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-04-18 14:20:00'),
+
+-- Alat Makan
+('Botol Minum Tupperware Biru', 2, '', 'Kantin Utama', 'Botol minum tupperware warna biru tua, ada stiker nama "RARA" di samping, tutup warna putih', '2024-01-20 12:00:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-04-20 12:00:00'),
+('Lunchbox Pink', 2, '', 'Gedung B lantai 1', 'Kotak makan plastik warna pink dengan 2 sekat, ada gambar Hello Kitty di tutup', '2024-01-22 11:30:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-04-22 11:30:00'),
+
+-- Elektronik
+('Kalkulator Casio FX-991ID Plus', 4, '', 'Ruang Kelas C101', 'Kalkulator scientific Casio FX-991ID Plus, warna hitam, ada goresan kecil di layar', '2024-01-25 08:15:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-04-25 08:15:00'),
+('Mouse Wireless Logitech', 4, '', 'Lab Komputer', 'Mouse wireless Logitech M185, warna merah, tidak ada baterai dan receiver USB', '2024-01-28 15:45:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-04-28 15:45:00'),
+('Charger Laptop HP', 4, '', 'Perpustakaan meja 15', 'Charger laptop HP original 65W, kabel agak kusut, ujung konektor bulat', '2024-02-01 10:20:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-05-01 10:20:00'),
+
+-- Aksesoris
+('Jam Tangan Casio G-Shock', 3, '', 'Toilet Gedung A', 'Jam tangan Casio G-Shock warna hitam dengan strip orange, tali karet, ada goresan di layar', '2024-02-03 13:00:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-05-03 13:00:00'),
+('Kacamata Minus', 3, '', 'Masjid Kampus', 'Kacamata minus frame hitam persegi, lensa agak tebal, ada case warna coklat', '2024-02-05 16:30:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-05-05 16:30:00'),
+
+-- Alat Tulis
+('Buku Kalkulus', 5, '', 'Gedung C lantai 3', 'Buku Kalkulus karangan Purcell edisi 9, sampul biru, ada coretan nama di halaman pertama (nama dicoret)', '2024-02-08 09:00:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-05-08 09:00:00'),
+('Pensil Mekanik Rotring', 5, '', 'Studio Gambar', 'Pensil mekanik Rotring Rapid Pro 0.5mm warna silver, agak berat, ada penyok kecil di badan', '2024-02-10 14:45:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-05-10 14:45:00');
+
+-- ============================================
+-- SEED LOST_ITEMS (Barang Hilang)
+-- ============================================
+INSERT INTO lost_items (user_id, name, category_id, color, location, description, date_lost, status) VALUES
+-- User Ahmad
+(4, 'Jaket Hitam', 1, 'Hitam', 'Gedung A', 'Jaket hitam merk Nike ukuran M dengan logo putih di dada', '2024-01-15 08:00:00', 'active'),
+(4, 'Kalkulator Casio Scientific', 4, 'Hitam', 'Ruang Kelas', 'Kalkulator scientific Casio FX-991ID Plus warna hitam', '2024-01-25 07:00:00', 'active'),
+
+-- User Siti
+(5, 'Botol Minum Biru', 2, 'Biru', 'Kantin', 'Botol tupperware biru dengan stiker nama RARA', '2024-01-20 11:00:00', 'active'),
+(5, 'Jam Tangan Casio', 3, 'Hitam', 'Toilet', 'Jam tangan G-Shock hitam dengan strip orange', '2024-02-03 12:00:00', 'active'),
+
+-- User Budi
+(6, 'Mouse Wireless', 4, 'Merah', 'Lab Komputer', 'Mouse wireless Logitech warna merah tanpa receiver', '2024-01-28 14:00:00', 'active'),
+
+-- User Dewi
+(7, 'Buku Kalkulus', 5, 'Biru', 'Gedung C', 'Buku Kalkulus Purcell edisi 9 sampul biru', '2024-02-08 08:00:00', 'active'),
+
+-- User Eko
+(8, 'Kacamata Minus', 3, 'Hitam', 'Masjid', 'Kacamata frame hitam persegi dengan case coklat', '2024-02-05 15:00:00', 'active');
+
+-- ============================================
+-- SEED CLAIMS (Klaim Barang)
+-- ============================================
+INSERT INTO claims (item_id, user_id, description, proof_url, contact, status, notes, verified_at, verified_by) VALUES
+-- Claim pending (belum diverifikasi)
+(1, 4, 'Jaket Nike hitam ukuran M, ada logo putih di dada kiri, resleting silver', '', '081234567893', 'pending', NULL, NULL, NULL),
+(3, 5, 'Botol tupperware biru dengan stiker nama RARA di samping, tutup putih', '', '081234567894', 'pending', NULL, NULL, NULL),
+
+-- Claim yang sudah approved
+(5, 4, 'Kalkulator Casio FX-991ID Plus warna hitam, ada goresan kecil di layar', '', '081234567893', 'approved', 'Deskripsi cocok, barang diserahkan', DATE_SUB(NOW(), INTERVAL 2 DAY), 2);
+
+-- ============================================
+-- SEED CLAIM_VERIFICATIONS
+-- ============================================
+INSERT INTO claim_verifications (claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched) VALUES
+(1, 85.50, '["jaket", "nike", "hitam", "logo", "putih", "resleting", "silver"]', 'High similarity detected', FALSE),
+(2, 92.30, '["botol", "tupperware", "biru", "stiker", "rara", "tutup", "putih"]', 'Very high similarity - likely owner', FALSE),
+(3, 88.70, '["kalkulator", "casio", "hitam", "goresan", "layar"]', 'Verified and approved', FALSE);
+
+-- ============================================
+-- SEED MATCH_RESULTS (Auto-Matching)
+-- ============================================
+INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, matched_at, is_notified) VALUES
+(1, 1, 87.50, '{"name": 85, "category": 100, "description": 90}', '2024-01-15 10:00:00', TRUE),
+(2, 5, 89.20, '{"name": 88, "category": 100, "description": 91}', '2024-01-25 09:00:00', TRUE),
+(3, 3, 91.80, '{"name": 90, "category": 100, "color": 100, "description": 89}', '2024-01-20 13:00:00', TRUE),
+(4, 8, 86.40, '{"name": 82, "category": 100, "color": 100, "description": 85}', '2024-02-03 14:00:00', TRUE),
+(5, 6, 84.60, '{"name": 80, "category": 100, "color": 100, "description": 88}', '2024-01-28 16:00:00', FALSE);
+
+-- ============================================
+-- SEED NOTIFICATIONS
+-- ============================================
+INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, is_read, read_at) VALUES
+-- Notifikasi match ditemukan
+(4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Jaket Hitam Nike', 'match', 1, TRUE, DATE_SUB(NOW(), INTERVAL 1 DAY)),
+(4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Kalkulator Casio FX-991ID Plus', 'match', 2, FALSE, NULL),
+(5, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Botol Minum Tupperware Biru', 'match', 3, FALSE, NULL),
+
+-- Notifikasi klaim disetujui
+(4, 'claim_approved', 'Klaim Disetujui!', 'Klaim Anda untuk barang Kalkulator Casio FX-991ID Plus telah disetujui. Silakan ambil barang di tempat yang ditentukan.', 'claim', 3, FALSE, NULL),
+
+-- Notifikasi untuk manager (pending claims)
+(2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Jaket Hitam Nike dari Ahmad Rizki', 'claim', 1, FALSE, NULL),
+(2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Botol Minum Tupperware Biru dari Siti Nurhaliza', 'claim', 2, FALSE, NULL);
+
+-- ============================================
+-- SEED AUDIT_LOGS
+-- ============================================
+INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES
+-- Login activities
+(1, 'login', 'user', 1, 'Admin logged in', '127.0.0.1', 'Mozilla/5.0'),
+(2, 'login', 'user', 2, 'Manager logged in', '127.0.0.1', 'Mozilla/5.0'),
+(4, 'login', 'user', 4, 'User logged in', '127.0.0.1', 'Mozilla/5.0'),
+
+-- Item creation activities
+(2, 'create', 'item', 1, 'Item created: Jaket Hitam Nike', '127.0.0.1', 'Mozilla/5.0'),
+(3, 'create', 'item', 2, 'Item created: Sweater Abu-abu', '127.0.0.1', 'Mozilla/5.0'),
+(2, 'create', 'item', 3, 'Item created: Botol Minum Tupperware Biru', '127.0.0.1', 'Mozilla/5.0'),
+
+-- Lost item creation activities
+(4, 'create', 'lost_item', 1, 'Lost item report created: Jaket Hitam', '127.0.0.1', 'Mozilla/5.0'),
+(5, 'create', 'lost_item', 3, 'Lost item report created: Botol Minum Biru', '127.0.0.1', 'Mozilla/5.0'),
+
+-- Claim activities
+(4, 'create', 'claim', 1, 'Claim created for item: Jaket Hitam Nike', '127.0.0.1', 'Mozilla/5.0'),
+(5, 'create', 'claim', 2, 'Claim created for item: Botol Minum Tupperware Biru', '127.0.0.1', 'Mozilla/5.0'),
+(4, 'create', 'claim', 3, 'Claim created for item: Kalkulator Casio FX-991ID Plus', '127.0.0.1', 'Mozilla/5.0'),
+
+-- Claim verification activity
+(2, 'approve', 'claim', 3, 'Claim approved: Deskripsi cocok, barang diserahkan', '127.0.0.1', 'Mozilla/5.0');
+
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- ============================================
+-- SUCCESS MESSAGE
+-- ============================================
+SELECT '✅ Database seeded successfully!' AS Status;
+SELECT '👥 Users created: 8 (1 admin, 2 managers, 5 students)' AS Info;
+SELECT '📦 Items created: 11 found items' AS Info;
+SELECT '🔍 Lost items created: 7 reports' AS Info;
+SELECT '📋 Claims created: 3 (1 approved, 2 pending)' AS Info;
+SELECT '🔔 Notifications created: 6' AS Info;
+SELECT '📝 Audit logs created: 11' AS Info;
+SELECT '' AS Empty;
+SELECT '🔐 Login Credentials:' AS Credentials;
+SELECT 'Admin: admin@lostandfound.com / password123' AS Admin;
+SELECT 'Manager: manager1@lostandfound.com / password123' AS Manager1;
+SELECT 'Manager: manager2@lostandfound.com / password123' AS Manager2;
+SELECT 'Student: ahmad@student.com / password123' AS Student;
+SELECT '' AS Empty2;
+SELECT '🚀 Ready to use! Start the server with: make run' AS NextStep;
\ No newline at end of file
diff --git a/go.mod b/lost-and-found/go.mod
similarity index 100%
rename from go.mod
rename to lost-and-found/go.mod
diff --git a/lost-and-found/go.sum b/lost-and-found/go.sum
new file mode 100644
index 0000000..d307780
--- /dev/null
+++ b/lost-and-found/go.sum
@@ -0,0 +1,131 @@
+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/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/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/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/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/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
+go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+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/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=
diff --git a/lost-and-found/internal/config/config.go b/lost-and-found/internal/config/config.go
new file mode 100644
index 0000000..0d14ed2
--- /dev/null
+++ b/lost-and-found/internal/config/config.go
@@ -0,0 +1,66 @@
+package config
+
+import (
+ "os"
+)
+
+// Config holds all configuration for the application
+type Config struct {
+ Database DatabaseConfig
+ JWT JWTConfig
+ Server ServerConfig
+}
+
+// ServerConfig holds server configuration
+type ServerConfig struct {
+ Port string
+ Environment string
+ UploadPath string
+ MaxUploadSize int64
+ AllowedOrigins []string
+}
+
+// GetConfig returns the application configuration
+func GetConfig() *Config {
+ return &Config{
+ Database: GetDatabaseConfig(),
+ JWT: GetJWTConfig(),
+ Server: GetServerConfig(),
+ }
+}
+
+// GetServerConfig returns server configuration from environment
+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, // 10MB
+ AllowedOrigins: []string{"*"}, // In production, specify exact origins
+ }
+}
+
+// IsProduction checks if running in production environment
+func IsProduction() bool {
+ return os.Getenv("ENVIRONMENT") == "production"
+}
+
+// IsDevelopment checks if running in development environment
+func IsDevelopment() bool {
+ return os.Getenv("ENVIRONMENT") != "production"
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/config/database.go b/lost-and-found/internal/config/database.go
new file mode 100644
index 0000000..316c958
--- /dev/null
+++ b/lost-and-found/internal/config/database.go
@@ -0,0 +1,145 @@
+package config
+
+import (
+ "fmt"
+ "log"
+ "lost-and-found/internal/models"
+ "os"
+ "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", "localhost"),
+ Port: getEnv("DB_PORT", "3306"),
+ User: getEnv("DB_USER", "root"),
+ Password: getEnv("DB_PASSWORD", ""),
+ DBName: getEnv("DB_NAME", "lost_and_found"),
+ 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()
+
+ // Build DSN (Data Source Name) for MySQL
+ dsn := fmt.Sprintf(
+ "%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s",
+ 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
+}
+
+// GetDB returns the database instance
+func GetDB() *gorm.DB {
+ return db
+}
+
+// RunMigrations runs database migrations
+func RunMigrations(db *gorm.DB) error {
+ log.Println("⚠️ Auto-migration is disabled. Please run schema.sql manually via HeidiSQL")
+ log.Println("📋 Steps:")
+ log.Println(" 1. Open HeidiSQL")
+ log.Println(" 2. Connect to your MySQL database")
+ log.Println(" 3. Create database 'lost_and_found' if not exists")
+ log.Println(" 4. Run database/schema.sql")
+ log.Println(" 5. Run database/seed.sql (optional)")
+
+ // Check if tables exist
+ if !db.Migrator().HasTable(&models.Role{}) {
+ return fmt.Errorf("❌ Tables not found. Please run database/schema.sql first")
+ }
+
+ log.Println("✅ Database tables detected")
+ return nil
+}
+
+// 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/config/jwt.go b/lost-and-found/internal/config/jwt.go
new file mode 100644
index 0000000..46b66d3
--- /dev/null
+++ b/lost-and-found/internal/config/jwt.go
@@ -0,0 +1,131 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/admin_controller.go b/lost-and-found/internal/controllers/admin_controller.go
new file mode 100644
index 0000000..bd35d82
--- /dev/null
+++ b/lost-and-found/internal/controllers/admin_controller.go
@@ -0,0 +1,98 @@
+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 {
+ userService *services.UserService
+ itemRepo *repositories.ItemRepository
+ claimRepo *repositories.ClaimRepository
+ archiveRepo *repositories.ArchiveRepository
+ auditService *services.AuditService
+}
+
+func NewAdminController(db *gorm.DB) *AdminController {
+ return &AdminController{
+ userService: services.NewUserService(db),
+ itemRepo: repositories.NewItemRepository(db),
+ claimRepo: repositories.NewClaimRepository(db),
+ archiveRepo: repositories.NewArchiveRepository(db),
+ auditService: services.NewAuditService(db),
+ }
+}
+
+// GetDashboardStats gets dashboard statistics (admin/manager)
+// GET /api/admin/dashboard
+func (c *AdminController) GetDashboardStats(ctx *gin.Context) {
+ stats := make(map[string]interface{})
+
+ // Item statistics
+ totalItems, _ := c.itemRepo.CountByStatus("")
+ unclaimedItems, _ := c.itemRepo.CountByStatus("unclaimed")
+ verifiedItems, _ := c.itemRepo.CountByStatus("verified")
+ expiredItems, _ := c.itemRepo.CountByStatus("expired")
+
+ stats["items"] = map[string]interface{}{
+ "total": totalItems,
+ "unclaimed": unclaimedItems,
+ "verified": verifiedItems,
+ "expired": expiredItems,
+ }
+
+ // Claim statistics
+ totalClaims, _ := c.claimRepo.CountByStatus("")
+ pendingClaims, _ := c.claimRepo.CountByStatus("pending")
+ approvedClaims, _ := c.claimRepo.CountByStatus("approved")
+ rejectedClaims, _ := c.claimRepo.CountByStatus("rejected")
+
+ stats["claims"] = map[string]interface{}{
+ "total": totalClaims,
+ "pending": pendingClaims,
+ "approved": approvedClaims,
+ "rejected": rejectedClaims,
+ }
+
+ // Archive statistics
+ archivedExpired, _ := c.archiveRepo.CountByReason("expired")
+ archivedClosed, _ := c.archiveRepo.CountByReason("case_closed")
+
+ stats["archives"] = map[string]interface{}{
+ "total": archivedExpired + archivedClosed,
+ "expired": archivedExpired,
+ "case_closed": archivedClosed,
+ }
+
+ 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/archive_controller.go b/lost-and-found/internal/controllers/archive_controller.go
new file mode 100644
index 0000000..8e8dcea
--- /dev/null
+++ b/lost-and-found/internal/controllers/archive_controller.go
@@ -0,0 +1,68 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/auth_controller.go b/lost-and-found/internal/controllers/auth_controller.go
new file mode 100644
index 0000000..04936ab
--- /dev/null
+++ b/lost-and-found/internal/controllers/auth_controller.go
@@ -0,0 +1,102 @@
+package controllers
+
+import (
+ "lost-and-found/internal/services"
+ "lost-and-found/internal/utils"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+type AuthController struct {
+ authService *services.AuthService
+}
+
+func NewAuthController(db *gorm.DB) *AuthController {
+ return &AuthController{
+ authService: services.NewAuthService(db),
+ }
+}
+
+// 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/category_controller.go b/lost-and-found/internal/controllers/category_controller.go
new file mode 100644
index 0000000..69f326f
--- /dev/null
+++ b/lost-and-found/internal/controllers/category_controller.go
@@ -0,0 +1,129 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/claim_controller.go b/lost-and-found/internal/controllers/claim_controller.go
new file mode 100644
index 0000000..53fdc91
--- /dev/null
+++ b/lost-and-found/internal/controllers/claim_controller.go
@@ -0,0 +1,247 @@
+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
+ }
+
+ utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
+}
+
+// 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()
+ }
+
+ 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)
+}
+
+// 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)
+}
+
+// CloseClaim closes a claim (manager only)
+// POST /api/claims/:id/close
+func (c *ClaimController) CloseClaim(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
+ }
+
+ ipAddress := ctx.ClientIP()
+ userAgent := ctx.Request.UserAgent()
+
+ if err := c.claimService.CloseClaim(manager.ID, uint(claimID), ipAddress, userAgent); err != nil {
+ utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to close claim", err.Error())
+ return
+ }
+
+ utils.SuccessResponse(ctx, http.StatusOK, "Claim closed and archived", nil)
+}
+
+// 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)
+}
+
+// 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)
+}
+
+// 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/item_controller.go b/lost-and-found/internal/controllers/item_controller.go
new file mode 100644
index 0000000..a90bb96
--- /dev/null
+++ b/lost-and-found/internal/controllers/item_controller.go
@@ -0,0 +1,222 @@
+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 ItemController struct {
+ itemService *services.ItemService
+ matchService *services.MatchService
+}
+
+func NewItemController(db *gorm.DB) *ItemController {
+ return &ItemController{
+ itemService: services.NewItemService(db),
+ matchService: services.NewMatchService(db),
+ }
+}
+
+// GetAllItems gets all items (public)
+// GET /api/items
+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")
+
+ 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)
+}
+
+// GetItemByID gets item by ID
+// GET /api/items/:id
+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()
+ }
+
+ item, err := c.itemService.GetItemByID(uint(id), isManager)
+ if err != nil {
+ utils.ErrorResponse(ctx, http.StatusNotFound, "Item not found", err.Error())
+ return
+ }
+
+ utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/lost_item_controller.go b/lost-and-found/internal/controllers/lost_item_controller.go
new file mode 100644
index 0000000..ca3a758
--- /dev/null
+++ b/lost-and-found/internal/controllers/lost_item_controller.go
@@ -0,0 +1,193 @@
+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")
+
+ var userID *uint
+ // If manager/admin, can see all. If user, only see their own
+ if userObj, exists := ctx.Get("user"); exists {
+ user := userObj.(*models.User)
+ if user.IsUser() {
+ userID = &user.ID
+ }
+ }
+
+ 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/match_controller.go b/lost-and-found/internal/controllers/match_controller.go
new file mode 100644
index 0000000..66f5987
--- /dev/null
+++ b/lost-and-found/internal/controllers/match_controller.go
@@ -0,0 +1,86 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/report_controller.go b/lost-and-found/internal/controllers/report_controller.go
new file mode 100644
index 0000000..f7545ac
--- /dev/null
+++ b/lost-and-found/internal/controllers/report_controller.go
@@ -0,0 +1,109 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/controllers/user_controller.go b/lost-and-found/internal/controllers/user_controller.go
new file mode 100644
index 0000000..077f414
--- /dev/null
+++ b/lost-and-found/internal/controllers/user_controller.go
@@ -0,0 +1,237 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/middleware/cors.go b/lost-and-found/internal/middleware/cors.go
new file mode 100644
index 0000000..a4ced60
--- /dev/null
+++ b/lost-and-found/internal/middleware/cors.go
@@ -0,0 +1,22 @@
+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()
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/middleware/jwt_middleware.go b/lost-and-found/internal/middleware/jwt_middleware.go
new file mode 100644
index 0000000..f01a10c
--- /dev/null
+++ b/lost-and-found/internal/middleware/jwt_middleware.go
@@ -0,0 +1,105 @@
+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()
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/middleware/logger.go b/lost-and-found/internal/middleware/logger.go
new file mode 100644
index 0000000..6e40d2e
--- /dev/null
+++ b/lost-and-found/internal/middleware/logger.go
@@ -0,0 +1,45 @@
+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,
+ )
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/middleware/rate_limiter.go b/lost-and-found/internal/middleware/rate_limiter.go
new file mode 100644
index 0000000..f913c3e
--- /dev/null
+++ b/lost-and-found/internal/middleware/rate_limiter.go
@@ -0,0 +1,112 @@
+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 (100 requests per minute)
+ if limiter == nil {
+ InitRateLimiter(100, 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()
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/middleware/role_middleware.go b/lost-and-found/internal/middleware/role_middleware.go
new file mode 100644
index 0000000..3c12a1b
--- /dev/null
+++ b/lost-and-found/internal/middleware/role_middleware.go
@@ -0,0 +1,56 @@
+package middleware
+
+import (
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/utils"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+// 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/archive.go b/lost-and-found/internal/models/archive.go
new file mode 100644
index 0000000..858139d
--- /dev/null
+++ b/lost-and-found/internal/models/archive.go
@@ -0,0 +1,110 @@
+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"` // Original 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"` // case_closed, expired
+ 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"` // expired, case_closed
+ ClaimedBy *uint `json:"claimed_by"` // User who claimed (if applicable)
+ Claimer *User `gorm:"foreignKey:ClaimedBy" json:"claimer,omitempty"`
+ 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(),
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/audit_log.go b/lost-and-found/internal/models/audit_log.go
new file mode 100644
index 0000000..8363d69
--- /dev/null
+++ b/lost-and-found/internal/models/audit_log.go
@@ -0,0 +1,98 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/category.go b/lost-and-found/internal/models/category.go
new file mode 100644
index 0000000..a2db012
--- /dev/null
+++ b/lost-and-found/internal/models/category.go
@@ -0,0 +1,48 @@
+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,
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/claim.go b/lost-and-found/internal/models/claim.go
new file mode 100644
index 0000000..7dce13c
--- /dev/null
+++ b/lost-and-found/internal/models/claim.go
@@ -0,0 +1,164 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// Claim represents a claim for a found item
+type Claim 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"`
+ User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
+ Description string `gorm:"type:text;not null" json:"description"` // User's description of the item
+ ProofURL string `gorm:"type:varchar(255)" json:"proof_url"` // Optional proof photo
+ Contact string `gorm:"type:varchar(50);not null" json:"contact"`
+ Status string `gorm:"type:varchar(50);default:'pending'" json:"status"` // pending, approved, rejected
+ Notes string `gorm:"type:text" json:"notes"` // Manager's notes (approval/rejection reason)
+ VerifiedAt *time.Time `json:"verified_at"`
+ VerifiedBy *uint `json:"verified_by"` // Manager who verified
+ 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"`
+}
+
+// TableName specifies the table name for Claim model
+func (Claim) TableName() string {
+ return "claims"
+}
+
+// Claim status constants
+const (
+ ClaimStatusPending = "pending"
+ ClaimStatusApproved = "approved"
+ ClaimStatusRejected = "rejected"
+)
+
+// BeforeCreate hook
+func (c *Claim) BeforeCreate(tx *gorm.DB) error {
+ if c.Status == "" {
+ c.Status = ClaimStatusPending
+ }
+ return nil
+}
+
+// IsPending checks if claim is pending
+func (c *Claim) IsPending() bool {
+ return c.Status == ClaimStatusPending
+}
+
+// IsApproved checks if claim is approved
+func (c *Claim) IsApproved() bool {
+ return c.Status == ClaimStatusApproved
+}
+
+// IsRejected checks if claim is rejected
+func (c *Claim) IsRejected() bool {
+ return c.Status == ClaimStatusRejected
+}
+
+// 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"`
+ ItemName string `json:"item_name"`
+ UserID uint `json:"user_id"`
+ UserName string `json:"user_name"`
+ Description string `json:"description"`
+ 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"`
+}
+
+// ToResponse converts Claim to ClaimResponse
+func (c *Claim) ToResponse() ClaimResponse {
+ itemName := ""
+ if c.Item.ID != 0 {
+ itemName = c.Item.Name
+ }
+
+ userName := ""
+ if c.User.ID != 0 {
+ userName = c.User.Name
+ }
+
+ verifierName := ""
+ if c.Verifier != nil && c.Verifier.ID != 0 {
+ verifierName = c.Verifier.Name
+ }
+
+ var matchPercentage *float64
+ if c.Verification != nil {
+ matchPercentage = &c.Verification.SimilarityScore
+ }
+
+ return ClaimResponse{
+ ID: c.ID,
+ ItemID: c.ItemID,
+ ItemName: itemName,
+ UserID: c.UserID,
+ UserName: userName,
+ Description: c.Description,
+ 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,
+ }
+}
+
+// ClaimDetailResponse includes item description for verification
+type ClaimDetailResponse struct {
+ ClaimResponse
+ ItemDescription string `json:"item_description"` // Original item description for comparison
+}
+
+// ToDetailResponse converts Claim to ClaimDetailResponse
+func (c *Claim) ToDetailResponse() ClaimDetailResponse {
+ baseResponse := c.ToResponse()
+
+ itemDescription := ""
+ if c.Item.ID != 0 {
+ itemDescription = c.Item.Description
+ }
+
+ return ClaimDetailResponse{
+ ClaimResponse: baseResponse,
+ ItemDescription: itemDescription,
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/claim_verification.go b/lost-and-found/internal/models/claim_verification.go
new file mode 100644
index 0000000..bcc1929
--- /dev/null
+++ b/lost-and-found/internal/models/claim_verification.go
@@ -0,0 +1,77 @@
+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,
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/item.go b/lost-and-found/internal/models/item.go
new file mode 100644
index 0000000..5b462b5
--- /dev/null
+++ b/lost-and-found/internal/models/item.go
@@ -0,0 +1,152 @@
+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"` // Keunikan (rahasia)
+ DateFound time.Time `gorm:"not null" json:"date_found"`
+ Status string `gorm:"type:varchar(50);default:'unclaimed'" json:"status"` // unclaimed, pending_claim, verified, case_closed, expired
+ 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"` // Auto-expire after 90 days
+ 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 {
+ // Set default status
+ if i.Status == "" {
+ i.Status = ItemStatusUnclaimed
+ }
+
+ // Set expiration date (90 days from date found)
+ if i.ExpiresAt == nil {
+ expiresAt := i.DateFound.AddDate(0, 0, 90) // Add 90 days
+ 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 {
+ // Cannot edit if case is closed or expired
+ 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"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// 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.Status,
+ CreatedAt: i.CreatedAt,
+ }
+}
+
+// ItemDetailResponse represents full item data for authorized users
+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"`
+ 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"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ToDetailResponse converts Item to ItemDetailResponse (includes all info)
+func (i *Item) ToDetailResponse() ItemDetailResponse {
+ categoryName := ""
+ if i.Category.ID != 0 {
+ categoryName = i.Category.Name
+ }
+
+ return ItemDetailResponse{
+ ID: i.ID,
+ Name: i.Name,
+ Category: categoryName,
+ PhotoURL: i.PhotoURL,
+ Location: i.Location,
+ Description: i.Description,
+ DateFound: i.DateFound,
+ Status: i.Status,
+ ReporterName: i.ReporterName,
+ ReporterContact: i.ReporterContact,
+ ExpiresAt: i.ExpiresAt,
+ CreatedAt: i.CreatedAt,
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/lost_item.go b/lost-and-found/internal/models/lost_item.go
new file mode 100644
index 0000000..86a0711
--- /dev/null
+++ b/lost-and-found/internal/models/lost_item.go
@@ -0,0 +1,93 @@
+package models
+
+import (
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// LostItem represents a lost item report
+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"` // Optional
+ 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"` // active, found, expired
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // Relationships
+ MatchResults []MatchResult `gorm:"foreignKey:LostItemID" json:"match_results,omitempty"`
+}
+
+// TableName specifies the table name for LostItem model
+func (LostItem) TableName() string {
+ return "lost_items"
+}
+
+// LostItem status constants
+const (
+ LostItemStatusActive = "active"
+ LostItemStatusFound = "found"
+ LostItemStatusExpired = "expired"
+)
+
+// BeforeCreate hook
+func (l *LostItem) BeforeCreate(tx *gorm.DB) error {
+ if l.Status == "" {
+ l.Status = LostItemStatusActive
+ }
+ return nil
+}
+
+// IsActive checks if lost item is still active
+func (l *LostItem) IsActive() bool {
+ return l.Status == LostItemStatusActive
+}
+
+// LostItemResponse represents lost item data for API responses
+type LostItemResponse struct {
+ ID uint `json:"id"`
+ UserName string `json:"user_name"`
+ Name string `json:"name"`
+ 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"`
+}
+
+// ToResponse converts LostItem to LostItemResponse
+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
+ }
+
+ return LostItemResponse{
+ ID: l.ID,
+ UserName: userName,
+ Name: l.Name,
+ Category: categoryName,
+ Color: l.Color,
+ Location: l.Location,
+ Description: l.Description,
+ DateLost: l.DateLost,
+ Status: l.Status,
+ CreatedAt: l.CreatedAt,
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/match_result.go b/lost-and-found/internal/models/match_result.go
new file mode 100644
index 0000000..893d3bc
--- /dev/null
+++ b/lost-and-found/internal/models/match_result.go
@@ -0,0 +1,128 @@
+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"`
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/notification.go b/lost-and-found/internal/models/notification.go
new file mode 100644
index 0000000..66f39d0
--- /dev/null
+++ b/lost-and-found/internal/models/notification.go
@@ -0,0 +1,127 @@
+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,
+ )
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/revision_log.go b/lost-and-found/internal/models/revision_log.go
new file mode 100644
index 0000000..4a809b0
--- /dev/null
+++ b/lost-and-found/internal/models/revision_log.go
@@ -0,0 +1,72 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/role.go b/lost-and-found/internal/models/role.go
new file mode 100644
index 0000000..9b47242
--- /dev/null
+++ b/lost-and-found/internal/models/role.go
@@ -0,0 +1,52 @@
+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"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // Relationships
+ 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/models/user.go b/lost-and-found/internal/models/user.go
new file mode 100644
index 0000000..d64f592
--- /dev/null
+++ b/lost-and-found/internal/models/user.go
@@ -0,0 +1,104 @@
+package models
+
+import (
+ "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:"-"` // Hide password in JSON
+ NRP string `gorm:"type:varchar(20);uniqueIndex" json:"nrp"`
+ Phone string `gorm:"type:varchar(20)" json:"phone"`
+ RoleID uint `gorm:"not null;default:3" json:"role_id"` // Default to user role
+ Role Role `gorm:"foreignKey:RoleID" json:"role,omitempty"`
+ Status string `gorm:"type:varchar(20);default:'active'" json:"status"` // active, blocked
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // Relationships
+ Items []Item `gorm:"foreignKey:ReporterID" json:"items,omitempty"`
+ LostItems []LostItem `gorm:"foreignKey:UserID" json:"lost_items,omitempty"`
+ Claims []Claim `gorm:"foreignKey:UserID" json:"claims,omitempty"`
+}
+
+// TableName specifies the table name for User model
+func (User) TableName() string {
+ return "users"
+}
+
+// BeforeCreate hook to validate user data
+func (u *User) BeforeCreate(tx *gorm.DB) error {
+ // Set default role if not specified
+ if u.RoleID == 0 {
+ u.RoleID = 3 // Default to user role
+ }
+
+ // Set default status
+ if u.Status == "" {
+ u.Status = "active"
+ }
+
+ return nil
+}
+
+// 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"
+}
+
+// IsUser checks if user is regular user
+func (u *User) IsUser() bool {
+ return u.Role.Name == "user"
+}
+
+// IsActive checks if user is active
+func (u *User) IsActive() bool {
+ return u.Status == "active"
+}
+
+// IsBlocked checks if user is blocked
+func (u *User) IsBlocked() bool {
+ return u.Status == "blocked"
+}
+
+// UserResponse represents user data for API responses (without sensitive info)
+type UserResponse struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ NRP string `json:"nrp"`
+ Phone string `json:"phone"`
+ Role string `json:"role"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ToResponse converts User to UserResponse
+func (u *User) ToResponse() UserResponse {
+ roleName := ""
+ if u.Role.ID != 0 {
+ roleName = u.Role.Name
+ }
+
+ return UserResponse{
+ ID: u.ID,
+ Name: u.Name,
+ Email: u.Email,
+ NRP: u.NRP,
+ Phone: u.Phone,
+ Role: roleName,
+ Status: u.Status,
+ CreatedAt: u.CreatedAt,
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/archive_repo.go b/lost-and-found/internal/repositories/archive_repo.go
new file mode 100644
index 0000000..82b2008
--- /dev/null
+++ b/lost-and-found/internal/repositories/archive_repo.go
@@ -0,0 +1,91 @@
+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 != "" {
+ query = query.Where("name ILIKE ? OR location ILIKE ?", "%"+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/audit_log_repo.go b/lost-and-found/internal/repositories/audit_log_repo.go
new file mode 100644
index 0000000..7e3e857
--- /dev/null
+++ b/lost-and-found/internal/repositories/audit_log_repo.go
@@ -0,0 +1,104 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/category_repo.go b/lost-and-found/internal/repositories/category_repo.go
new file mode 100644
index 0000000..6c2ed3f
--- /dev/null
+++ b/lost-and-found/internal/repositories/category_repo.go
@@ -0,0 +1,101 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/claim_repo.go b/lost-and-found/internal/repositories/claim_repo.go
new file mode 100644
index 0000000..abd8613
--- /dev/null
+++ b/lost-and-found/internal/repositories/claim_repo.go
@@ -0,0 +1,145 @@
+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("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
+ 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
+ err := query.Preload("Item").Preload("Item.Category").
+ 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)
+
+ // 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("Item.Category").
+ 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/claim_verification_repo.go b/lost-and-found/internal/repositories/claim_verification_repo.go
new file mode 100644
index 0000000..5cbac09
--- /dev/null
+++ b/lost-and-found/internal/repositories/claim_verification_repo.go
@@ -0,0 +1,66 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/item_repo.go b/lost-and-found/internal/repositories/item_repo.go
new file mode 100644
index 0000000..d974529
--- /dev/null
+++ b/lost-and-found/internal/repositories/item_repo.go
@@ -0,0 +1,158 @@
+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").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
+}
+
+// FindAll returns all items with filters
+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{})
+
+ // Apply filters
+ if status != "" {
+ 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 ILIKE ? OR location ILIKE ?", "%"+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("Reporter").Preload("Reporter.Role").
+ 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/lost_item_repo.go b/lost-and-found/internal/repositories/lost_item_repo.go
new file mode 100644
index 0000000..1bee969
--- /dev/null
+++ b/lost-and-found/internal/repositories/lost_item_repo.go
@@ -0,0 +1,127 @@
+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
+}
+
+// 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 != "" {
+ query = query.Where("name ILIKE ? OR description ILIKE ?", "%"+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
+}
+
+// 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/match_result_repo.go b/lost-and-found/internal/repositories/match_result_repo.go
new file mode 100644
index 0000000..f46d617
--- /dev/null
+++ b/lost-and-found/internal/repositories/match_result_repo.go
@@ -0,0 +1,124 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/notification_repo.go b/lost-and-found/internal/repositories/notification_repo.go
new file mode 100644
index 0000000..978c6d4
--- /dev/null
+++ b/lost-and-found/internal/repositories/notification_repo.go
@@ -0,0 +1,103 @@
+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(¬ification, id).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("notification not found")
+ }
+ return nil, err
+ }
+ return ¬ification, 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(¬ifications).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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/revision_log_repo.go b/lost-and-found/internal/repositories/revision_log_repo.go
new file mode 100644
index 0000000..c87de82
--- /dev/null
+++ b/lost-and-found/internal/repositories/revision_log_repo.go
@@ -0,0 +1,92 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/role_repo.go b/lost-and-found/internal/repositories/role_repo.go
new file mode 100644
index 0000000..f7e1fac
--- /dev/null
+++ b/lost-and-found/internal/repositories/role_repo.go
@@ -0,0 +1,64 @@
+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}
+}
+
+// FindAll returns all roles
+func (r *RoleRepository) FindAll() ([]models.Role, error) {
+ var roles []models.Role
+ err := r.db.Find(&roles).Error
+ return roles, err
+}
+
+// FindByID finds role by ID
+func (r *RoleRepository) FindByID(id uint) (*models.Role, error) {
+ var role models.Role
+ err := r.db.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
+}
+
+// FindByName finds role by name
+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
+}
+
+// Create creates a new role
+func (r *RoleRepository) Create(role *models.Role) error {
+ return r.db.Create(role).Error
+}
+
+// Update updates role data
+func (r *RoleRepository) Update(role *models.Role) error {
+ return r.db.Save(role).Error
+}
+
+// Delete deletes a role
+func (r *RoleRepository) Delete(id uint) error {
+ return r.db.Delete(&models.Role{}, id).Error
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/repositories/user_repo.go b/lost-and-found/internal/repositories/user_repo.go
new file mode 100644
index 0000000..d10d886
--- /dev/null
+++ b/lost-and-found/internal/repositories/user_repo.go
@@ -0,0 +1,152 @@
+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}
+}
+
+// Create creates a new user
+func (r *UserRepository) Create(user *models.User) error {
+ return r.db.Create(user).Error
+}
+
+// FindByID finds user by ID
+func (r *UserRepository) FindByID(id uint) (*models.User, error) {
+ var user models.User
+ err := r.db.Preload("Role").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
+}
+
+// FindByEmail finds user by email
+func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
+ var user models.User
+ err := r.db.Preload("Role").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
+}
+
+// FindByNRP finds user by NRP
+func (r *UserRepository) FindByNRP(nrp string) (*models.User, error) {
+ var user models.User
+ err := r.db.Preload("Role").Where("nrp = ?", nrp).First(&user).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return &user, nil
+}
+
+// FindAll returns all users
+func (r *UserRepository) FindAll(page, limit int) ([]models.User, int64, error) {
+ var users []models.User
+ var total int64
+
+ // Count total
+ if err := r.db.Model(&models.User{}).Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ // Get paginated results
+ offset := (page - 1) * limit
+ err := r.db.Preload("Role").Offset(offset).Limit(limit).Find(&users).Error
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return users, total, nil
+}
+
+// Update updates user data
+func (r *UserRepository) Update(user *models.User) error {
+ return r.db.Save(user).Error
+}
+
+// UpdateRole updates user role
+func (r *UserRepository) UpdateRole(userID, roleID uint) error {
+ return r.db.Model(&models.User{}).Where("id = ?", userID).Update("role_id", roleID).Error
+}
+
+// UpdateStatus updates user status
+func (r *UserRepository) UpdateStatus(userID uint, status string) error {
+ return r.db.Model(&models.User{}).Where("id = ?", userID).Update("status", status).Error
+}
+
+// Delete soft deletes a user
+func (r *UserRepository) Delete(id uint) error {
+ return r.db.Delete(&models.User{}, id).Error
+}
+
+// BlockUser blocks a user
+func (r *UserRepository) BlockUser(id uint) error {
+ return r.UpdateStatus(id, "blocked")
+}
+
+// UnblockUser unblocks a user
+func (r *UserRepository) UnblockUser(id uint) error {
+ return r.UpdateStatus(id, "active")
+}
+
+// CountByRole counts users by role
+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
+}
+
+// GetUserStats gets user statistics
+func (r *UserRepository) GetUserStats(userID uint) (map[string]interface{}, error) {
+ var stats map[string]interface{} = make(map[string]interface{})
+
+ // Count 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
+
+ // Count lost items reported
+ 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
+
+ // Count claims made
+ 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
+
+ // Count 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/routes/routes.go b/lost-and-found/internal/routes/routes.go
new file mode 100644
index 0000000..24d99af
--- /dev/null
+++ b/lost-and-found/internal/routes/routes.go
@@ -0,0 +1,128 @@
+package routes
+
+import (
+ "lost-and-found/internal/controllers"
+ "lost-and-found/internal/middleware"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// SetupRoutes configures all application routes
+func SetupRoutes(router *gin.Engine, db *gorm.DB) {
+ // Initialize controllers
+ authController := controllers.NewAuthController(db)
+ 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)
+
+ // API group
+ api := router.Group("/api")
+ {
+ // Public routes (no authentication required)
+ api.POST("/register", authController.Register)
+ api.POST("/login", authController.Login)
+ api.POST("/refresh-token", authController.RefreshToken)
+
+ // Public categories
+ api.GET("/categories", categoryController.GetAllCategories)
+ api.GET("/categories/:id", categoryController.GetCategoryByID)
+
+ // Public items (read-only, limited info)
+ api.GET("/items", itemController.GetAllItems)
+ api.GET("/items/:id", itemController.GetItemByID)
+
+ // Authenticated routes (all users)
+ authenticated := api.Group("")
+ authenticated.Use(middleware.JWTMiddleware(db))
+ authenticated.Use(middleware.RequireUser())
+ {
+ // User profile
+ 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)
+
+ // User items
+ authenticated.GET("/user/items", itemController.GetItemsByReporter)
+ authenticated.POST("/items", itemController.CreateItem)
+
+ // User lost items
+ authenticated.GET("/user/lost-items", lostItemController.GetLostItemsByUser)
+ authenticated.GET("/lost-items", lostItemController.GetAllLostItems)
+ authenticated.GET("/lost-items/:id", lostItemController.GetLostItemByID)
+ authenticated.POST("/lost-items", lostItemController.CreateLostItem)
+ authenticated.PUT("/lost-items/:id", lostItemController.UpdateLostItem)
+ authenticated.PATCH("/lost-items/:id/status", lostItemController.UpdateLostItemStatus)
+ authenticated.DELETE("/lost-items/:id", lostItemController.DeleteLostItem)
+
+ // User claims
+ authenticated.GET("/user/claims", claimController.GetClaimsByUser)
+ authenticated.GET("/claims", claimController.GetAllClaims)
+ authenticated.GET("/claims/:id", claimController.GetClaimByID)
+ authenticated.POST("/claims", claimController.CreateClaim)
+ authenticated.DELETE("/claims/:id", claimController.DeleteClaim)
+
+ // Matches (for lost items)
+ authenticated.GET("/lost-items/:id/matches", matchController.GetMatchesForLostItem)
+ authenticated.POST("/lost-items/:id/find-similar", matchController.FindSimilarItems)
+ }
+
+ // Manager routes (manager and admin)
+ manager := api.Group("")
+ manager.Use(middleware.JWTMiddleware(db))
+ manager.Use(middleware.RequireManager())
+ {
+ // Item management
+ manager.PUT("/items/:id", itemController.UpdateItem)
+ manager.PATCH("/items/:id/status", itemController.UpdateItemStatus)
+ manager.DELETE("/items/:id", itemController.DeleteItem)
+ manager.GET("/items/:id/revisions", itemController.GetItemRevisionHistory)
+ manager.GET("/items/:id/matches", matchController.GetMatchesForItem)
+
+ // Claim verification
+ manager.POST("/claims/:id/verify", claimController.VerifyClaim)
+ manager.GET("/claims/:id/verification", claimController.GetClaimVerification)
+ manager.POST("/claims/:id/close", claimController.CloseClaim)
+
+ // Archives
+ manager.GET("/archives", archiveController.GetAllArchives)
+ manager.GET("/archives/:id", archiveController.GetArchiveByID)
+ manager.GET("/archives/stats", archiveController.GetArchiveStats)
+
+ // Dashboard
+ manager.GET("/manager/dashboard", adminController.GetDashboardStats)
+ }
+
+ // Admin routes (admin only)
+ admin := api.Group("/admin")
+ admin.Use(middleware.JWTMiddleware(db))
+ admin.Use(middleware.RequireAdmin())
+ {
+ // User management
+ admin.GET("/users", userController.GetAllUsers)
+ admin.GET("/users/:id", userController.GetUserByID)
+ admin.PATCH("/users/:id/role", userController.UpdateUserRole)
+ admin.POST("/users/:id/block", userController.BlockUser)
+ admin.POST("/users/:id/unblock", userController.UnblockUser)
+ admin.DELETE("/users/:id", userController.DeleteUser)
+
+ // Category management
+ admin.POST("/categories", categoryController.CreateCategory)
+ admin.PUT("/categories/:id", categoryController.UpdateCategory)
+ admin.DELETE("/categories/:id", categoryController.DeleteCategory)
+
+ // Dashboard & Reports
+ admin.GET("/dashboard", adminController.GetDashboardStats)
+ admin.GET("/audit-logs", adminController.GetAuditLogs)
+ admin.POST("/reports/export", reportController.ExportReport)
+ }
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/archive_service.go b/lost-and-found/internal/services/archive_service.go
new file mode 100644
index 0000000..bdc7044
--- /dev/null
+++ b/lost-and-found/internal/services/archive_service.go
@@ -0,0 +1,67 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/audit_service.go b/lost-and-found/internal/services/audit_service.go
new file mode 100644
index 0000000..c75d0b0
--- /dev/null
+++ b/lost-and-found/internal/services/audit_service.go
@@ -0,0 +1,68 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/auth_service.go b/lost-and-found/internal/services/auth_service.go
new file mode 100644
index 0000000..74c2df3
--- /dev/null
+++ b/lost-and-found/internal/services/auth_service.go
@@ -0,0 +1,172 @@
+package services
+
+import (
+ "errors"
+ "lost-and-found/internal/config"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+ "lost-and-found/internal/utils"
+
+ "gorm.io/gorm"
+)
+
+type AuthService struct {
+ userRepo *repositories.UserRepository
+ roleRepo *repositories.RoleRepository
+ auditLogRepo *repositories.AuditLogRepository
+}
+
+func NewAuthService(db *gorm.DB) *AuthService {
+ return &AuthService{
+ userRepo: repositories.NewUserRepository(db),
+ roleRepo: repositories.NewRoleRepository(db),
+ auditLogRepo: repositories.NewAuditLogRepository(db),
+ }
+}
+
+// RegisterRequest represents registration data
+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"`
+}
+
+// LoginRequest represents login data
+type LoginRequest struct {
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required"`
+}
+
+// AuthResponse represents authentication response
+type AuthResponse struct {
+ Token string `json:"token"`
+ User models.UserResponse `json:"user"`
+}
+
+// Register registers a new user
+func (s *AuthService) Register(req RegisterRequest, ipAddress, userAgent string) (*AuthResponse, error) {
+ // Check if email already exists
+ existingUser, _ := s.userRepo.FindByEmail(req.Email)
+ if existingUser != nil {
+ return nil, errors.New("email already registered")
+ }
+
+ // Check if NRP already exists
+ if req.NRP != "" {
+ existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
+ if existingNRP != nil {
+ return nil, errors.New("NRP already registered")
+ }
+ }
+
+ // Hash password
+ hashedPassword, err := utils.HashPassword(req.Password)
+ if err != nil {
+ return nil, errors.New("failed to hash password")
+ }
+
+ // Get user role ID
+ userRole, err := s.roleRepo.FindByName(models.RoleUser)
+ if err != nil {
+ return nil, errors.New("failed to get user role")
+ }
+
+ // Create user
+ user := &models.User{
+ Name: req.Name,
+ Email: req.Email,
+ Password: hashedPassword,
+ NRP: req.NRP,
+ Phone: req.Phone,
+ RoleID: userRole.ID,
+ Status: "active",
+ }
+
+ if err := s.userRepo.Create(user); err != nil {
+ return nil, errors.New("failed to create user")
+ }
+
+ // Load user with role
+ user, err = s.userRepo.FindByID(user.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Generate JWT token
+ token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
+ if err != nil {
+ 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)
+
+ return &AuthResponse{
+ Token: token,
+ User: user.ToResponse(),
+ }, nil
+}
+
+// Login authenticates a user
+func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) {
+ // Find user by email
+ user, err := s.userRepo.FindByEmail(req.Email)
+ if err != nil {
+ return nil, errors.New("invalid email or password")
+ }
+
+ // Check if user is blocked
+ if user.IsBlocked() {
+ return nil, errors.New("account is blocked")
+ }
+
+ // Verify password
+ if !utils.CheckPasswordHash(req.Password, user.Password) {
+ return nil, errors.New("invalid email or password")
+ }
+
+ // Generate JWT token
+ token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
+ if err != nil {
+ 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)
+
+ return &AuthResponse{
+ Token: token,
+ User: user.ToResponse(),
+ }, nil
+}
+
+// ValidateToken validates JWT token and returns user
+func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) {
+ // Validate token
+ claims, err := config.ValidateToken(tokenString)
+ if err != nil {
+ return nil, errors.New("invalid token")
+ }
+
+ // Get user
+ user, err := s.userRepo.FindByID(claims.UserID)
+ if err != nil {
+ return nil, errors.New("user not found")
+ }
+
+ // Check if user is blocked
+ if user.IsBlocked() {
+ return nil, errors.New("account is blocked")
+ }
+
+ return user, nil
+}
+
+// RefreshToken refreshes JWT token
+func (s *AuthService) RefreshToken(oldToken string) (string, error) {
+ return config.RefreshToken(oldToken)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/category_service.go b/lost-and-found/internal/services/category_service.go
new file mode 100644
index 0000000..153fca7
--- /dev/null
+++ b/lost-and-found/internal/services/category_service.go
@@ -0,0 +1,147 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/claim_service.go b/lost-and-found/internal/services/claim_service.go
new file mode 100644
index 0000000..7caa90e
--- /dev/null
+++ b/lost-and-found/internal/services/claim_service.go
@@ -0,0 +1,268 @@
+package services
+
+import (
+ "errors"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+
+ "gorm.io/gorm"
+)
+
+type ClaimService struct {
+ db *gorm.DB // Tambahkan ini
+ claimRepo *repositories.ClaimRepository
+ itemRepo *repositories.ItemRepository
+ verificationRepo *repositories.ClaimVerificationRepository
+ notificationRepo *repositories.NotificationRepository
+ auditLogRepo *repositories.AuditLogRepository
+}
+
+func NewClaimService(db *gorm.DB) *ClaimService {
+ return &ClaimService{
+ db: db, // Tambahkan ini
+ claimRepo: repositories.NewClaimRepository(db),
+ itemRepo: repositories.NewItemRepository(db),
+ verificationRepo: repositories.NewClaimVerificationRepository(db),
+ notificationRepo: repositories.NewNotificationRepository(db),
+ auditLogRepo: repositories.NewAuditLogRepository(db),
+ }
+}
+
+// CreateClaimRequest represents claim creation data
+type CreateClaimRequest struct {
+ ItemID uint `json:"item_id" binding:"required"`
+ Description string `json:"description" binding:"required"`
+ ProofURL string `json:"proof_url"`
+ Contact string `json:"contact" binding:"required"`
+}
+
+// VerifyClaimRequest represents claim verification data
+type VerifyClaimRequest struct {
+ Status string `json:"status" binding:"required"` // approved or rejected
+ Notes string `json:"notes"`
+}
+
+// CreateClaim creates a new claim
+func (s *ClaimService) CreateClaim(userID uint, req CreateClaimRequest, ipAddress, userAgent string) (*models.Claim, error) {
+ // Check if item exists
+ item, err := s.itemRepo.FindByID(req.ItemID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if item can be claimed
+ if !item.CanBeClaimed() {
+ return nil, errors.New("item cannot be claimed")
+ }
+
+ // Check if user already claimed this item
+ exists, err := s.claimRepo.CheckExistingClaim(userID, req.ItemID)
+ if err != nil {
+ return nil, err
+ }
+ if exists {
+ return nil, errors.New("you already claimed this item")
+ }
+
+ // Create claim
+ claim := &models.Claim{
+ ItemID: req.ItemID,
+ UserID: userID,
+ Description: req.Description,
+ ProofURL: req.ProofURL,
+ Contact: req.Contact,
+ Status: models.ClaimStatusPending,
+ }
+
+ if err := s.claimRepo.Create(claim); err != nil {
+ return nil, errors.New("failed to create claim")
+ }
+
+ // Update item status to pending claim
+ s.itemRepo.UpdateStatus(req.ItemID, models.ItemStatusPendingClaim)
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityClaim, &claim.ID,
+ "Claim created for item: "+item.Name, ipAddress, userAgent)
+
+ // Load claim with relations
+ return s.claimRepo.FindByID(claim.ID)
+}
+
+// GetAllClaims gets all claims
+func (s *ClaimService) GetAllClaims(page, limit int, status string, itemID, userID *uint) ([]models.ClaimResponse, int64, error) {
+ claims, total, err := s.claimRepo.FindAll(page, limit, status, itemID, userID)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var responses []models.ClaimResponse
+ for _, claim := range claims {
+ responses = append(responses, claim.ToResponse())
+ }
+
+ return responses, total, nil
+}
+
+// GetClaimByID gets claim by ID
+func (s *ClaimService) GetClaimByID(id uint, isManager bool) (interface{}, error) {
+ claim, err := s.claimRepo.FindByID(id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Manager can see full details including item description
+ if isManager {
+ return claim.ToDetailResponse(), nil
+ }
+
+ return claim.ToResponse(), nil
+}
+
+// GetClaimsByUser gets claims by user
+func (s *ClaimService) GetClaimsByUser(userID uint, page, limit int) ([]models.ClaimResponse, int64, error) {
+ claims, total, err := s.claimRepo.FindByUser(userID, page, limit)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var responses []models.ClaimResponse
+ for _, claim := range claims {
+ responses = append(responses, claim.ToResponse())
+ }
+
+ return responses, total, nil
+}
+
+// VerifyClaim verifies a claim (manager only)
+func (s *ClaimService) VerifyClaim(managerID, claimID uint, req VerifyClaimRequest, similarityScore float64, matchedKeywords string, ipAddress, userAgent string) error {
+ claim, err := s.claimRepo.FindByID(claimID)
+ if err != nil {
+ return err
+ }
+
+ if !claim.IsPending() {
+ return errors.New("claim is not pending")
+ }
+
+ // Create or update verification record
+ verification, _ := s.verificationRepo.FindByClaimID(claimID)
+ if verification == nil {
+ verification = &models.ClaimVerification{
+ ClaimID: claimID,
+ SimilarityScore: similarityScore,
+ MatchedKeywords: matchedKeywords,
+ VerificationNotes: req.Notes,
+ IsAutoMatched: false,
+ }
+ s.verificationRepo.Create(verification)
+ } else {
+ verification.VerificationNotes = req.Notes
+ s.verificationRepo.Update(verification)
+ }
+
+ // Update claim status
+ if req.Status == models.ClaimStatusApproved {
+ claim.Approve(managerID, req.Notes)
+
+ // Update item status to verified
+ s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusVerified)
+
+ // Send approval notification - PERBAIKAN DI SINI
+ item, _ := s.itemRepo.FindByID(claim.ItemID)
+ models.CreateClaimApprovedNotification(s.db, claim.UserID, item.Name, claimID)
+
+ // Log audit
+ s.auditLogRepo.Log(&managerID, models.ActionApprove, models.EntityClaim, &claimID,
+ "Claim approved", ipAddress, userAgent)
+ } else if req.Status == models.ClaimStatusRejected {
+ claim.Reject(managerID, req.Notes)
+
+ // Check if there are other pending claims for this item
+ otherClaims, _ := s.claimRepo.FindByItem(claim.ItemID)
+ hasPendingClaims := false
+ for _, c := range otherClaims {
+ if c.ID != claimID && c.IsPending() {
+ hasPendingClaims = true
+ break
+ }
+ }
+
+ // If no other pending claims, set item back to unclaimed
+ if !hasPendingClaims {
+ s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusUnclaimed)
+ }
+
+ // Send rejection notification - PERBAIKAN DI SINI
+ item, _ := s.itemRepo.FindByID(claim.ItemID)
+ models.CreateClaimRejectedNotification(s.db, claim.UserID, item.Name, req.Notes, claimID)
+
+ // Log audit
+ s.auditLogRepo.Log(&managerID, models.ActionReject, models.EntityClaim, &claimID,
+ "Claim rejected: "+req.Notes, ipAddress, userAgent)
+ } else {
+ return errors.New("invalid status")
+ }
+
+ if err := s.claimRepo.Update(claim); err != nil {
+ return errors.New("failed to verify claim")
+ }
+
+ return nil
+}
+
+// CloseClaim closes a claim and moves item to archive (manager only)
+func (s *ClaimService) CloseClaim(managerID, claimID uint, ipAddress, userAgent string) error {
+ claim, err := s.claimRepo.FindByID(claimID)
+ if err != nil {
+ return err
+ }
+
+ if !claim.IsApproved() {
+ return errors.New("only approved claims can be closed")
+ }
+
+ // Update item status to case_closed
+ if err := s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusCaseClosed); err != nil {
+ return errors.New("failed to close case")
+ }
+
+ // Archive the item
+ item, _ := s.itemRepo.FindByID(claim.ItemID)
+ s.itemRepo.ArchiveItem(item, models.ArchiveReasonCaseClosed, &claim.UserID)
+
+ // Log audit
+ s.auditLogRepo.Log(&managerID, models.ActionUpdate, models.EntityItem, &item.ID,
+ "Case closed and archived", ipAddress, userAgent)
+
+ return nil
+}
+
+// DeleteClaim deletes a claim
+func (s *ClaimService) DeleteClaim(userID, claimID uint, ipAddress, userAgent string) error {
+ claim, err := s.claimRepo.FindByID(claimID)
+ if err != nil {
+ return err
+ }
+
+ // Only pending claims can be deleted by users
+ if !claim.IsPending() && claim.UserID == userID {
+ return errors.New("cannot delete non-pending claim")
+ }
+
+ if err := s.claimRepo.Delete(claimID); err != nil {
+ return errors.New("failed to delete claim")
+ }
+
+ // Check if item should go back to unclaimed
+ otherClaims, _ := s.claimRepo.FindByItem(claim.ItemID)
+ if len(otherClaims) == 0 {
+ s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusUnclaimed)
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityClaim, &claimID,
+ "Claim deleted", ipAddress, userAgent)
+
+ return nil
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/export_service.go b/lost-and-found/internal/services/export_service.go
new file mode 100644
index 0000000..4bef52a
--- /dev/null
+++ b/lost-and-found/internal/services/export_service.go
@@ -0,0 +1,254 @@
+package services
+
+import (
+ "bytes"
+ "fmt"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+ "lost-and-found/internal/utils"
+ "time"
+
+ "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) {
+ // 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 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")
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/item_service.go b/lost-and-found/internal/services/item_service.go
new file mode 100644
index 0000000..e749717
--- /dev/null
+++ b/lost-and-found/internal/services/item_service.go
@@ -0,0 +1,211 @@
+package services
+
+import (
+ "errors"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type ItemService struct {
+ itemRepo *repositories.ItemRepository
+ categoryRepo *repositories.CategoryRepository
+ auditLogRepo *repositories.AuditLogRepository
+ revisionRepo *repositories.RevisionLogRepository
+}
+
+func NewItemService(db *gorm.DB) *ItemService {
+ return &ItemService{
+ itemRepo: repositories.NewItemRepository(db),
+ categoryRepo: repositories.NewCategoryRepository(db),
+ auditLogRepo: repositories.NewAuditLogRepository(db),
+ revisionRepo: repositories.NewRevisionLogRepository(db),
+ }
+}
+
+// CreateItemRequest represents create item data
+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"`
+ DateFound time.Time `json:"date_found" binding:"required"`
+ ReporterName string `json:"reporter_name" binding:"required"`
+ ReporterContact string `json:"reporter_contact" binding:"required"`
+}
+
+// UpdateItemRequest represents update item data
+type UpdateItemRequest struct {
+ Name string `json:"name"`
+ CategoryID uint `json:"category_id"`
+ Location string `json:"location"`
+ Description string `json:"description"`
+ DateFound time.Time `json:"date_found"`
+ ReporterName string `json:"reporter_name"`
+ ReporterContact string `json:"reporter_contact"`
+ Reason string `json:"reason"` // Reason for edit
+}
+
+// GetAllItems gets all items (public view)
+func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) {
+ items, total, err := s.itemRepo.FindAll(page, limit, status, category, search)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var responses []models.ItemPublicResponse
+ for _, item := range items {
+ responses = append(responses, item.ToPublicResponse())
+ }
+
+ return responses, total, nil
+}
+
+// GetItemByID gets item by ID
+func (s *ItemService) GetItemByID(id uint, isManager bool) (interface{}, error) {
+ item, err := s.itemRepo.FindByID(id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Manager can see full details
+ if isManager {
+ return item.ToDetailResponse(), nil
+ }
+
+ // Public can only see limited info
+ return item.ToPublicResponse(), nil
+}
+
+// CreateItem creates a new item
+func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
+ // Verify category exists
+ if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
+ return nil, errors.New("invalid category")
+ }
+
+ item := &models.Item{
+ Name: req.Name,
+ CategoryID: req.CategoryID,
+ PhotoURL: req.PhotoURL,
+ Location: req.Location,
+ Description: req.Description,
+ DateFound: req.DateFound,
+ Status: models.ItemStatusUnclaimed,
+ ReporterID: reporterID,
+ ReporterName: req.ReporterName,
+ ReporterContact: req.ReporterContact,
+ }
+
+ if err := s.itemRepo.Create(item); err != nil {
+ return nil, errors.New("failed to create item")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&reporterID, models.ActionCreate, models.EntityItem, &item.ID,
+ "Item created: "+item.Name, ipAddress, userAgent)
+
+ return item, nil
+}
+
+// UpdateItem updates an item
+func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
+ item, err := s.itemRepo.FindByID(itemID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if item can be edited
+ if !item.CanBeEdited() {
+ return nil, errors.New("cannot edit item with status: " + item.Status)
+ }
+
+ // Track changes for revision log
+ if req.Name != "" && req.Name != item.Name {
+ s.revisionRepo.Log(itemID, userID, "name", item.Name, req.Name, req.Reason)
+ item.Name = req.Name
+ }
+ if req.CategoryID != 0 && req.CategoryID != item.CategoryID {
+ oldCat, _ := s.categoryRepo.FindByID(item.CategoryID)
+ newCat, _ := s.categoryRepo.FindByID(req.CategoryID)
+ s.revisionRepo.Log(itemID, userID, "category", oldCat.Name, newCat.Name, req.Reason)
+ item.CategoryID = req.CategoryID
+ }
+ if req.Location != "" && req.Location != item.Location {
+ s.revisionRepo.Log(itemID, userID, "location", item.Location, req.Location, req.Reason)
+ item.Location = req.Location
+ }
+ if req.Description != "" && req.Description != item.Description {
+ s.revisionRepo.Log(itemID, userID, "description", item.Description, req.Description, req.Reason)
+ item.Description = req.Description
+ }
+
+ if err := s.itemRepo.Update(item); err != nil {
+ return nil, errors.New("failed to update item")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityItem, &itemID,
+ "Item updated: "+item.Name, ipAddress, userAgent)
+
+ return item, nil
+}
+
+// UpdateItemStatus updates item status
+func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error {
+ if err := s.itemRepo.UpdateStatus(itemID, status); err != nil {
+ return errors.New("failed to update item status")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityItem, &itemID,
+ "Item status updated to: "+status, ipAddress, userAgent)
+
+ return nil
+}
+
+// DeleteItem deletes an item
+func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error {
+ item, err := s.itemRepo.FindByID(itemID)
+ if err != nil {
+ return err
+ }
+
+ // Cannot delete verified or case closed items
+ if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed {
+ return errors.New("cannot delete item with status: " + item.Status)
+ }
+
+ if err := s.itemRepo.Delete(itemID); err != nil {
+ return errors.New("failed to delete item")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityItem, &itemID,
+ "Item deleted: "+item.Name, ipAddress, userAgent)
+
+ return nil
+}
+
+// GetItemsByReporter gets items by reporter
+func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
+ return s.itemRepo.FindByReporter(reporterID, page, limit)
+}
+
+// GetItemRevisionHistory gets revision history for an item
+func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) {
+ logs, total, err := s.revisionRepo.FindByItem(itemID, page, limit)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ var responses []models.RevisionLogResponse
+ for _, log := range logs {
+ responses = append(responses, log.ToResponse())
+ }
+
+ return responses, total, nil
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/lost_item_service.go b/lost-and-found/internal/services/lost_item_service.go
new file mode 100644
index 0000000..fdf32f8
--- /dev/null
+++ b/lost-and-found/internal/services/lost_item_service.go
@@ -0,0 +1,207 @@
+package services
+
+import (
+ "errors"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+type LostItemService struct {
+ lostItemRepo *repositories.LostItemRepository
+ categoryRepo *repositories.CategoryRepository
+ auditLogRepo *repositories.AuditLogRepository
+}
+
+func NewLostItemService(db *gorm.DB) *LostItemService {
+ return &LostItemService{
+ lostItemRepo: repositories.NewLostItemRepository(db),
+ categoryRepo: repositories.NewCategoryRepository(db),
+ auditLogRepo: repositories.NewAuditLogRepository(db),
+ }
+}
+
+// CreateLostItemRequest represents create lost item data
+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"`
+}
+
+// UpdateLostItemRequest represents update lost item data
+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"`
+}
+
+// GetAllLostItems gets all lost items
+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 {
+ responses = append(responses, lostItem.ToResponse())
+ }
+
+ return responses, total, nil
+}
+
+// GetLostItemByID gets lost item by ID
+func (s *LostItemService) GetLostItemByID(id uint) (*models.LostItem, error) {
+ return s.lostItemRepo.FindByID(id)
+}
+
+// CreateLostItem creates a new lost item report
+func (s *LostItemService) CreateLostItem(userID uint, req CreateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
+ // Verify category exists
+ 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")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityLostItem, &lostItem.ID,
+ "Lost item report created: "+lostItem.Name, ipAddress, userAgent)
+
+ // Load with relations
+ return s.lostItemRepo.FindByID(lostItem.ID)
+}
+
+// UpdateLostItem updates a lost item report
+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
+ }
+
+ // Only owner can update
+ if lostItem.UserID != userID {
+ return nil, errors.New("unauthorized to update this lost item report")
+ }
+
+ // Only active reports can be updated
+ if !lostItem.IsActive() {
+ return nil, errors.New("cannot update non-active lost item report")
+ }
+
+ // Update fields
+ if req.Name != "" {
+ lostItem.Name = req.Name
+ }
+ if req.CategoryID != 0 {
+ // Verify category exists
+ if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
+ return nil, errors.New("invalid category")
+ }
+ lostItem.CategoryID = req.CategoryID
+ }
+ 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
+ }
+
+ if err := s.lostItemRepo.Update(lostItem); err != nil {
+ return nil, errors.New("failed to update lost item report")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
+ "Lost item report updated: "+lostItem.Name, ipAddress, userAgent)
+
+ return lostItem, nil
+}
+
+// UpdateLostItemStatus updates lost item status
+func (s *LostItemService) UpdateLostItemStatus(userID, lostItemID uint, status string, ipAddress, userAgent string) error {
+ lostItem, err := s.lostItemRepo.FindByID(lostItemID)
+ if err != nil {
+ return err
+ }
+
+ // Only owner can update
+ if lostItem.UserID != userID {
+ 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")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
+ "Lost item status updated to: "+status, ipAddress, userAgent)
+
+ return nil
+}
+
+// DeleteLostItem deletes a lost item report
+func (s *LostItemService) DeleteLostItem(userID, lostItemID uint, ipAddress, userAgent string) error {
+ lostItem, err := s.lostItemRepo.FindByID(lostItemID)
+ if err != nil {
+ return err
+ }
+
+ // Only owner can delete
+ if lostItem.UserID != userID {
+ 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")
+ }
+
+ // Log audit
+ s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityLostItem, &lostItemID,
+ "Lost item report deleted: "+lostItem.Name, ipAddress, userAgent)
+
+ return nil
+}
+
+// GetLostItemsByUser gets lost items by user
+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 {
+ responses = append(responses, lostItem.ToResponse())
+ }
+
+ return responses, total, nil
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/match_service.go b/lost-and-found/internal/services/match_service.go
new file mode 100644
index 0000000..fff50f9
--- /dev/null
+++ b/lost-and-found/internal/services/match_service.go
@@ -0,0 +1,239 @@
+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
+ totalScore := 0.0
+ maxScore := 0.0
+
+ // Category match (20 points)
+ maxScore += 20
+ if lostItem.CategoryID == item.CategoryID {
+ totalScore += 20
+ matchedFields = append(matchedFields, MatchedField{
+ Field: "category",
+ LostValue: lostItem.Category.Name,
+ FoundValue: item.Category.Name,
+ Score: 20,
+ })
+ }
+
+ // Name similarity (30 points)
+ maxScore += 30
+ nameSimilarity := utils.CalculateStringSimilarity(lostItem.Name, item.Name)
+ nameScore := nameSimilarity * 30
+ totalScore += nameScore
+ if nameScore > 10 {
+ matchedFields = append(matchedFields, MatchedField{
+ Field: "name",
+ LostValue: lostItem.Name,
+ FoundValue: item.Name,
+ Score: nameScore,
+ })
+ }
+
+ // Color match (15 points)
+ if lostItem.Color != "" {
+ maxScore += 15
+ colorSimilarity := utils.CalculateStringSimilarity(lostItem.Color, item.Name+" "+item.Description)
+ colorScore := colorSimilarity * 15
+ totalScore += colorScore
+ if colorScore > 5 {
+ matchedFields = append(matchedFields, MatchedField{
+ Field: "color",
+ LostValue: lostItem.Color,
+ FoundValue: "matched in description",
+ Score: colorScore,
+ })
+ }
+ }
+
+ // Location match (20 points)
+ if lostItem.Location != "" {
+ maxScore += 20
+ locationSimilarity := utils.CalculateStringSimilarity(lostItem.Location, item.Location)
+ locationScore := locationSimilarity * 20
+ totalScore += locationScore
+ if locationScore > 10 {
+ matchedFields = append(matchedFields, MatchedField{
+ Field: "location",
+ LostValue: lostItem.Location,
+ FoundValue: item.Location,
+ Score: locationScore,
+ })
+ }
+ }
+
+ // Description keywords match (15 points)
+ maxScore += 15
+ descSimilarity := utils.CalculateStringSimilarity(lostItem.Description, item.Description)
+ descScore := descSimilarity * 15
+ totalScore += descScore
+ if descScore > 5 {
+ matchedFields = append(matchedFields, MatchedField{
+ Field: "description",
+ LostValue: "keywords matched",
+ FoundValue: "keywords matched",
+ Score: descScore,
+ })
+ }
+
+ // Calculate percentage
+ percentage := (totalScore / maxScore) * 100
+ if percentage > 100 {
+ percentage = 100
+ }
+
+ return percentage, matchedFields
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/notification_service.go b/lost-and-found/internal/services/notification_service.go
new file mode 100644
index 0000000..a75d0fa
--- /dev/null
+++ b/lost-and-found/internal/services/notification_service.go
@@ -0,0 +1,115 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/user_service.go b/lost-and-found/internal/services/user_service.go
new file mode 100644
index 0000000..8d94f93
--- /dev/null
+++ b/lost-and-found/internal/services/user_service.go
@@ -0,0 +1,190 @@
+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 {
+ userRepo *repositories.UserRepository
+ roleRepo *repositories.RoleRepository
+ auditLogRepo *repositories.AuditLogRepository
+}
+
+func NewUserService(db *gorm.DB) *UserService {
+ return &UserService{
+ 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 updates user profile
+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
+ }
+ if req.Phone != "" {
+ user.Phone = req.Phone
+ }
+ 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) {
+ return s.userRepo.GetUserStats(userID)
+}
+
+// GetAllUsers gets all users (admin only)
+func (s *UserService) GetAllUsers(page, limit int) ([]models.User, int64, error) {
+ return s.userRepo.FindAll(page, limit)
+}
+
+// GetUserByID gets user by ID (admin only)
+func (s *UserService) GetUserByID(id uint) (*models.User, error) {
+ return s.userRepo.FindByID(id)
+}
+
+// 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/services/verification_service.go b/lost-and-found/internal/services/verification_service.go
new file mode 100644
index 0000000..35e9c09
--- /dev/null
+++ b/lost-and-found/internal/services/verification_service.go
@@ -0,0 +1,153 @@
+package services
+
+import (
+ "encoding/json"
+ "errors"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+ "lost-and-found/internal/utils"
+
+ "gorm.io/gorm"
+)
+
+type VerificationService struct {
+ verificationRepo *repositories.ClaimVerificationRepository
+ claimRepo *repositories.ClaimRepository
+ itemRepo *repositories.ItemRepository
+}
+
+func NewVerificationService(db *gorm.DB) *VerificationService {
+ return &VerificationService{
+ verificationRepo: repositories.NewClaimVerificationRepository(db),
+ claimRepo: repositories.NewClaimRepository(db),
+ itemRepo: repositories.NewItemRepository(db),
+ }
+}
+
+// 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 description
+func (s *VerificationService) VerifyClaimDescription(claimID uint) (*VerificationResult, error) {
+ claim, err := s.claimRepo.FindByID(claimID)
+ if err != nil {
+ return nil, err
+ }
+
+ item, err := s.itemRepo.FindByID(claim.ItemID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Calculate similarity between claim description and item description
+ similarity := utils.CalculateStringSimilarity(claim.Description, item.Description)
+ similarityPercent := similarity * 100
+
+ // Extract matched keywords
+ claimKeywords := utils.ExtractKeywords(claim.Description)
+ itemKeywords := utils.ExtractKeywords(item.Description)
+ matchedKeywords := utils.FindMatchedKeywords(claimKeywords, itemKeywords)
+
+ // Determine match level
+ matchLevel := "low"
+ recommendation := "REJECT - Deskripsi tidak cocok"
+
+ if similarityPercent >= 70.0 {
+ matchLevel = "high"
+ recommendation = "APPROVE - Deskripsi sangat cocok"
+ } else if similarityPercent >= 50.0 {
+ matchLevel = "medium"
+ recommendation = "REVIEW - Perlu verifikasi lebih lanjut"
+ }
+
+ // 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,
+ "item_description": item.Description,
+ "matched_count": string(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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/error.go b/lost-and-found/internal/utils/error.go
new file mode 100644
index 0000000..448c296
--- /dev/null
+++ b/lost-and-found/internal/utils/error.go
@@ -0,0 +1,66 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/excel_export.go b/lost-and-found/internal/utils/excel_export.go
new file mode 100644
index 0000000..bb3d3c4
--- /dev/null
+++ b/lost-and-found/internal/utils/excel_export.go
@@ -0,0 +1,99 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/hash.go b/lost-and-found/internal/utils/hash.go
new file mode 100644
index 0000000..0ef2a3d
--- /dev/null
+++ b/lost-and-found/internal/utils/hash.go
@@ -0,0 +1,17 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/image_handler.go b/lost-and-found/internal/utils/image_handler.go
new file mode 100644
index 0000000..a4b7e43
--- /dev/null
+++ b/lost-and-found/internal/utils/image_handler.go
@@ -0,0 +1,187 @@
+package utils
+
+import (
+ "errors"
+ "fmt"
+ "image"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "mime/multipart"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "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
+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 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)
+
+ // 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
+}
+
+// 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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/matching.go b/lost-and-found/internal/utils/matching.go
new file mode 100644
index 0000000..9203179
--- /dev/null
+++ b/lost-and-found/internal/utils/matching.go
@@ -0,0 +1,102 @@
+package utils
+
+import (
+ "strings"
+)
+
+// CalculateMatchScore calculates match score between two items
+func CalculateMatchScore(item1, item2 map[string]interface{}) float64 {
+ totalScore := 0.0
+ maxScore := 0.0
+
+ // Name matching (30%)
+ maxScore += 30
+ if name1, ok1 := item1["name"].(string); ok1 {
+ if name2, ok2 := item2["name"].(string); ok2 {
+ similarity := CalculateStringSimilarity(name1, name2)
+ totalScore += similarity * 30
+ }
+ }
+
+ // Category matching (20%)
+ maxScore += 20
+ if cat1, ok1 := item1["category"].(string); ok1 {
+ if cat2, ok2 := item2["category"].(string); ok2 {
+ if strings.EqualFold(cat1, cat2) {
+ totalScore += 20
+ }
+ }
+ }
+
+ // Color matching (15%)
+ if color1, ok1 := item1["color"].(string); ok1 {
+ if color1 != "" {
+ maxScore += 15
+ if color2, ok2 := item2["color"].(string); ok2 {
+ if strings.Contains(strings.ToLower(color2), strings.ToLower(color1)) {
+ totalScore += 15
+ }
+ }
+ }
+ }
+
+ // Location matching (20%)
+ if loc1, ok1 := item1["location"].(string); ok1 {
+ if loc1 != "" {
+ maxScore += 20
+ if loc2, ok2 := item2["location"].(string); ok2 {
+ similarity := CalculateStringSimilarity(loc1, loc2)
+ totalScore += similarity * 20
+ }
+ }
+ }
+
+ // Description matching (15%)
+ maxScore += 15
+ if desc1, ok1 := item1["description"].(string); ok1 {
+ if desc2, ok2 := item2["description"].(string); ok2 {
+ similarity := CalculateStringSimilarity(desc1, desc2)
+ totalScore += similarity * 15
+ }
+ }
+
+ if maxScore == 0 {
+ return 0
+ }
+
+ return (totalScore / maxScore) * 100
+}
+
+// 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"
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/pdf_export.go b/lost-and-found/internal/utils/pdf_export.go
new file mode 100644
index 0000000..3d98ed4
--- /dev/null
+++ b/lost-and-found/internal/utils/pdf_export.go
@@ -0,0 +1,105 @@
+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()
+ pdf.SetFont("Arial", "", 12)
+
+ 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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/response.go b/lost-and-found/internal/utils/response.go
new file mode 100644
index 0000000..febc059
--- /dev/null
+++ b/lost-and-found/internal/utils/response.go
@@ -0,0 +1,67 @@
+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,
+ },
+ })
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/similarity.go b/lost-and-found/internal/utils/similarity.go
new file mode 100644
index 0000000..727462e
--- /dev/null
+++ b/lost-and-found/internal/utils/similarity.go
@@ -0,0 +1,159 @@
+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
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/utils/validator.go b/lost-and-found/internal/utils/validator.go
new file mode 100644
index 0000000..e4a64d7
--- /dev/null
+++ b/lost-and-found/internal/utils/validator.go
@@ -0,0 +1,84 @@
+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)
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/workers/audit_worker.go b/lost-and-found/internal/workers/audit_worker.go
new file mode 100644
index 0000000..01762ea
--- /dev/null
+++ b/lost-and-found/internal/workers/audit_worker.go
@@ -0,0 +1,72 @@
+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
+func (w *AuditWorker) Start() {
+ log.Println("🔍 Audit Worker started")
+
+ 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
+ }
+ }
+}
+
+// Stop stops the audit worker
+func (w *AuditWorker) Stop() {
+ w.stopChan <- true
+}
+
+// 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()
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/workers/expire_worker.go b/lost-and-found/internal/workers/expire_worker.go
new file mode 100644
index 0000000..79654ed
--- /dev/null
+++ b/lost-and-found/internal/workers/expire_worker.go
@@ -0,0 +1,103 @@
+package workers
+
+import (
+ "log"
+ "lost-and-found/internal/models"
+ "lost-and-found/internal/repositories"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// ExpireWorker handles item expiration background tasks
+type ExpireWorker struct {
+ db *gorm.DB
+ itemRepo *repositories.ItemRepository
+ archiveRepo *repositories.ArchiveRepository
+ stopChan chan bool
+}
+
+// NewExpireWorker creates a new expire worker
+func NewExpireWorker(db *gorm.DB) *ExpireWorker {
+ return &ExpireWorker{
+ db: db,
+ itemRepo: repositories.NewItemRepository(db),
+ archiveRepo: repositories.NewArchiveRepository(db),
+ stopChan: make(chan bool),
+ }
+}
+
+// Start starts the expire worker
+func (w *ExpireWorker) Start() {
+ log.Println("⏰ Expire Worker started")
+
+ // 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("⏰ Expire Worker stopped")
+ return
+ }
+ }
+}
+
+// Stop stops the expire worker
+func (w *ExpireWorker) Stop() {
+ w.stopChan <- true
+}
+
+// expireItems finds and expires items that have passed their expiration date
+func (w *ExpireWorker) expireItems() {
+ log.Println("🔍 Checking for expired items...")
+
+ // Find expired items
+ expiredItems, err := w.itemRepo.FindExpired()
+ if err != nil {
+ log.Printf("❌ Error finding expired items: %v", err)
+ return
+ }
+
+ if len(expiredItems) == 0 {
+ log.Println("✅ No expired items found")
+ return
+ }
+
+ log.Printf("📦 Found %d expired items", len(expiredItems))
+
+ // Process each expired item
+ expiredCount := 0
+ for _, item := range expiredItems {
+ if err := w.archiveExpiredItem(&item); err != nil {
+ log.Printf("❌ Failed to archive item ID %d: %v", item.ID, err)
+ continue
+ }
+ expiredCount++
+ }
+
+ log.Printf("✅ Successfully archived %d expired items", expiredCount)
+}
+
+// archiveExpiredItem archives an expired item
+func (w *ExpireWorker) archiveExpiredItem(item *models.Item) error {
+ // Archive the item
+ if err := w.itemRepo.ArchiveItem(item, models.ArchiveReasonExpired, nil); err != nil {
+ return err
+ }
+
+ log.Printf("📦 Archived expired item: %s (ID: %d)", item.Name, item.ID)
+ return nil
+}
+
+// RunNow runs expiration check immediately (for testing)
+func (w *ExpireWorker) RunNow() {
+ log.Println("▶️ Running expiration check manually...")
+ w.expireItems()
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/workers/matching_worker.go b/lost-and-found/internal/workers/matching_worker.go
new file mode 100644
index 0000000..0052496
--- /dev/null
+++ b/lost-and-found/internal/workers/matching_worker.go
@@ -0,0 +1,89 @@
+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
+func (w *MatchingWorker) Start() {
+ log.Println("🔗 Matching Worker started")
+
+ // 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
+ }
+ }
+}
+
+// Stop stops the matching worker
+func (w *MatchingWorker) Stop() {
+ w.stopChan <- true
+}
+
+// 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()
+}
\ No newline at end of file
diff --git a/lost-and-found/internal/workers/notification_worker.go b/lost-and-found/internal/workers/notification_worker.go
new file mode 100644
index 0000000..b250972
--- /dev/null
+++ b/lost-and-found/internal/workers/notification_worker.go
@@ -0,0 +1,116 @@
+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
+func (w *NotificationWorker) Start() {
+ log.Println("📬 Notification Worker started")
+
+ // 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
+ }
+ }
+}
+
+// Stop stops the notification worker
+func (w *NotificationWorker) Stop() {
+ w.stopChan <- true
+}
+
+// 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()
+}
\ No newline at end of file
diff --git a/lost-and-found/setup.go b/lost-and-found/setup.go
new file mode 100644
index 0000000..c6bda00
--- /dev/null
+++ b/lost-and-found/setup.go
@@ -0,0 +1,254 @@
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+var structure = []string{
+ // Main Entry Point
+ "cmd/server/main.go",
+
+ // Configuration
+ "internal/config/config.go",
+ "internal/config/database.go",
+ "internal/config/jwt.go",
+
+ // Models
+ "internal/models/user.go",
+ "internal/models/role.go",
+ "internal/models/item.go",
+ "internal/models/lost_item.go",
+ "internal/models/claim.go",
+ "internal/models/category.go",
+ "internal/models/archive.go",
+ "internal/models/audit_log.go",
+ "internal/models/claim_verification.go", // BARU: tracking verifikasi klaim
+ "internal/models/match_result.go", // BARU: hasil matching barang
+ "internal/models/revision_log.go", // BARU: audit trail edit barang
+ "internal/models/notification.go", // BARU: notifikasi (opsional)
+
+ // Repositories
+ "internal/repositories/user_repo.go",
+ "internal/repositories/role_repo.go",
+ "internal/repositories/item_repo.go",
+ "internal/repositories/lost_item_repo.go",
+ "internal/repositories/claim_repo.go",
+ "internal/repositories/category_repo.go",
+ "internal/repositories/archive_repo.go",
+ "internal/repositories/audit_log_repo.go",
+ "internal/repositories/claim_verification_repo.go", // BARU
+ "internal/repositories/match_result_repo.go", // BARU
+ "internal/repositories/revision_log_repo.go", // BARU
+ "internal/repositories/notification_repo.go", // BARU (opsional)
+
+ // Services
+ "internal/services/auth_service.go",
+ "internal/services/user_service.go",
+ "internal/services/item_service.go",
+ "internal/services/lost_item_service.go",
+ "internal/services/claim_service.go",
+ "internal/services/match_service.go",
+ "internal/services/category_service.go",
+ "internal/services/archive_service.go",
+ "internal/services/audit_service.go",
+ "internal/services/export_service.go",
+ "internal/services/verification_service.go", // BARU: logic verifikasi terpisah
+ "internal/services/notification_service.go", // BARU: handle notifikasi
+
+ // Controllers
+ "internal/controllers/auth_controller.go",
+ "internal/controllers/user_controller.go",
+ "internal/controllers/item_controller.go",
+ "internal/controllers/lost_item_controller.go",
+ "internal/controllers/claim_controller.go",
+ "internal/controllers/match_controller.go",
+ "internal/controllers/category_controller.go",
+ "internal/controllers/archive_controller.go",
+ "internal/controllers/admin_controller.go",
+ "internal/controllers/report_controller.go", // BARU: export laporan terpisah
+
+ // Middleware
+ "internal/middleware/jwt_middleware.go",
+ "internal/middleware/role_middleware.go",
+ "internal/middleware/logger.go",
+ "internal/middleware/cors.go",
+ "internal/middleware/rate_limiter.go", // BARU: rate limiting (recommended)
+
+ // Workers (Concurrency)
+ "internal/workers/expire_worker.go",
+ "internal/workers/audit_worker.go",
+ "internal/workers/matching_worker.go", // BARU: auto-matching background
+ "internal/workers/notification_worker.go", // BARU: kirim notifikasi
+
+ // Utils
+ "internal/utils/hash.go",
+ "internal/utils/response.go",
+ "internal/utils/error.go",
+ "internal/utils/validator.go",
+ "internal/utils/matching.go",
+ "internal/utils/similarity.go",
+ "internal/utils/pdf_export.go",
+ "internal/utils/excel_export.go",
+ "internal/utils/image_handler.go", // BARU: handle upload/resize foto
+
+ // Routes
+ "internal/routes/routes.go",
+
+ // Upload directories
+ "uploads/items/.gitkeep",
+ "uploads/lost_items/.gitkeep",
+ "uploads/claims/.gitkeep",
+
+ // Web Frontend
+ "web/index.html",
+ "web/login.html",
+ "web/admin.html",
+ "web/manager.html",
+ "web/user.html",
+ "web/css/style.css",
+ "web/js/main.js",
+ "web/js/admin.js",
+ "web/js/manager.js",
+ "web/js/user.js",
+
+ // Database (Manual migration via HeidiSQL)
+ "database/schema.sql",
+ "database/seed.sql",
+
+ // Root Files
+ ".env.example",
+ "README.md",
+ "Makefile", // BARU: untuk command shortcuts
+ "go.mod",
+ "go.sum",
+}
+
+func main() {
+ fmt.Println("🚀 Memulai pembuatan struktur project Lost & Found...")
+ fmt.Println("📦 Total file yang akan dibuat:", len(structure))
+ fmt.Println()
+
+ successCount := 0
+ failCount := 0
+
+ for _, path := range structure {
+ dir := getDir(path)
+ if dir != "" {
+ err := os.MkdirAll(dir, os.ModePerm)
+ if err != nil {
+ fmt.Printf("❌ Gagal buat folder: %s - %v\n", dir, err)
+ failCount++
+ continue
+ }
+ }
+
+ file, err := os.Create(path)
+ if err != nil {
+ fmt.Printf("❌ Gagal buat file: %s - %v\n", path, err)
+ failCount++
+ continue
+ }
+ file.Close()
+
+ fmt.Printf("✅ Dibuat: %s\n", path)
+ successCount++
+ }
+
+ fmt.Println()
+ fmt.Println("════════════════════════════════════════════════════")
+ fmt.Printf("🎉 Proses selesai!\n")
+ fmt.Printf("✅ Berhasil: %d file\n", successCount)
+ if failCount > 0 {
+ fmt.Printf("❌ Gagal: %d file\n", failCount)
+ }
+ fmt.Println("════════════════════════════════════════════════════")
+ fmt.Println()
+
+ printStructureInfo()
+ printNextSteps()
+ printNewFeatures()
+}
+
+func getDir(path string) string {
+ i := len(path) - 1
+ for i >= 0 && path[i] != '/' && path[i] != '\\' {
+ i--
+ }
+ if i > 0 {
+ return path[:i]
+ }
+ return ""
+}
+
+func printStructureInfo() {
+ fmt.Println("📁 Struktur Project:")
+ fmt.Println(" - cmd/server : Entry point aplikasi")
+ fmt.Println(" - internal/config : Konfigurasi (DB, JWT)")
+ fmt.Println(" - internal/models : Entity models (12 models)")
+ fmt.Println(" - internal/repos : Database operations")
+ fmt.Println(" - internal/services : Business logic")
+ fmt.Println(" - internal/controllers: HTTP handlers")
+ fmt.Println(" - internal/middleware : Auth, RBAC & logging")
+ fmt.Println(" - internal/workers : Background jobs (4 workers)")
+ fmt.Println(" - internal/utils : Helper functions")
+ fmt.Println(" - internal/routes : API routing")
+ fmt.Println(" - uploads : Storage untuk foto upload")
+ fmt.Println(" - web : Frontend files (HTML, CSS, JS)")
+ fmt.Println(" - database : Schema & seed (manual via HeidiSQL)")
+ fmt.Println()
+}
+
+func printNextSteps() {
+ fmt.Println("🔧 Next Steps:")
+ fmt.Println(" 1. Copy .env.example ke .env")
+ fmt.Println(" 2. Edit .env dengan konfigurasi database Anda")
+ fmt.Println(" 3. Run: go mod init lost-and-found")
+ fmt.Println(" 4. Run: go mod tidy")
+ fmt.Println(" 5. Install dependencies:")
+ fmt.Println(" - go get github.com/gin-gonic/gin")
+ fmt.Println(" - go get github.com/golang-jwt/jwt/v5")
+ fmt.Println(" - go get gorm.io/gorm")
+ fmt.Println(" - go get gorm.io/driver/postgres")
+ fmt.Println(" 6. Buka HeidiSQL dan jalankan database/schema.sql")
+ fmt.Println(" 7. (Opsional) Jalankan database/seed.sql untuk data dummy")
+ fmt.Println(" 8. Mulai coding dari cmd/server/main.go")
+ fmt.Println()
+}
+
+func printNewFeatures() {
+ fmt.Println("✨ File Baru yang Ditambahkan:")
+ fmt.Println()
+ fmt.Println("📌 MODELS (4 baru):")
+ fmt.Println(" • claim_verification.go → Tracking verifikasi & % matching")
+ fmt.Println(" • match_result.go → Hasil auto-matching barang")
+ fmt.Println(" • revision_log.go → Audit trail edit barang")
+ fmt.Println(" • notification.go → Notifikasi ke user")
+ fmt.Println()
+ fmt.Println("📌 SERVICES (2 baru):")
+ fmt.Println(" • verification_service.go → Logic verifikasi klaim terpisah")
+ fmt.Println(" • notification_service.go → Handle notifikasi")
+ fmt.Println()
+ fmt.Println("📌 WORKERS (2 baru):")
+ fmt.Println(" • matching_worker.go → Auto-matching background")
+ fmt.Println(" • notification_worker.go → Kirim notifikasi async")
+ fmt.Println()
+ fmt.Println("📌 UTILS (1 baru):")
+ fmt.Println(" • image_handler.go → Handle upload & resize foto")
+ fmt.Println()
+ fmt.Println("📌 MIDDLEWARE (1 baru):")
+ fmt.Println(" • rate_limiter.go → Rate limiting untuk security")
+ fmt.Println()
+ fmt.Println("📌 UPLOADS:")
+ fmt.Println(" • uploads/items/ → Foto barang ditemukan")
+ fmt.Println(" • uploads/lost_items/ → Foto barang hilang")
+ fmt.Println(" • uploads/claims/ → Foto bukti klaim")
+ fmt.Println()
+ fmt.Println("════════════════════════════════════════════════════")
+ fmt.Println("💡 Tips:")
+ fmt.Println(" - Foto disimpan di folder uploads/")
+ fmt.Println(" - Database hanya simpan path foto (bukan file)")
+ fmt.Println(" - Gunakan Makefile untuk shortcuts command")
+ fmt.Println(" - Database dikelola manual via HeidiSQL")
+ fmt.Println("════════════════════════════════════════════════════")
+}
\ No newline at end of file
diff --git a/lost-and-found/uploads/claims/.gitkeep b/lost-and-found/uploads/claims/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/lost-and-found/uploads/items/.gitkeep b/lost-and-found/uploads/items/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/lost-and-found/uploads/lost_items/.gitkeep b/lost-and-found/uploads/lost_items/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/lost-and-found/web/admin.html b/lost-and-found/web/admin.html
new file mode 100644
index 0000000..e99d9ba
--- /dev/null
+++ b/lost-and-found/web/admin.html
@@ -0,0 +1,316 @@
+
+
+
+
+
+ Dashboard Admin - Lost & Found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Nama |
+ Email |
+ NRP |
+ Role |
+ Status |
+ Aksi |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Filter Laporan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preview Laporan
+
+
+ Total Barang:
+ -
+
+
+ Diklaim:
+ -
+
+
+ Expired:
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Waktu |
+ User |
+ Aksi |
+ Detail |
+ IP Address |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lost-and-found/web/css/style.css b/lost-and-found/web/css/style.css
new file mode 100644
index 0000000..489d955
--- /dev/null
+++ b/lost-and-found/web/css/style.css
@@ -0,0 +1,675 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --primary: #2563eb;
+ --primary-dark: #1e40af;
+ --danger: #ef4444;
+ --success: #10b981;
+ --warning: #f59e0b;
+ --light: #f8fafc;
+ --dark: #1e293b;
+ --secondary: #64748b;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background: #f1f5f9;
+}
+
+/* Navbar */
+.navbar {
+ background: white;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ padding: 15px 30px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.navbar-brand {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+.navbar-menu {
+ display: flex;
+ gap: 20px;
+ align-items: center;
+}
+
+.nav-link {
+ text-decoration: none;
+ color: var(--dark);
+ font-weight: 500;
+ padding: 8px 15px;
+ border-radius: 8px;
+ transition: all 0.3s;
+}
+
+.nav-link:hover {
+ background: var(--light);
+ color: var(--primary);
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.user-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--primary);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+}
+
+.user-role {
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.btn-logout {
+ background: var(--danger);
+ color: white;
+ border: none;
+ padding: 8px 20px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.3s;
+}
+
+.btn-logout:hover {
+ background: #dc2626;
+}
+
+/* Container */
+.container {
+ max-width: 1400px;
+ margin: 30px auto;
+ padding: 0 20px;
+}
+
+.page-header {
+ margin-bottom: 30px;
+}
+
+.page-header h1 {
+ color: var(--dark);
+ font-size: 2rem;
+ margin-bottom: 10px;
+}
+
+/* Stats Grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+ margin-bottom: 30px;
+}
+
+.stats-grid-4 {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+}
+
+.stat-card {
+ background: white;
+ padding: 25px;
+ border-radius: 15px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+}
+
+.stat-card h3 {
+ color: var(--secondary);
+ font-size: 0.9rem;
+ margin-bottom: 10px;
+}
+
+.stat-number {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+.stat-success {
+ color: var(--success);
+}
+
+.stat-warning {
+ color: var(--warning);
+}
+
+.stat-danger {
+ color: var(--danger);
+}
+
+/* Tabs */
+.tabs {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 30px;
+ flex-wrap: wrap;
+}
+
+.tab-btn {
+ padding: 12px 25px;
+ background: white;
+ border: 2px solid #e2e8f0;
+ border-radius: 10px;
+ cursor: pointer;
+ font-weight: 600;
+ color: var(--secondary);
+ transition: all 0.3s;
+}
+
+.tab-btn.active {
+ background: var(--primary);
+ color: white;
+ border-color: var(--primary);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Card */
+.card {
+ background: white;
+ border-radius: 15px;
+ padding: 25px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.05);
+ margin-bottom: 20px;
+}
+
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.card-title {
+ font-size: 1.3rem;
+ color: var(--dark);
+ font-weight: 600;
+}
+
+/* Buttons */
+.btn {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--primary-dark);
+}
+
+.btn-success {
+ background: var(--success);
+ color: white;
+}
+
+.btn-success:hover {
+ background: #059669;
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+}
+
+.btn-danger:hover {
+ background: #dc2626;
+}
+
+.btn-warning {
+ background: var(--warning);
+ color: white;
+}
+
+.btn-warning:hover {
+ background: #d97706;
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 0.85rem;
+}
+
+/* Search Box */
+.search-box {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.search-input {
+ flex: 1;
+ min-width: 200px;
+ padding: 12px 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 10px;
+ font-size: 1rem;
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.filter-select {
+ padding: 12px 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 10px;
+ font-size: 1rem;
+}
+
+/* Items Grid */
+.items-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+}
+
+.item-card {
+ background: white;
+ border: 2px solid #e2e8f0;
+ border-radius: 15px;
+ overflow: hidden;
+ transition: all 0.3s;
+ cursor: pointer;
+}
+
+.item-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 10px 25px rgba(0,0,0,0.1);
+}
+
+.item-image {
+ width: 100%;
+ height: 200px;
+ object-fit: cover;
+ background: var(--light);
+}
+
+.item-body {
+ padding: 15px;
+}
+
+.item-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--dark);
+ margin-bottom: 8px;
+}
+
+.item-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ color: var(--secondary);
+ font-size: 0.9rem;
+ margin-bottom: 10px;
+}
+
+.item-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+/* Badge */
+.badge {
+ display: inline-block;
+ padding: 5px 12px;
+ border-radius: 20px;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.badge-success {
+ background: #d1fae5;
+ color: var(--success);
+}
+
+.badge-warning {
+ background: #fef3c7;
+ color: var(--warning);
+}
+
+.badge-danger {
+ background: #fee2e2;
+ color: var(--danger);
+}
+
+.badge-primary {
+ background: #dbeafe;
+ color: var(--primary);
+}
+
+/* Modal */
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0,0,0,0.5);
+ animation: fadeIn 0.3s;
+}
+
+.modal.active {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-content {
+ background: white;
+ border-radius: 20px;
+ max-width: 600px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ animation: slideUp 0.3s;
+}
+
+.modal-large {
+ max-width: 900px;
+}
+
+.modal-header {
+ padding: 25px;
+ border-bottom: 1px solid #e2e8f0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ color: var(--secondary);
+}
+
+.modal-body {
+ padding: 25px;
+}
+
+/* Form */
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 600;
+ color: var(--dark);
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 12px 15px;
+ border: 2px solid #e2e8f0;
+ border-radius: 10px;
+ font-size: 1rem;
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+/* Table */
+.table-container {
+ overflow-x: auto;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 20px;
+}
+
+.data-table thead {
+ background: var(--light);
+}
+
+.data-table th,
+.data-table td {
+ padding: 15px;
+ text-align: left;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.data-table th {
+ font-weight: 600;
+ color: var(--dark);
+}
+
+.data-table tr:hover {
+ background: var(--light);
+}
+
+/* Claims List */
+.claims-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.claim-card {
+ background: white;
+ border: 2px solid #e2e8f0;
+ border-radius: 15px;
+ padding: 20px;
+ transition: all 0.3s;
+}
+
+.claim-card:hover {
+ box-shadow: 0 5px 15px rgba(0,0,0,0.1);
+}
+
+.claim-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+.claim-info {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 10px;
+ margin-bottom: 15px;
+}
+
+.claim-actions {
+ display: flex;
+ gap: 10px;
+}
+
+/* Categories Grid */
+.categories-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 20px;
+}
+
+.category-card {
+ background: white;
+ border: 2px solid #e2e8f0;
+ border-radius: 15px;
+ padding: 20px;
+ text-align: center;
+ transition: all 0.3s;
+}
+
+.category-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 5px 15px rgba(0,0,0,0.1);
+}
+
+.category-icon {
+ font-size: 3rem;
+ margin-bottom: 10px;
+}
+
+/* Report Section */
+.report-section {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 30px;
+}
+
+.report-filters,
+.report-preview {
+ background: var(--light);
+ padding: 20px;
+ border-radius: 15px;
+}
+
+.report-actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.report-stats {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 15px;
+}
+
+.report-stat-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 10px;
+ background: white;
+ border-radius: 8px;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--secondary);
+}
+
+.empty-state-icon {
+ font-size: 4rem;
+ margin-bottom: 20px;
+}
+
+/* Loading */
+.loading {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 3px solid rgba(255,255,255,.3);
+ border-radius: 50%;
+ border-top-color: white;
+ animation: spin 1s ease-in-out infinite;
+}
+
+/* Animations */
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(50px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .navbar {
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .navbar-menu {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .items-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .search-box {
+ flex-direction: column;
+ }
+
+ .search-input {
+ width: 100%;
+ }
+
+ .report-section {
+ grid-template-columns: 1fr;
+ }
+
+ .data-table {
+ font-size: 0.9rem;
+ }
+
+ .data-table th,
+ .data-table td {
+ padding: 10px;
+ }
+}
\ No newline at end of file
diff --git a/lost-and-found/web/index.html b/lost-and-found/web/index.html
new file mode 100644
index 0000000..2123eeb
--- /dev/null
+++ b/lost-and-found/web/index.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+ Lost & Found System
+
+
+
+
+
+
+
+
+
+
+
📢
+
Lapor Kehilangan
+
Laporkan barang yang hilang dengan mudah dan cepat
+
+
+
+
📦
+
Temukan Barang
+
Cari barang temuanmu di database kami
+
+
+
+
🤝
+
Klaim Barang
+
Proses klaim yang aman dengan verifikasi
+
+
+
+
⚡
+
Auto Matching
+
Sistem otomatis mencocokkan barang hilang
+
+
+
+
+
+
+
+
+
+
0
+
Pengguna Terdaftar
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lost-and-found/web/js/admin.js b/lost-and-found/web/js/admin.js
new file mode 100644
index 0000000..0433b78
--- /dev/null
+++ b/lost-and-found/web/js/admin.js
@@ -0,0 +1,434 @@
+// Dashboard Admin JavaScript - FIXED ENDPOINTS
+
+let allUsers = [];
+let allCategories = [];
+let allAuditLogs = [];
+
+// Initialize dashboard
+window.addEventListener("DOMContentLoaded", async () => {
+ const user = checkAuth();
+ if (!user || user.role !== "admin") {
+ window.location.href = "/login";
+ return;
+ }
+
+ await loadStats();
+ await loadUsers();
+
+ setupSearchAndFilters();
+ setupReportFilters();
+});
+
+// Load statistics - FIXED
+async function loadStats() {
+ try {
+ const stats = await apiCall("/api/admin/dashboard");
+ document.getElementById("statTotalUsers").textContent =
+ stats.total_users || 0;
+ document.getElementById("statTotalItems").textContent =
+ stats.total_items || 0;
+ document.getElementById("statTotalClaims").textContent =
+ stats.total_claims || 0;
+ document.getElementById("statTotalArchive").textContent =
+ stats.total_archive || 0;
+ } catch (error) {
+ console.error("Error loading stats:", error);
+ }
+}
+
+// Load users - CORRECT (sudah sesuai)
+async function loadUsers() {
+ try {
+ const response = await apiCall("/api/admin/users");
+ allUsers = response.data || [];
+ renderUsers(allUsers);
+ } catch (error) {
+ console.error("Error loading users:", error);
+ showAlert("Gagal memuat data user", "danger");
+ }
+}
+
+// Render users
+function renderUsers(users) {
+ const tbody = document.getElementById("usersTableBody");
+
+ if (!users || users.length === 0) {
+ tbody.innerHTML = `
+
+ |
+ Belum ada data user
+ |
+
+ `;
+ return;
+ }
+
+ tbody.innerHTML = users
+ .map(
+ (user) => `
+
+ | ${user.name} |
+ ${user.email} |
+ ${user.nrp} |
+ ${getRoleBadge(user.role)} |
+ ${getStatusBadge(user.status || "active")} |
+
+
+ ${
+ user.status === "active"
+ ? ``
+ : ``
+ }
+ |
+
+ `
+ )
+ .join("");
+}
+
+// Edit user
+async function editUser(userId) {
+ try {
+ const user = await apiCall(`/api/admin/users/${userId}`);
+
+ const form = document.getElementById("editUserForm");
+ form.elements.user_id.value = user.id;
+ form.elements.name.value = user.name;
+ form.elements.email.value = user.email;
+ form.elements.nrp.value = user.nrp;
+ form.elements.phone.value = user.phone || "";
+ form.elements.role.value = user.role;
+
+ openModal("editUserModal");
+ } catch (error) {
+ console.error("Error loading user:", error);
+ showAlert("Gagal memuat data user", "danger");
+ }
+}
+
+// Submit edit user
+document
+ .getElementById("editUserForm")
+ ?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+ const userId = formData.get("user_id");
+ const role = formData.get("role");
+
+ try {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Menyimpan...';
+
+ // Update role
+ await apiCall(`/api/admin/users/${userId}/role`, {
+ method: "PATCH",
+ body: JSON.stringify({ role }),
+ });
+
+ showAlert("User berhasil diupdate!", "success");
+ closeModal("editUserModal");
+ await loadUsers();
+ } catch (error) {
+ console.error("Error updating user:", error);
+ showAlert(error.message || "Gagal update user", "danger");
+ } finally {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Update User";
+ }
+ }
+ });
+
+// Block user
+async function blockUser(userId) {
+ if (!confirmAction("Block user ini?")) return;
+
+ try {
+ await apiCall(`/api/admin/users/${userId}/block`, {
+ method: "POST",
+ });
+
+ showAlert("User berhasil diblock!", "success");
+ await loadUsers();
+ } catch (error) {
+ console.error("Error blocking user:", error);
+ showAlert(error.message || "Gagal block user", "danger");
+ }
+}
+
+// Unblock user
+async function unblockUser(userId) {
+ if (!confirmAction("Unblock user ini?")) return;
+
+ try {
+ await apiCall(`/api/admin/users/${userId}/unblock`, {
+ method: "POST",
+ });
+
+ showAlert("User berhasil di-unblock!", "success");
+ await loadUsers();
+ } catch (error) {
+ console.error("Error unblocking user:", error);
+ showAlert(error.message || "Gagal unblock user", "danger");
+ }
+}
+
+// Load categories
+async function loadCategories() {
+ try {
+ const response = await apiCall("/api/categories");
+ allCategories = response.data || [];
+ renderCategories(allCategories);
+ } catch (error) {
+ console.error("Error loading categories:", error);
+ showAlert("Gagal memuat data kategori", "danger");
+ }
+}
+
+// Render categories
+function renderCategories(categories) {
+ const grid = document.getElementById("categoriesGrid");
+
+ if (!categories || categories.length === 0) {
+ grid.innerHTML = `
+
+
🏷️
+
Belum ada kategori
+
+ `;
+ return;
+ }
+
+ const icons = {
+ pakaian: "👕",
+ alat_makan: "🍽️",
+ aksesoris: "👓",
+ elektronik: "💻",
+ alat_tulis: "✏️",
+ lainnya: "📦",
+ };
+
+ grid.innerHTML = categories
+ .map(
+ (cat) => `
+
+
${icons[cat.slug] || "📦"}
+
${cat.name}
+ ${
+ cat.description
+ ? `
${cat.description}
`
+ : ""
+ }
+
+
+
+
+
+ `
+ )
+ .join("");
+}
+
+// Submit add category
+document
+ .getElementById("addCategoryForm")
+ ?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+ const data = Object.fromEntries(formData);
+
+ try {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Menyimpan...';
+
+ await apiCall("/api/admin/categories", {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+
+ showAlert("Kategori berhasil ditambahkan!", "success");
+ closeModal("addCategoryModal");
+ e.target.reset();
+ await loadCategories();
+ } catch (error) {
+ console.error("Error adding category:", error);
+ showAlert(error.message || "Gagal menambahkan kategori", "danger");
+ } finally {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Tambah Kategori";
+ }
+ }
+ });
+
+// Edit category
+async function editCategory(catId) {
+ const newName = prompt("Nama kategori baru:");
+ if (!newName) return;
+
+ try {
+ await apiCall(`/api/admin/categories/${catId}`, {
+ method: "PUT",
+ body: JSON.stringify({ name: newName }),
+ });
+
+ showAlert("Kategori berhasil diupdate!", "success");
+ await loadCategories();
+ } catch (error) {
+ console.error("Error updating category:", error);
+ showAlert(error.message || "Gagal update kategori", "danger");
+ }
+}
+
+// Delete category
+async function deleteCategory(catId) {
+ if (!confirmAction("Hapus kategori ini?")) return;
+
+ try {
+ await apiCall(`/api/admin/categories/${catId}`, {
+ method: "DELETE",
+ });
+
+ showAlert("Kategori berhasil dihapus!", "success");
+ await loadCategories();
+ } catch (error) {
+ console.error("Error deleting category:", error);
+ showAlert(error.message || "Gagal hapus kategori", "danger");
+ }
+}
+
+// Setup report filters - SIMPLIFIED (remove preview)
+function setupReportFilters() {
+ // Remove preview functionality since endpoint doesn't exist
+ // Just setup the export buttons
+}
+
+// Export report
+async function exportReport(format) {
+ const period = document.getElementById("reportPeriod")?.value;
+ const type = document.getElementById("reportType")?.value;
+ const startDate = document.getElementById("reportStartDate")?.value;
+ const endDate = document.getElementById("reportEndDate")?.value;
+
+ let url = `/api/admin/reports/export?format=${format}&period=${period}&type=${type}`;
+
+ if (period === "custom" && startDate && endDate) {
+ url += `&start_date=${startDate}&end_date=${endDate}`;
+ }
+
+ try {
+ const token = getToken();
+ const response = await fetch(`${API_URL}${url}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error("Export failed");
+ }
+
+ const blob = await response.blob();
+ const downloadUrl = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = downloadUrl;
+ a.download = `laporan_${type}_${period}.${
+ format === "pdf" ? "pdf" : "xlsx"
+ }`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(downloadUrl);
+ a.remove();
+
+ showAlert(
+ `Laporan ${format.toUpperCase()} berhasil didownload!`,
+ "success"
+ );
+ } catch (error) {
+ console.error("Error exporting report:", error);
+ showAlert("Gagal export laporan", "danger");
+ }
+}
+
+// Load audit logs
+async function loadAudit() {
+ try {
+ const response = await apiCall("/api/admin/audit-logs");
+ allAuditLogs = response.data || [];
+ renderAuditLogs(allAuditLogs);
+ } catch (error) {
+ console.error("Error loading audit logs:", error);
+ showAlert("Gagal memuat audit log", "danger");
+ }
+}
+
+// Render audit logs
+function renderAuditLogs(logs) {
+ const tbody = document.getElementById("auditTableBody");
+
+ if (!logs || logs.length === 0) {
+ tbody.innerHTML = `
+
+ |
+ Belum ada audit log
+ |
+
+ `;
+ return;
+ }
+
+ tbody.innerHTML = logs
+ .map(
+ (log) => `
+
+ | ${formatDateTime(log.created_at)} |
+ ${log.user_name} |
+ ${log.action} |
+ ${log.details || "-"} |
+ ${log.ip_address || "-"} |
+
+ `
+ )
+ .join("");
+}
+
+// Setup search and filters
+function setupSearchAndFilters() {
+ const searchUsers = document.getElementById("searchUsers");
+ const roleFilter = document.getElementById("roleFilter");
+ const statusFilter = document.getElementById("statusFilter");
+
+ const performUsersSearch = debounce(() => {
+ const searchTerm = searchUsers?.value.toLowerCase() || "";
+ const role = roleFilter?.value || "";
+ const status = statusFilter?.value || "";
+
+ let filtered = allUsers.filter((user) => {
+ const matchesSearch =
+ user.name.toLowerCase().includes(searchTerm) ||
+ user.email.toLowerCase().includes(searchTerm) ||
+ user.nrp.includes(searchTerm);
+ const matchesRole = !role || user.role === role;
+ const matchesStatus = !status || user.status === status;
+ return matchesSearch && matchesRole && matchesStatus;
+ });
+
+ renderUsers(filtered);
+ }, 300);
+
+ searchUsers?.addEventListener("input", performUsersSearch);
+ roleFilter?.addEventListener("change", performUsersSearch);
+ statusFilter?.addEventListener("change", performUsersSearch);
+}
diff --git a/lost-and-found/web/js/main.js b/lost-and-found/web/js/main.js
new file mode 100644
index 0000000..534530d
--- /dev/null
+++ b/lost-and-found/web/js/main.js
@@ -0,0 +1,349 @@
+// Main.js - Shared functions across all dashboards
+const API_URL = "http://localhost:8080";
+
+// Auth utilities
+function getToken() {
+ return localStorage.getItem("token");
+}
+
+function getCurrentUser() {
+ const user = localStorage.getItem("user");
+ return user ? JSON.parse(user) : null;
+}
+
+function setAuth(token, user) {
+ localStorage.setItem("token", token);
+ localStorage.setItem("user", JSON.stringify(user));
+}
+
+function clearAuth() {
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+}
+
+// Check authentication - FIXED
+function checkAuth() {
+ const token = getToken();
+ const user = getCurrentUser();
+
+ if (!token || !user) {
+ window.location.href = "/login"; // FIXED: tambah /
+ return null;
+ }
+
+ return user;
+}
+
+// Logout - FIXED
+function logout() {
+ if (confirm("Apakah Anda yakin ingin logout?")) {
+ clearAuth();
+ window.location.href = "/login"; // FIXED: tambah /
+ }
+}
+
+// API call helper
+async function apiCall(endpoint, options = {}) {
+ const token = getToken();
+
+ const defaultOptions = {
+ headers: {
+ "Content-Type": "application/json",
+ ...(token && { Authorization: `Bearer ${token}` }),
+ },
+ };
+
+ const finalOptions = {
+ ...defaultOptions,
+ ...options,
+ headers: {
+ ...defaultOptions.headers,
+ ...options.headers,
+ },
+ };
+
+ try {
+ const response = await fetch(`${API_URL}${endpoint}`, finalOptions);
+ const data = await response.json();
+
+ if (!response.ok) {
+ // Handle 401 Unauthorized
+ if (response.status === 401) {
+ showAlert("Session expired. Please login again.", "danger");
+ setTimeout(() => {
+ clearAuth();
+ window.location.href = "/login"; // FIXED: tambah /
+ }, 1500);
+ throw new Error("Unauthorized");
+ }
+
+ throw new Error(data.error || "Request failed");
+ }
+
+ return data;
+ } catch (error) {
+ console.error("API call error:", error);
+ throw error;
+ }
+}
+
+// API call for file upload - FIXED: support PUT method
+async function apiUpload(endpoint, formData, method = "POST") {
+ const token = getToken();
+
+ try {
+ const response = await fetch(`${API_URL}${endpoint}`, {
+ method: method, // FIXED: bisa POST atau PUT
+ headers: {
+ ...(token && { Authorization: `Bearer ${token}` }),
+ // JANGAN set Content-Type untuk FormData, browser akan set otomatis dengan boundary
+ },
+ body: formData,
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ showAlert("Session expired. Please login again.", "danger");
+ setTimeout(() => {
+ clearAuth();
+ window.location.href = "/login"; // FIXED: tambah /
+ }, 1500);
+ throw new Error("Unauthorized");
+ }
+
+ throw new Error(data.error || "Upload failed");
+ }
+
+ return data;
+ } catch (error) {
+ console.error("Upload error:", error);
+ throw error;
+ }
+}
+
+// Tab switching
+function switchTab(tabName) {
+ // Remove active class from all tabs
+ document.querySelectorAll(".tab-btn").forEach((btn) => {
+ btn.classList.remove("active");
+ });
+ document.querySelectorAll(".tab-content").forEach((content) => {
+ content.classList.remove("active");
+ });
+
+ // Add active class to selected tab
+ event.target.classList.add("active");
+ document.getElementById(tabName + "Tab").classList.add("active");
+
+ // Trigger load function for specific tab if exists
+ const loadFunctionName = `load${capitalize(tabName)}`;
+ if (typeof window[loadFunctionName] === "function") {
+ window[loadFunctionName]();
+ }
+}
+
+// Modal utilities
+function openModal(modalId) {
+ document.getElementById(modalId).classList.add("active");
+}
+
+function closeModal(modalId) {
+ document.getElementById(modalId).classList.remove("active");
+}
+
+// Close modal when clicking outside
+window.addEventListener("click", (e) => {
+ if (e.target.classList.contains("modal")) {
+ e.target.classList.remove("active");
+ }
+});
+
+// Alert notification
+function showAlert(message, type = "info") {
+ const alertDiv = document.createElement("div");
+ alertDiv.className = `alert alert-${type}`;
+ alertDiv.textContent = message;
+ alertDiv.style.cssText = `
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ padding: 15px 20px;
+ border-radius: 10px;
+ box-shadow: 0 5px 15px rgba(0,0,0,0.2);
+ z-index: 9999;
+ animation: slideInRight 0.3s;
+ `;
+
+ // Set colors based on type
+ const colors = {
+ success: { bg: "#d1fae5", color: "#10b981", border: "#10b981" },
+ danger: { bg: "#fee2e2", color: "#ef4444", border: "#ef4444" },
+ warning: { bg: "#fef3c7", color: "#f59e0b", border: "#f59e0b" },
+ info: { bg: "#dbeafe", color: "#2563eb", border: "#2563eb" },
+ };
+
+ const colorScheme = colors[type] || colors.info;
+ alertDiv.style.background = colorScheme.bg;
+ alertDiv.style.color = colorScheme.color;
+ alertDiv.style.border = `2px solid ${colorScheme.border}`;
+
+ document.body.appendChild(alertDiv);
+
+ setTimeout(() => {
+ alertDiv.style.animation = "slideOutRight 0.3s";
+ setTimeout(() => alertDiv.remove(), 300);
+ }, 3000);
+}
+
+// Format date
+function formatDate(dateString) {
+ const date = new Date(dateString);
+ const options = { year: "numeric", month: "long", day: "numeric" };
+ return date.toLocaleDateString("id-ID", options);
+}
+
+// Format datetime
+function formatDateTime(dateString) {
+ const date = new Date(dateString);
+ const options = {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ };
+ return date.toLocaleDateString("id-ID", options);
+}
+
+// Capitalize first letter
+function capitalize(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+// Get status badge HTML
+function getStatusBadge(status) {
+ const statusMap = {
+ unclaimed: { label: "Unclaimed", class: "badge-primary" },
+ pending_claim: { label: "Pending Claim", class: "badge-warning" },
+ verified: { label: "Verified", class: "badge-success" },
+ case_closed: { label: "Case Closed", class: "badge-success" },
+ expired: { label: "Expired", class: "badge-danger" },
+ pending: { label: "Pending", class: "badge-warning" },
+ approved: { label: "Approved", class: "badge-success" },
+ rejected: { label: "Rejected", class: "badge-danger" },
+ active: { label: "Active", class: "badge-success" },
+ blocked: { label: "Blocked", class: "badge-danger" },
+ };
+
+ const statusInfo = statusMap[status] || {
+ label: status,
+ class: "badge-primary",
+ };
+ return `${statusInfo.label}`;
+}
+
+// Get role badge HTML
+function getRoleBadge(role) {
+ const roleMap = {
+ admin: { label: "Admin", class: "badge-danger" },
+ manager: { label: "Manager", class: "badge-warning" },
+ user: { label: "User", class: "badge-primary" },
+ };
+
+ const roleInfo = roleMap[role] || { label: role, class: "badge-primary" };
+ return `${roleInfo.label}`;
+}
+
+// Confirm dialog
+function confirmAction(message) {
+ return confirm(message);
+}
+
+// Loading state
+function setLoading(elementId, isLoading) {
+ const element = document.getElementById(elementId);
+ if (!element) return;
+
+ if (isLoading) {
+ element.innerHTML = `
+
+ `;
+ }
+}
+
+// Empty state
+function showEmptyState(elementId, icon, message) {
+ const element = document.getElementById(elementId);
+ if (!element) return;
+
+ element.innerHTML = `
+
+
${icon}
+
Tidak ada data
+
${message}
+
+ `;
+}
+
+// Debounce function for search
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
+// Add CSS animation styles
+const style = document.createElement("style");
+style.textContent = `
+ @keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ }
+
+ @keyframes slideOutRight {
+ from {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ }
+`;
+document.head.appendChild(style);
+
+// Initialize user info in navbar
+function initUserInfo() {
+ const user = getCurrentUser();
+ if (!user) return;
+
+ const userNameEl = document.getElementById("userName");
+ const userAvatarEl = document.getElementById("userAvatar");
+
+ if (userNameEl) userNameEl.textContent = user.name;
+ if (userAvatarEl)
+ userAvatarEl.textContent = user.name.charAt(0).toUpperCase();
+}
+
+// Initialize on page load
+window.addEventListener("DOMContentLoaded", () => {
+ initUserInfo();
+});
diff --git a/lost-and-found/web/js/manager.js b/lost-and-found/web/js/manager.js
new file mode 100644
index 0000000..58dfb71
--- /dev/null
+++ b/lost-and-found/web/js/manager.js
@@ -0,0 +1,767 @@
+// Dashboard Manager JavaScript - FIXED ENDPOINTS
+
+let allItems = [];
+let allClaims = [];
+let allLostItems = [];
+let allArchive = [];
+
+// Initialize dashboard
+window.addEventListener("DOMContentLoaded", async () => {
+ const user = checkAuth();
+ if (!user || user.role !== "manager") {
+ window.location.href = "/login";
+ return;
+ }
+
+ await loadStats();
+ await loadItems();
+ setupSearchAndFilters();
+});
+
+// Load statistics - FIXED
+async function loadStats() {
+ try {
+ const stats = await apiCall("/api/manager/dashboard");
+ document.getElementById("statTotalItems").textContent =
+ stats.total_items || 0;
+ document.getElementById("statPendingClaims").textContent =
+ stats.pending_claims || 0;
+ document.getElementById("statVerified").textContent = stats.verified || 0;
+ document.getElementById("statExpired").textContent = stats.expired || 0;
+ } catch (error) {
+ console.error("Error loading stats:", error);
+ }
+}
+
+// Load items - FIXED
+async function loadItems() {
+ setLoading("itemsGrid", true);
+
+ try {
+ const response = await apiCall("/api/items");
+ allItems = response.data || [];
+ renderItems(allItems);
+ } catch (error) {
+ console.error("Error loading items:", error);
+ showEmptyState("itemsGrid", "📦", "Gagal memuat data barang");
+ }
+}
+
+// Render items
+function renderItems(items) {
+ const grid = document.getElementById("itemsGrid");
+
+ if (!items || items.length === 0) {
+ showEmptyState("itemsGrid", "📦", "Belum ada barang");
+ return;
+ }
+
+ grid.innerHTML = items
+ .map(
+ (item) => `
+
+

+
+
${item.name}
+
+ 📍 ${item.location}
+ 📅 ${formatDate(item.date_found)}
+ ${getStatusBadge(item.status)}
+
+
+
+ ${
+ item.status !== "case_closed" && item.status !== "expired"
+ ? ``
+ : ""
+ }
+ ${
+ item.status === "verified"
+ ? ``
+ : ""
+ }
+
+
+
+ `
+ )
+ .join("");
+}
+
+// View item detail - FIXED
+async function viewItemDetail(itemId) {
+ try {
+ const item = await apiCall(`/api/items/${itemId}`);
+
+ const modalContent = document.getElementById("itemDetailContent");
+ modalContent.innerHTML = `
+
+ ${item.name}
+
+
Kategori: ${item.category}
+
Lokasi Ditemukan: ${item.location}
+
Tanggal Ditemukan: ${formatDate(
+ item.date_found
+ )}
+
Status: ${getStatusBadge(item.status)}
+
Pelapor: ${item.reporter_name}
+
Kontak: ${item.reporter_contact}
+
+ Deskripsi Keunikan (Rahasia):
+ ${item.description}
+
+
+ `;
+
+ openModal("itemDetailModal");
+ } catch (error) {
+ console.error("Error loading item detail:", error);
+ showAlert("Gagal memuat detail barang", "danger");
+ }
+}
+
+// Edit item - FIXED
+async function editItem(itemId) {
+ try {
+ const item = await apiCall(`/api/items/${itemId}`);
+
+ const form = document.getElementById("editItemForm");
+ form.elements.item_id.value = item.id;
+ form.elements.name.value = item.name;
+ form.elements.category.value = item.category;
+ form.elements.location.value = item.location;
+ form.elements.description.value = item.description;
+ form.elements.reporter_name.value = item.reporter_name;
+ form.elements.reporter_contact.value = item.reporter_contact;
+ form.elements.date_found.value = item.date_found.split("T")[0];
+
+ openModal("editItemModal");
+ } catch (error) {
+ console.error("Error loading item:", error);
+ showAlert("Gagal memuat data barang", "danger");
+ }
+}
+
+// Submit edit item - FIXED
+document
+ .getElementById("editItemForm")
+ ?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+ const itemId = formData.get("item_id");
+ formData.delete("item_id");
+
+ try {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Menyimpan...';
+
+ await apiUpload(`/api/items/${itemId}`, formData, "PUT");
+
+ showAlert("Barang berhasil diupdate!", "success");
+ closeModal("editItemModal");
+ await loadItems();
+ await loadStats();
+ } catch (error) {
+ console.error("Error updating item:", error);
+ showAlert(error.message || "Gagal update barang", "danger");
+ } finally {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Update";
+ }
+ }
+ });
+
+// Close case - FIXED
+async function closeCase(itemId) {
+ if (!confirmAction("Tutup kasus ini? Barang akan dipindahkan ke arsip."))
+ return;
+
+ try {
+ await apiCall(`/api/items/${itemId}/status`, {
+ method: "PATCH",
+ body: JSON.stringify({ status: "case_closed" }),
+ });
+
+ showAlert("Kasus berhasil ditutup!", "success");
+ await loadItems();
+ await loadStats();
+ } catch (error) {
+ console.error("Error closing case:", error);
+ showAlert(error.message || "Gagal menutup kasus", "danger");
+ }
+}
+
+// Load claims - FIXED
+async function loadClaims() {
+ setLoading("claimsList", true);
+
+ try {
+ const response = await apiCall("/api/claims");
+ allClaims = response.data || [];
+ renderClaims(allClaims);
+ } catch (error) {
+ console.error("Error loading claims:", error);
+ document.getElementById("claimsList").innerHTML = `
+
+
🤝
+
Gagal memuat data klaim
+
+ `;
+ }
+}
+
+// Render claims
+function renderClaims(claims) {
+ const list = document.getElementById("claimsList");
+
+ if (!claims || claims.length === 0) {
+ list.innerHTML = `
+
+
🤝
+
Belum ada klaim yang masuk
+
+ `;
+ return;
+ }
+
+ list.innerHTML = claims
+ .map(
+ (claim) => `
+
+
+
+
Pengklaim: ${claim.user_name}
+
Kontak: ${claim.contact}
+
Tanggal Klaim: ${formatDate(
+ claim.created_at
+ )}
+ ${
+ claim.match_percentage
+ ? `
+
Match:
+
+ ${claim.match_percentage}%
+
+
+ `
+ : ""
+ }
+
+
+ Deskripsi dari Pengklaim:
+ ${claim.description}
+
+ ${
+ claim.status === "pending"
+ ? `
+
+
+
+
+
+ `
+ : ""
+ }
+ ${
+ claim.status === "rejected" && claim.notes
+ ? `
+
+ Alasan: ${claim.notes}
+
+ `
+ : ""
+ }
+
+ `
+ )
+ .join("");
+}
+
+// Verify claim - FIXED
+async function verifyClaim(claimId) {
+ try {
+ const claim = await apiCall(`/api/claims/${claimId}`);
+
+ const modalContent = document.getElementById("verifyClaimContent");
+ modalContent.innerHTML = `
+
+
+
Deskripsi Asli Barang
+
+ ${claim.item_description}
+
+
+
+
Deskripsi dari Pengklaim
+
+ ${claim.description}
+
+
+
+
+ ${
+ claim.proof_url
+ ? `
+
+
Bukti Pendukung
+

+
+ `
+ : ""
+ }
+
+ ${
+ claim.match_percentage
+ ? `
+
+ Similarity Match:
+
+ ${claim.match_percentage}%
+
+
+ `
+ : ""
+ }
+
+
+
Info Pengklaim:
+
+
Nama: ${claim.user_name}
+
Kontak: ${claim.contact}
+
+
+
+
+
+
+
+ `;
+
+ openModal("verifyClaimModal");
+ } catch (error) {
+ console.error("Error loading claim:", error);
+ showAlert("Gagal memuat data klaim", "danger");
+ }
+}
+
+// Approve claim - FIXED
+async function approveClaim(claimId) {
+ const notes = prompt("Catatan (opsional):");
+
+ try {
+ await apiCall(`/api/claims/${claimId}/verify`, {
+ method: "POST",
+ body: JSON.stringify({
+ approved: true,
+ notes: notes || "",
+ }),
+ });
+
+ showAlert("Klaim berhasil diapprove!", "success");
+ closeModal("verifyClaimModal");
+ await loadClaims();
+ await loadItems();
+ await loadStats();
+ } catch (error) {
+ console.error("Error approving claim:", error);
+ showAlert(error.message || "Gagal approve klaim", "danger");
+ }
+}
+
+// Reject claim - FIXED
+async function rejectClaim(claimId) {
+ const notes = prompt("Alasan penolakan (wajib):");
+ if (!notes) {
+ showAlert("Alasan penolakan harus diisi!", "warning");
+ return;
+ }
+
+ try {
+ await apiCall(`/api/claims/${claimId}/verify`, {
+ method: "POST",
+ body: JSON.stringify({
+ approved: false,
+ notes,
+ }),
+ });
+
+ showAlert("Klaim berhasil ditolak!", "success");
+ closeModal("verifyClaimModal");
+ await loadClaims();
+ await loadStats();
+ } catch (error) {
+ console.error("Error rejecting claim:", error);
+ showAlert(error.message || "Gagal reject klaim", "danger");
+ }
+}
+
+// Load lost items - FIXED
+async function loadLost() {
+ setLoading("lostItemsGrid", true);
+
+ try {
+ const response = await apiCall("/api/lost-items");
+ allLostItems = response.data || [];
+ renderLostItems(allLostItems);
+ } catch (error) {
+ console.error("Error loading lost items:", error);
+ showEmptyState("lostItemsGrid", "😢", "Gagal memuat data barang hilang");
+ }
+}
+
+// Render lost items
+function renderLostItems(items) {
+ const grid = document.getElementById("lostItemsGrid");
+
+ if (!items || items.length === 0) {
+ showEmptyState("lostItemsGrid", "😢", "Belum ada laporan barang hilang");
+ return;
+ }
+
+ grid.innerHTML = items
+ .map(
+ (item) => `
+
+
+
${item.name}
+
+ 🏷️ ${item.category}
+ 🎨 ${item.color}
+ 📅 ${formatDate(item.date_lost)}
+ ${item.location ? `📍 ${item.location}` : ""}
+
+
${
+ item.description
+ }
+
+ Pelapor: ${item.user_name}
+
+
+
+
+ `
+ )
+ .join("");
+}
+
+// Find similar items - FIXED
+async function findSimilarItems(lostItemId) {
+ try {
+ setLoading("matchItemsContent", true);
+ openModal("matchItemsModal");
+
+ const response = await apiCall(`/api/lost-items/${lostItemId}/matches`);
+ const matches = response.data || [];
+
+ const modalContent = document.getElementById("matchItemsContent");
+
+ if (matches.length === 0) {
+ modalContent.innerHTML = `
+
+
🔍
+
Tidak ada barang yang cocok
+
Belum ada barang ditemukan yang mirip dengan laporan ini
+
+ `;
+ return;
+ }
+
+ modalContent.innerHTML = `
+ Ditemukan ${
+ matches.length
+ } barang yang mungkin cocok:
+
+ ${matches
+ .map(
+ (match) => `
+
+

+
+
+
+ ${match.similarity}% Match
+
+
+
${match.name}
+
+ 📍 ${match.location}
+ 📅 ${formatDate(match.date_found)}
+ ${getStatusBadge(match.status)}
+
+
+
+
+ `
+ )
+ .join("")}
+
+ `;
+ } catch (error) {
+ console.error("Error finding similar items:", error);
+ document.getElementById("matchItemsContent").innerHTML = `
+
+
❌
+
Gagal mencari barang yang mirip
+
+ `;
+ }
+}
+
+// Load archive - FIXED
+async function loadArchive() {
+ setLoading("archiveGrid", true);
+
+ try {
+ const response = await apiCall("/api/archives");
+ allArchive = response.data || [];
+ renderArchive(allArchive);
+ } catch (error) {
+ console.error("Error loading archive:", error);
+ showEmptyState("archiveGrid", "📂", "Gagal memuat data arsip");
+ }
+}
+
+// Render archive
+function renderArchive(items) {
+ const grid = document.getElementById("archiveGrid");
+
+ if (!items || items.length === 0) {
+ showEmptyState("archiveGrid", "📂", "Belum ada barang di arsip");
+ return;
+ }
+
+ grid.innerHTML = items
+ .map(
+ (item) => `
+
+

+
+
${item.name}
+
+ 📍 ${item.location}
+ 📅 ${formatDate(item.date_found)}
+ ${getStatusBadge(item.status)}
+
+
+
+ `
+ )
+ .join("");
+}
+
+// Report found item - FIXED
+function openReportFoundModal() {
+ openModal("reportFoundModal");
+}
+
+// Submit found item report - FIXED
+document
+ .getElementById("reportFoundForm")
+ ?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+
+ try {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Mengirim...';
+
+ await apiUpload("/api/items", formData);
+
+ showAlert("Barang berhasil ditambahkan!", "success");
+ closeModal("reportFoundModal");
+ e.target.reset();
+ await loadItems();
+ await loadStats();
+ } catch (error) {
+ console.error("Error submitting item:", error);
+ showAlert(error.message || "Gagal menambahkan barang", "danger");
+ } finally {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Submit";
+ }
+ }
+ });
+
+// Setup search and filters
+function setupSearchAndFilters() {
+ // Items tab
+ const searchItems = document.getElementById("searchItems");
+ const categoryFilterItems = document.getElementById("categoryFilterItems");
+ const statusFilterItems = document.getElementById("statusFilterItems");
+ const sortItems = document.getElementById("sortItems");
+
+ const performItemsSearch = debounce(() => {
+ const searchTerm = searchItems?.value.toLowerCase() || "";
+ const category = categoryFilterItems?.value || "";
+ const status = statusFilterItems?.value || "";
+ const sort = sortItems?.value || "date_desc";
+
+ let filtered = allItems.filter((item) => {
+ const matchesSearch =
+ item.name.toLowerCase().includes(searchTerm) ||
+ item.location.toLowerCase().includes(searchTerm);
+ const matchesCategory = !category || item.category === category;
+ const matchesStatus = !status || item.status === status;
+ return matchesSearch && matchesCategory && matchesStatus;
+ });
+
+ // Sort
+ filtered.sort((a, b) => {
+ switch (sort) {
+ case "date_desc":
+ return new Date(b.date_found) - new Date(a.date_found);
+ case "date_asc":
+ return new Date(a.date_found) - new Date(b.date_found);
+ case "name_asc":
+ return a.name.localeCompare(b.name);
+ case "name_desc":
+ return b.name.localeCompare(a.name);
+ default:
+ return 0;
+ }
+ });
+
+ renderItems(filtered);
+ }, 300);
+
+ searchItems?.addEventListener("input", performItemsSearch);
+ categoryFilterItems?.addEventListener("change", performItemsSearch);
+ statusFilterItems?.addEventListener("change", performItemsSearch);
+ sortItems?.addEventListener("change", performItemsSearch);
+
+ // Claims tab
+ const searchClaims = document.getElementById("searchClaims");
+ const statusFilterClaims = document.getElementById("statusFilterClaims");
+
+ const performClaimsSearch = debounce(() => {
+ const searchTerm = searchClaims?.value.toLowerCase() || "";
+ const status = statusFilterClaims?.value || "";
+
+ let filtered = allClaims.filter((claim) => {
+ const matchesSearch =
+ claim.item_name.toLowerCase().includes(searchTerm) ||
+ claim.user_name.toLowerCase().includes(searchTerm);
+ const matchesStatus = !status || claim.status === status;
+ return matchesSearch && matchesStatus;
+ });
+
+ renderClaims(filtered);
+ }, 300);
+
+ searchClaims?.addEventListener("input", performClaimsSearch);
+ statusFilterClaims?.addEventListener("change", performClaimsSearch);
+}
+
+// Create edit item modal if not exists
+if (!document.getElementById("editItemModal")) {
+ const editItemModal = document.createElement("div");
+ editItemModal.id = "editItemModal";
+ editItemModal.className = "modal";
+ editItemModal.innerHTML = `
+
+ `;
+ document.body.appendChild(editItemModal);
+}
diff --git a/lost-and-found/web/js/user.js b/lost-and-found/web/js/user.js
new file mode 100644
index 0000000..7796542
--- /dev/null
+++ b/lost-and-found/web/js/user.js
@@ -0,0 +1,490 @@
+// Dashboard User JavaScript - FIXED ENDPOINTS
+
+let allItems = [];
+let allLostItems = [];
+let allClaims = [];
+
+// Initialize dashboard
+window.addEventListener("DOMContentLoaded", async () => {
+ const user = checkAuth();
+ if (!user || user.role !== "user") {
+ window.location.href = "/login";
+ return;
+ }
+
+ await loadStats();
+ await loadBrowseItems();
+ setupSearchAndFilters();
+});
+
+// Load statistics - CORRECT (sudah sesuai)
+async function loadStats() {
+ try {
+ const stats = await apiCall("/api/user/stats");
+ document.getElementById("statLost").textContent = stats.lost_items || 0;
+ document.getElementById("statFound").textContent = stats.found_items || 0;
+ document.getElementById("statClaims").textContent = stats.claims || 0;
+ } catch (error) {
+ console.error("Error loading stats:", error);
+ }
+}
+
+// Load browse items - CORRECT (sudah sesuai)
+async function loadBrowseItems() {
+ setLoading("itemsGrid", true);
+
+ try {
+ const response = await apiCall("/api/items");
+ allItems = response.data || [];
+ renderItems(allItems);
+ } catch (error) {
+ console.error("Error loading items:", error);
+ showEmptyState("itemsGrid", "📦", "Gagal memuat data barang");
+ }
+}
+
+// Render items
+function renderItems(items) {
+ const grid = document.getElementById("itemsGrid");
+
+ if (!items || items.length === 0) {
+ showEmptyState("itemsGrid", "📦", "Belum ada barang ditemukan");
+ return;
+ }
+
+ grid.innerHTML = items
+ .map(
+ (item) => `
+
+

+
+
${item.name}
+
+ 📍 ${item.location}
+ 📅 ${formatDate(item.date_found)}
+ ${getStatusBadge(item.status)}
+
+ ${
+ item.status === "unclaimed"
+ ? `
`
+ : ""
+ }
+
+
+ `
+ )
+ .join("");
+}
+
+// View item detail - CORRECT (sudah sesuai)
+async function viewItemDetail(itemId) {
+ try {
+ const item = await apiCall(`/api/items/${itemId}`);
+
+ const modalContent = document.getElementById("itemDetailContent");
+ modalContent.innerHTML = `
+
+ ${item.name}
+
+
Kategori: ${item.category}
+
Lokasi Ditemukan: ${item.location}
+
Tanggal Ditemukan: ${formatDate(
+ item.date_found
+ )}
+
Status: ${getStatusBadge(item.status)}
+
+ ${
+ item.status === "unclaimed"
+ ? ``
+ : ""
+ }
+ `;
+
+ openModal("itemDetailModal");
+ } catch (error) {
+ console.error("Error loading item detail:", error);
+ showAlert("Gagal memuat detail barang", "danger");
+ }
+}
+
+// Open claim modal
+function openClaimModal(itemId) {
+ closeModal("itemDetailModal");
+
+ const modalContent = document.getElementById("claimModalContent");
+ modalContent.innerHTML = `
+
+ `;
+
+ openModal("claimModal");
+}
+
+// Submit claim - CORRECT (sudah sesuai)
+async function submitClaim(event, itemId) {
+ event.preventDefault();
+
+ const form = event.target;
+ const formData = new FormData(form);
+ formData.append("item_id", itemId);
+
+ try {
+ const submitBtn = form.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Mengirim...';
+
+ await apiUpload("/api/claims", formData);
+
+ showAlert(
+ "Klaim berhasil disubmit! Menunggu verifikasi dari manager.",
+ "success"
+ );
+ closeModal("claimModal");
+ await loadBrowseItems();
+ await loadStats();
+ } catch (error) {
+ console.error("Error submitting claim:", error);
+ showAlert(error.message || "Gagal submit klaim", "danger");
+ } finally {
+ const submitBtn = form.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Submit Klaim";
+ }
+ }
+}
+
+// Load my lost items - CORRECT (sudah sesuai)
+async function loadLost() {
+ setLoading("lostItemsGrid", true);
+
+ try {
+ const response = await apiCall("/api/user/lost-items");
+ allLostItems = response.data || [];
+ renderLostItems(allLostItems);
+ } catch (error) {
+ console.error("Error loading lost items:", error);
+ showEmptyState("lostItemsGrid", "😢", "Gagal memuat data barang hilang");
+ }
+}
+
+// Render lost items
+function renderLostItems(items) {
+ const grid = document.getElementById("lostItemsGrid");
+
+ if (!items || items.length === 0) {
+ showEmptyState(
+ "lostItemsGrid",
+ "😢",
+ "Anda belum melaporkan barang hilang"
+ );
+ return;
+ }
+
+ grid.innerHTML = items
+ .map(
+ (item) => `
+
+
+
${item.name}
+
+ 🏷️ ${item.category}
+ 🎨 ${item.color}
+ 📅 ${formatDate(item.date_lost)}
+ ${item.location ? `📍 ${item.location}` : ""}
+
+
${
+ item.description
+ }
+
+
+ `
+ )
+ .join("");
+}
+
+// Load my found items - FIXED
+async function loadFound() {
+ setLoading("foundItemsGrid", true);
+
+ try {
+ const response = await apiCall("/api/user/items");
+ const items = response.data || [];
+ renderFoundItems(items);
+ } catch (error) {
+ console.error("Error loading found items:", error);
+ showEmptyState(
+ "foundItemsGrid",
+ "🎉",
+ "Gagal memuat data barang yang ditemukan"
+ );
+ }
+}
+
+// Render found items
+function renderFoundItems(items) {
+ const grid = document.getElementById("foundItemsGrid");
+
+ if (!items || items.length === 0) {
+ showEmptyState(
+ "foundItemsGrid",
+ "🎉",
+ "Anda belum melaporkan penemuan barang"
+ );
+ return;
+ }
+
+ grid.innerHTML = items
+ .map(
+ (item) => `
+
+

+
+
${item.name}
+
+ 📍 ${item.location}
+ 📅 ${formatDate(item.date_found)}
+ ${getStatusBadge(item.status)}
+
+
+
+ `
+ )
+ .join("");
+}
+
+// Load my claims - CORRECT (sudah sesuai)
+async function loadClaims() {
+ setLoading("claimsGrid", true);
+
+ try {
+ const response = await apiCall("/api/user/claims");
+ allClaims = response.data || [];
+ renderClaims(allClaims);
+ } catch (error) {
+ console.error("Error loading claims:", error);
+ showEmptyState("claimsGrid", "🤝", "Gagal memuat data klaim");
+ }
+}
+
+// Render claims
+function renderClaims(claims) {
+ const grid = document.getElementById("claimsGrid");
+
+ if (!claims || claims.length === 0) {
+ showEmptyState("claimsGrid", "🤝", "Anda belum pernah mengajukan klaim");
+ return;
+ }
+
+ grid.innerHTML = claims
+ .map(
+ (claim) => `
+
+
+
${claim.item_name}
+
+ 📅 ${formatDate(claim.created_at)}
+ ${getStatusBadge(claim.status)}
+
+
+ ${claim.description}
+
+ ${
+ claim.status === "rejected" && claim.notes
+ ? `
+
+ Alasan ditolak: ${claim.notes}
+
+ `
+ : ""
+ }
+
+
+ `
+ )
+ .join("");
+}
+
+// Report lost item
+function openReportLostModal() {
+ openModal("reportLostModal");
+}
+
+// Submit lost item report - CORRECT (sudah sesuai)
+document
+ .getElementById("reportLostForm")
+ ?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+ const data = Object.fromEntries(formData);
+
+ try {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Mengirim...';
+
+ await apiCall("/api/lost-items", {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+
+ showAlert("Laporan kehilangan berhasil disubmit!", "success");
+ closeModal("reportLostModal");
+ e.target.reset();
+ await loadLost();
+ await loadStats();
+ } catch (error) {
+ console.error("Error submitting lost item:", error);
+ showAlert(error.message || "Gagal submit laporan", "danger");
+ } finally {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Submit Laporan";
+ }
+ }
+ });
+
+// Report found item
+function openReportFoundModal() {
+ openModal("reportFoundModal");
+}
+
+// Submit found item report - CORRECT (sudah sesuai)
+document
+ .getElementById("reportFoundForm")
+ ?.addEventListener("submit", async (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+
+ try {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.innerHTML = ' Mengirim...';
+
+ await apiUpload("/api/items", formData);
+
+ showAlert("Laporan penemuan berhasil disubmit!", "success");
+ closeModal("reportFoundModal");
+ e.target.reset();
+ await loadFound();
+ await loadStats();
+ } catch (error) {
+ console.error("Error submitting found item:", error);
+ showAlert(error.message || "Gagal submit laporan", "danger");
+ } finally {
+ const submitBtn = e.target.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = "Submit Penemuan";
+ }
+ }
+ });
+
+// Setup search and filters
+function setupSearchAndFilters() {
+ const searchInput = document.getElementById("searchInput");
+ const categoryFilter = document.getElementById("categoryFilter");
+ const sortBy = document.getElementById("sortBy");
+
+ const performSearch = debounce(() => {
+ const searchTerm = searchInput?.value.toLowerCase() || "";
+ const category = categoryFilter?.value || "";
+ const sort = sortBy?.value || "date_desc";
+
+ let filtered = allItems.filter((item) => {
+ const matchesSearch =
+ item.name.toLowerCase().includes(searchTerm) ||
+ item.location.toLowerCase().includes(searchTerm);
+ const matchesCategory = !category || item.category === category;
+ return matchesSearch && matchesCategory;
+ });
+
+ // Sort
+ filtered.sort((a, b) => {
+ switch (sort) {
+ case "date_desc":
+ return new Date(b.date_found) - new Date(a.date_found);
+ case "date_asc":
+ return new Date(a.date_found) - new Date(b.date_found);
+ case "name_asc":
+ return a.name.localeCompare(b.name);
+ case "name_desc":
+ return b.name.localeCompare(a.name);
+ default:
+ return 0;
+ }
+ });
+
+ renderItems(filtered);
+ }, 300);
+
+ searchInput?.addEventListener("input", performSearch);
+ categoryFilter?.addEventListener("change", performSearch);
+ sortBy?.addEventListener("change", performSearch);
+}
+
+// Create claim modal if not exists
+if (!document.getElementById("claimModal")) {
+ const claimModal = document.createElement("div");
+ claimModal.id = "claimModal";
+ claimModal.className = "modal";
+ claimModal.innerHTML = `
+
+ `;
+ document.body.appendChild(claimModal);
+}
+
+// Create item detail modal if not exists
+if (!document.getElementById("itemDetailModal")) {
+ const itemDetailModal = document.createElement("div");
+ itemDetailModal.id = "itemDetailModal";
+ itemDetailModal.className = "modal";
+ itemDetailModal.innerHTML = `
+
+ `;
+ document.body.appendChild(itemDetailModal);
+}
diff --git a/lost-and-found/web/login.html b/lost-and-found/web/login.html
new file mode 100644
index 0000000..6c6a4cb
--- /dev/null
+++ b/lost-and-found/web/login.html
@@ -0,0 +1,396 @@
+
+
+
+
+
+ Login - Lost & Found
+
+
+
+
+
+
+
+
+
+
+
+
atau
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lost-and-found/web/manager.html b/lost-and-found/web/manager.html
new file mode 100644
index 0000000..0563ec9
--- /dev/null
+++ b/lost-and-found/web/manager.html
@@ -0,0 +1,253 @@
+
+
+
+
+
+ Dashboard Manager - Lost & Found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lost-and-found/web/register.html b/lost-and-found/web/register.html
new file mode 100644
index 0000000..482830c
--- /dev/null
+++ b/lost-and-found/web/register.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+ Register - Lost & Found
+
+
+
+
+
+
+
+
+
+
+
+
atau
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lost-and-found/web/user.html b/lost-and-found/web/user.html
new file mode 100644
index 0000000..a1cfb6a
--- /dev/null
+++ b/lost-and-found/web/user.html
@@ -0,0 +1,638 @@
+
+
+
+
+
+ Dashboard User - Lost & Found
+
+
+
+
+
+
+
+
+
+
+
Barang Hilang Saya
+
0
+
+
+
Barang yang Saya Temukan
+
0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file