This commit is contained in:
5803024019 2025-11-17 12:17:44 +07:00
parent 470d9fd236
commit 8da420c148
93 changed files with 14401 additions and 0 deletions

23
lost-and-found/.env Normal file
View File

@ -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=*

Binary file not shown.

File diff suppressed because one or more lines are too long

0
lost-and-found/Makefile Normal file
View File

0
lost-and-found/README.md Normal file
View File

View File

@ -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")
}

View File

@ -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;

View File

@ -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;

131
lost-and-found/go.sum Normal file
View File

@ -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=

View File

@ -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"
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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,
)
}
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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(),
}
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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"`
}

View File

@ -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,
)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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(&notification, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("notification not found")
}
return nil, err
}
return &notification, nil
}
// FindByUser finds notifications for a user
func (r *NotificationRepository) FindByUser(userID uint, page, limit int, onlyUnread bool) ([]models.Notification, int64, error) {
var notifications []models.Notification
var total int64
query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID)
// Filter unread if specified
if onlyUnread {
query = query.Where("is_read = ?", false)
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results
offset := (page - 1) * limit
err := query.Order("created_at DESC").
Offset(offset).Limit(limit).Find(&notifications).Error
if err != nil {
return nil, 0, err
}
return notifications, total, nil
}
// MarkAsRead marks a notification as read
func (r *NotificationRepository) MarkAsRead(id uint) error {
notification, err := r.FindByID(id)
if err != nil {
return err
}
notification.MarkAsRead()
return r.db.Save(notification).Error
}
// MarkAllAsRead marks all notifications for a user as read
func (r *NotificationRepository) MarkAllAsRead(userID uint) error {
return r.db.Model(&models.Notification{}).
Where("user_id = ? AND is_read = ?", userID, false).
Update("is_read", true).Error
}
// Delete deletes a notification
func (r *NotificationRepository) Delete(id uint) error {
return r.db.Delete(&models.Notification{}, id).Error
}
// DeleteAllForUser deletes all notifications for a user
func (r *NotificationRepository) DeleteAllForUser(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error
}
// CountUnread counts unread notifications for a user
func (r *NotificationRepository) CountUnread(userID uint) (int64, error) {
var count int64
err := r.db.Model(&models.Notification{}).
Where("user_id = ? AND is_read = ?", userID, false).
Count(&count).Error
return count, err
}
// Notify creates a notification (helper method)
func (r *NotificationRepository) Notify(userID uint, notifType, title, message, entityType string, entityID *uint) error {
return models.CreateNotification(r.db, userID, notifType, title, message, entityType, entityID)
}

View File

@ -0,0 +1,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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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,
},
})
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}

254
lost-and-found/setup.go Normal file
View File

@ -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("════════════════════════════════════════════════════")
}

View File

View File

View File

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Admin - Lost & Found</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">🔍 Lost & Found</div>
<div class="navbar-menu">
<div class="user-info">
<div class="user-avatar" id="userAvatar">A</div>
<span id="userName">Admin</span>
<span class="user-role badge-danger">Admin</span>
</div>
<button class="btn-logout" onclick="logout()">Logout</button>
</div>
</nav>
<div class="container">
<div class="page-header">
<h1>Dashboard Admin</h1>
<p>Kelola sistem Lost & Found</p>
</div>
<div class="stats-grid stats-grid-4">
<div class="stat-card">
<h3>Total User</h3>
<div class="stat-number" id="statTotalUsers">0</div>
</div>
<div class="stat-card">
<h3>Total Barang</h3>
<div class="stat-number" id="statTotalItems">0</div>
</div>
<div class="stat-card">
<h3>Total Klaim</h3>
<div class="stat-number" id="statTotalClaims">0</div>
</div>
<div class="stat-card">
<h3>Di Arsip</h3>
<div class="stat-number" id="statTotalArchive">0</div>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('users')">👥 Kelola User</button>
<button class="tab-btn" onclick="switchTab('categories')">🏷️ Kategori</button>
<button class="tab-btn" onclick="switchTab('reports')">📊 Laporan</button>
<button class="tab-btn" onclick="switchTab('audit')">🔍 Audit Log</button>
</div>
<!-- Tab: Kelola User -->
<div id="usersTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">Daftar User</h2>
<button class="btn btn-primary" onclick="openAddUserModal()">+ Tambah User</button>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari user..." id="searchUsers">
<select class="filter-select" id="roleFilter">
<option value="">Semua Role</option>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
<select class="filter-select" id="statusFilter">
<option value="">Semua Status</option>
<option value="active">Active</option>
<option value="blocked">Blocked</option>
</select>
</div>
<div class="table-container">
<table class="data-table" id="usersTable">
<thead>
<tr>
<th>Nama</th>
<th>Email</th>
<th>NRP</th>
<th>Role</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="usersTableBody"></tbody>
</table>
</div>
</div>
</div>
<!-- Tab: Kategori -->
<div id="categoriesTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Kelola Kategori</h2>
<button class="btn btn-primary" onclick="openAddCategoryModal()">+ Tambah Kategori</button>
</div>
<div class="categories-grid" id="categoriesGrid"></div>
</div>
</div>
<!-- Tab: Laporan -->
<div id="reportsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Export Laporan</h2>
</div>
<div class="report-section">
<div class="report-filters">
<h3>Filter Laporan</h3>
<div class="form-group">
<label>Periode</label>
<select id="reportPeriod">
<option value="month">Bulan Ini</option>
<option value="semester">Semester Ini</option>
<option value="year">Tahun Ini</option>
<option value="custom">Custom Range</option>
</select>
</div>
<div class="form-group" id="customDateRange" style="display: none;">
<label>Tanggal Mulai</label>
<input type="date" id="reportStartDate">
<label>Tanggal Akhir</label>
<input type="date" id="reportEndDate">
</div>
<div class="form-group">
<label>Tipe Laporan</label>
<select id="reportType">
<option value="all">Semua Data</option>
<option value="items">Barang Ditemukan</option>
<option value="lost">Barang Hilang</option>
<option value="claims">Klaim</option>
<option value="archive">Arsip</option>
</select>
</div>
<div class="report-actions">
<button class="btn btn-success" onclick="exportReport('pdf')">📄 Export PDF</button>
<button class="btn btn-success" onclick="exportReport('excel')">📊 Export Excel</button>
</div>
</div>
<div class="report-preview" id="reportPreview">
<h3>Preview Laporan</h3>
<div class="report-stats">
<div class="report-stat-item">
<strong>Total Barang:</strong>
<span id="previewTotalItems">-</span>
</div>
<div class="report-stat-item">
<strong>Diklaim:</strong>
<span id="previewClaimed">-</span>
</div>
<div class="report-stat-item">
<strong>Expired:</strong>
<span id="previewExpired">-</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Audit Log -->
<div id="auditTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Audit Log</h2>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari log..." id="searchAudit">
<select class="filter-select" id="actionFilter">
<option value="">Semua Aksi</option>
<option value="create">Create</option>
<option value="update">Update</option>
<option value="delete">Delete</option>
<option value="verify">Verify</option>
</select>
<input type="date" class="filter-select" id="dateFilter">
</div>
<div class="table-container">
<table class="data-table" id="auditTable">
<thead>
<tr>
<th>Waktu</th>
<th>User</th>
<th>Aksi</th>
<th>Detail</th>
<th>IP Address</th>
</tr>
</thead>
<tbody id="auditTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Add User -->
<div id="addUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Tambah User</h3>
<button class="close-btn" onclick="closeModal('addUserModal')">&times;</button>
</div>
<div class="modal-body">
<form id="addUserForm">
<div class="form-group">
<label>Nama Lengkap *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Email *</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>NRP *</label>
<input type="text" name="nrp" required>
</div>
<div class="form-group">
<label>No. Telepon *</label>
<input type="tel" name="phone" required>
</div>
<div class="form-group">
<label>Role *</label>
<select name="role" required>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label>Password *</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Tambah User</button>
</form>
</div>
</div>
</div>
<!-- Modal Edit User -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Edit User</h3>
<button class="close-btn" onclick="closeModal('editUserModal')">&times;</button>
</div>
<div class="modal-body">
<form id="editUserForm">
<input type="hidden" name="user_id">
<div class="form-group">
<label>Nama Lengkap *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Email *</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>NRP *</label>
<input type="text" name="nrp" required>
</div>
<div class="form-group">
<label>No. Telepon *</label>
<input type="tel" name="phone" required>
</div>
<div class="form-group">
<label>Role *</label>
<select name="role" required>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Update User</button>
</form>
</div>
</div>
</div>
<!-- Modal Add Category -->
<div id="addCategoryModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Tambah Kategori</h3>
<button class="close-btn" onclick="closeModal('addCategoryModal')">&times;</button>
</div>
<div class="modal-body">
<form id="addCategoryForm">
<div class="form-group">
<label>Nama Kategori *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Deskripsi</label>
<textarea name="description"></textarea>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Tambah Kategori</button>
</form>
</div>
</div>
</div>
<!-- REFACTORED: Update script references -->
<script src="js/main.js"></script>
<script src="js/admin.js"></script>
</body>
</html>

View File

@ -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;
}
}

View File

@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lost & Found System</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #2563eb;
--primary-dark: #1e40af;
--secondary: #64748b;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--light: #f8fafc;
--dark: #1e293b;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 1200px;
width: 100%;
}
.hero {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.hero-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
padding: 60px 40px;
text-align: center;
}
.hero-header h1 {
font-size: 3rem;
margin-bottom: 10px;
font-weight: 700;
}
.hero-header p {
font-size: 1.2rem;
opacity: 0.9;
}
.hero-body {
padding: 50px 40px;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin-bottom: 40px;
}
.feature-card {
background: var(--light);
padding: 30px;
border-radius: 15px;
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 15px;
}
.feature-card h3 {
color: var(--dark);
margin-bottom: 10px;
font-size: 1.3rem;
}
.feature-card p {
color: var(--secondary);
line-height: 1.6;
}
.cta-section {
text-align: center;
padding: 40px 0;
}
.cta-buttons {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 15px 40px;
border: none;
border-radius: 10px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.3);
}
.btn-outline {
background: transparent;
color: var(--primary);
border: 2px solid var(--primary);
}
.btn-outline:hover {
background: var(--primary);
color: white;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 50px;
padding: 40px;
background: var(--light);
border-radius: 15px;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 5px;
}
.stat-label {
color: var(--secondary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.hero-header h1 {
font-size: 2rem;
}
.hero-header p {
font-size: 1rem;
}
.hero-header, .hero-body {
padding: 40px 20px;
}
.features {
grid-template-columns: 1fr;
}
.cta-buttons {
flex-direction: column;
align-items: stretch;
}
.btn {
width: 100%;
}
}
.footer {
text-align: center;
padding: 30px;
color: white;
margin-top: 20px;
}
.footer p {
opacity: 0.8;
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<div class="hero-header">
<h1>🔍 Lost & Found System</h1>
<p>Sistem Manajemen Barang Hilang & Temuan Kampus</p>
</div>
<div class="hero-body">
<div class="features">
<div class="feature-card">
<div class="feature-icon">📢</div>
<h3>Lapor Kehilangan</h3>
<p>Laporkan barang yang hilang dengan mudah dan cepat</p>
</div>
<div class="feature-card">
<div class="feature-icon">📦</div>
<h3>Temukan Barang</h3>
<p>Cari barang temuanmu di database kami</p>
</div>
<div class="feature-card">
<div class="feature-icon">🤝</div>
<h3>Klaim Barang</h3>
<p>Proses klaim yang aman dengan verifikasi</p>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Auto Matching</h3>
<p>Sistem otomatis mencocokkan barang hilang</p>
</div>
</div>
<div class="cta-section">
<h2 style="margin-bottom: 30px; color: var(--dark);">Mulai Sekarang</h2>
<div class="cta-buttons">
<a href="/login" class="btn btn-primary">🔐 Login</a>
<a href="/register" class="btn btn-outline">📝 Register</a>
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-number" id="totalItems">0</div>
<div class="stat-label">Barang Ditemukan</div>
</div>
<div class="stat-item">
<div class="stat-number" id="totalClaimed">0</div>
<div class="stat-label">Sudah Diklaim</div>
</div>
<div class="stat-item">
<div class="stat-number" id="totalUsers">0</div>
<div class="stat-label">Pengguna Terdaftar</div>
</div>
</div>
</div>
</div>
<div class="footer">
<p>&copy; 2025 Lost & Found System. Built with Go & Love ❤️</p>
</div>
</div>
<script>
// Animasi counter
function animateCounter(id, target) {
const element = document.getElementById(id);
let current = 0;
const increment = target / 50;
const timer = setInterval(() => {
current += increment;
if (current >= target) {
element.textContent = target;
clearInterval(timer);
} else {
element.textContent = Math.floor(current);
}
}, 30);
}
// Simulasi data (nanti akan diganti dengan API call ke backend)
window.addEventListener('load', () => {
animateCounter('totalItems', 127);
animateCounter('totalClaimed', 89);
animateCounter('totalUsers', 234);
});
// Uncomment ini ketika backend sudah siap
/*
async function fetchStats() {
try {
const response = await fetch('http://localhost:8080/stats');
const data = await response.json();
document.getElementById('totalItems').textContent = data.total_items;
document.getElementById('totalClaimed').textContent = data.total_claimed;
document.getElementById('totalUsers').textContent = data.total_users;
} catch (error) {
console.error('Error fetching stats:', error);
}
}
fetchStats();
*/
</script>
</body>
</html>

View File

@ -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 = `
<tr>
<td colspan="6" style="text-align: center; padding: 40px; color: #64748b;">
Belum ada data user
</td>
</tr>
`;
return;
}
tbody.innerHTML = users
.map(
(user) => `
<tr>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${user.nrp}</td>
<td>${getRoleBadge(user.role)}</td>
<td>${getStatusBadge(user.status || "active")}</td>
<td>
<button class="btn btn-warning btn-sm" onclick="editUser(${
user.id
})">Edit</button>
${
user.status === "active"
? `<button class="btn btn-danger btn-sm" onclick="blockUser(${user.id})">Block</button>`
: `<button class="btn btn-success btn-sm" onclick="unblockUser(${user.id})">Unblock</button>`
}
</td>
</tr>
`
)
.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 = '<span class="loading"></span> 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 = `
<div class="empty-state">
<div class="empty-state-icon">🏷</div>
<p>Belum ada kategori</p>
</div>
`;
return;
}
const icons = {
pakaian: "👕",
alat_makan: "🍽️",
aksesoris: "👓",
elektronik: "💻",
alat_tulis: "✏️",
lainnya: "📦",
};
grid.innerHTML = categories
.map(
(cat) => `
<div class="category-card">
<div class="category-icon">${icons[cat.slug] || "📦"}</div>
<h3>${cat.name}</h3>
${
cat.description
? `<p style="color: #64748b; font-size: 0.9rem; margin-top: 8px;">${cat.description}</p>`
: ""
}
<div style="display: flex; gap: 8px; margin-top: 15px; justify-content: center;">
<button class="btn btn-warning btn-sm" onclick="editCategory(${
cat.id
})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteCategory(${
cat.id
})">Hapus</button>
</div>
</div>
`
)
.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 = '<span class="loading"></span> 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 = `
<tr>
<td colspan="5" style="text-align: center; padding: 40px; color: #64748b;">
Belum ada audit log
</td>
</tr>
`;
return;
}
tbody.innerHTML = logs
.map(
(log) => `
<tr>
<td>${formatDateTime(log.created_at)}</td>
<td>${log.user_name}</td>
<td><span class="badge badge-primary">${log.action}</span></td>
<td>${log.details || "-"}</td>
<td>${log.ip_address || "-"}</td>
</tr>
`
)
.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);
}

View File

@ -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 `<span class="badge ${statusInfo.class}">${statusInfo.label}</span>`;
}
// 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 `<span class="badge ${roleInfo.class}">${roleInfo.label}</span>`;
}
// 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 = `
<div class="empty-state">
<div class="loading" style="width: 50px; height: 50px; border-width: 5px;"></div>
<p style="margin-top: 20px;">Loading...</p>
</div>
`;
}
}
// Empty state
function showEmptyState(elementId, icon, message) {
const element = document.getElementById(elementId);
if (!element) return;
element.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">${icon}</div>
<h3>Tidak ada data</h3>
<p>${message}</p>
</div>
`;
}
// 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();
});

View File

@ -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) => `
<div class="item-card">
<img src="${
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
}"
alt="${item.name}"
class="item-image"
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
<div class="item-body">
<h3 class="item-title">${item.name}</h3>
<div class="item-meta">
<span>📍 ${item.location}</span>
<span>📅 ${formatDate(item.date_found)}</span>
<span>${getStatusBadge(item.status)}</span>
</div>
<div class="item-actions">
<button class="btn btn-primary btn-sm" onclick="viewItemDetail(${
item.id
})">Detail</button>
${
item.status !== "case_closed" && item.status !== "expired"
? `<button class="btn btn-warning btn-sm" onclick="editItem(${item.id})">Edit</button>`
: ""
}
${
item.status === "verified"
? `<button class="btn btn-success btn-sm" onclick="closeCase(${item.id})">Close Case</button>`
: ""
}
</div>
</div>
</div>
`
)
.join("");
}
// View item detail - FIXED
async function viewItemDetail(itemId) {
try {
const item = await apiCall(`/api/items/${itemId}`);
const modalContent = document.getElementById("itemDetailContent");
modalContent.innerHTML = `
<img src="${
item.photo_url || "https://via.placeholder.com/600x400?text=No+Image"
}"
alt="${item.name}"
style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 10px; margin-bottom: 20px;"
onerror="this.src='https://via.placeholder.com/600x400?text=No+Image'">
<h3>${item.name}</h3>
<div style="display: grid; gap: 15px; margin-top: 20px;">
<div><strong>Kategori:</strong> ${item.category}</div>
<div><strong>Lokasi Ditemukan:</strong> ${item.location}</div>
<div><strong>Tanggal Ditemukan:</strong> ${formatDate(
item.date_found
)}</div>
<div><strong>Status:</strong> ${getStatusBadge(item.status)}</div>
<div><strong>Pelapor:</strong> ${item.reporter_name}</div>
<div><strong>Kontak:</strong> ${item.reporter_contact}</div>
<div style="background: #f8fafc; padding: 15px; border-radius: 10px;">
<strong>Deskripsi Keunikan (Rahasia):</strong><br>
${item.description}
</div>
</div>
`;
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 = '<span class="loading"></span> 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 = `
<div class="empty-state">
<div class="empty-state-icon">🤝</div>
<p>Gagal memuat data klaim</p>
</div>
`;
}
}
// Render claims
function renderClaims(claims) {
const list = document.getElementById("claimsList");
if (!claims || claims.length === 0) {
list.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🤝</div>
<p>Belum ada klaim yang masuk</p>
</div>
`;
return;
}
list.innerHTML = claims
.map(
(claim) => `
<div class="claim-card">
<div class="claim-header">
<h3>${claim.item_name}</h3>
${getStatusBadge(claim.status)}
</div>
<div class="claim-info">
<div><strong>Pengklaim:</strong> ${claim.user_name}</div>
<div><strong>Kontak:</strong> ${claim.contact}</div>
<div><strong>Tanggal Klaim:</strong> ${formatDate(
claim.created_at
)}</div>
${
claim.match_percentage
? `
<div><strong>Match:</strong>
<span style="color: ${
claim.match_percentage >= 70 ? "#10b981" : "#f59e0b"
}; font-weight: 600;">
${claim.match_percentage}%
</span>
</div>
`
: ""
}
</div>
<div style="background: #f8fafc; padding: 15px; border-radius: 10px; margin-bottom: 15px;">
<strong>Deskripsi dari Pengklaim:</strong><br>
${claim.description}
</div>
${
claim.status === "pending"
? `
<div class="claim-actions">
<button class="btn btn-primary btn-sm" onclick="verifyClaim(${claim.id})">Verifikasi</button>
<button class="btn btn-success btn-sm" onclick="approveClaim(${claim.id})">Approve</button>
<button class="btn btn-danger btn-sm" onclick="rejectClaim(${claim.id})">Reject</button>
</div>
`
: ""
}
${
claim.status === "rejected" && claim.notes
? `
<div style="color: #ef4444; margin-top: 10px;">
<strong>Alasan:</strong> ${claim.notes}
</div>
`
: ""
}
</div>
`
)
.join("");
}
// Verify claim - FIXED
async function verifyClaim(claimId) {
try {
const claim = await apiCall(`/api/claims/${claimId}`);
const modalContent = document.getElementById("verifyClaimContent");
modalContent.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 30px;">
<div>
<h4>Deskripsi Asli Barang</h4>
<div style="background: #f8fafc; padding: 15px; border-radius: 10px; margin-top: 10px;">
${claim.item_description}
</div>
</div>
<div>
<h4>Deskripsi dari Pengklaim</h4>
<div style="background: #fef3c7; padding: 15px; border-radius: 10px; margin-top: 10px;">
${claim.description}
</div>
</div>
</div>
${
claim.proof_url
? `
<div style="margin-top: 20px;">
<h4>Bukti Pendukung</h4>
<img src="${claim.proof_url}" style="width: 100%; max-height: 300px; object-fit: contain; border-radius: 10px; margin-top: 10px;">
</div>
`
: ""
}
${
claim.match_percentage
? `
<div style="margin-top: 20px; padding: 15px; background: ${
claim.match_percentage >= 70 ? "#d1fae5" : "#fef3c7"
}; border-radius: 10px; text-align: center;">
<strong>Similarity Match:</strong>
<span style="font-size: 2rem; font-weight: 700; color: ${
claim.match_percentage >= 70 ? "#10b981" : "#f59e0b"
};">
${claim.match_percentage}%
</span>
</div>
`
: ""
}
<div style="margin-top: 20px;">
<strong>Info Pengklaim:</strong>
<div style="margin-top: 10px;">
<div>Nama: ${claim.user_name}</div>
<div>Kontak: ${claim.contact}</div>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 30px;">
<button class="btn btn-success" onclick="approveClaim(${claimId})" style="flex: 1;"> Approve Klaim</button>
<button class="btn btn-danger" onclick="rejectClaim(${claimId})" style="flex: 1;"> Reject Klaim</button>
</div>
`;
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) => `
<div class="item-card">
<div class="item-body">
<h3 class="item-title">${item.name}</h3>
<div class="item-meta">
<span>🏷 ${item.category}</span>
<span>🎨 ${item.color}</span>
<span>📅 ${formatDate(item.date_lost)}</span>
${item.location ? `<span>📍 ${item.location}</span>` : ""}
</div>
<p style="color: #64748b; font-size: 0.9rem; margin-top: 10px;">${
item.description
}</p>
<div style="margin-top: 10px;">
<small><strong>Pelapor:</strong> ${item.user_name}</small>
</div>
<button class="btn btn-primary btn-sm" onclick="findSimilarItems(${
item.id
})" style="margin-top: 10px; width: 100%;">
🔍 Cari Barang yang Mirip
</button>
</div>
</div>
`
)
.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 = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<h3>Tidak ada barang yang cocok</h3>
<p>Belum ada barang ditemukan yang mirip dengan laporan ini</p>
</div>
`;
return;
}
modalContent.innerHTML = `
<p style="margin-bottom: 20px; color: #64748b;">Ditemukan ${
matches.length
} barang yang mungkin cocok:</p>
<div class="items-grid">
${matches
.map(
(match) => `
<div class="item-card" style="border: 2px solid ${
match.similarity >= 70 ? "#10b981" : "#f59e0b"
};">
<img src="${
match.photo_url ||
"https://via.placeholder.com/280x200?text=No+Image"
}"
alt="${match.name}"
class="item-image"
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
<div class="item-body">
<div style="text-align: center; margin-bottom: 10px;">
<span style="font-size: 1.5rem; font-weight: 700; color: ${
match.similarity >= 70 ? "#10b981" : "#f59e0b"
};">
${match.similarity}% Match
</span>
</div>
<h3 class="item-title">${match.name}</h3>
<div class="item-meta">
<span>📍 ${match.location}</span>
<span>📅 ${formatDate(match.date_found)}</span>
<span>${getStatusBadge(match.status)}</span>
</div>
<button class="btn btn-primary btn-sm" onclick="viewItemDetail(${
match.id
})" style="width: 100%; margin-top: 10px;">
Lihat Detail
</button>
</div>
</div>
`
)
.join("")}
</div>
`;
} catch (error) {
console.error("Error finding similar items:", error);
document.getElementById("matchItemsContent").innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"></div>
<p>Gagal mencari barang yang mirip</p>
</div>
`;
}
}
// 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) => `
<div class="item-card">
<img src="${
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
}"
alt="${item.name}"
class="item-image"
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
<div class="item-body">
<h3 class="item-title">${item.name}</h3>
<div class="item-meta">
<span>📍 ${item.location}</span>
<span>📅 ${formatDate(item.date_found)}</span>
<span>${getStatusBadge(item.status)}</span>
</div>
</div>
</div>
`
)
.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 = '<span class="loading"></span> 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 = `
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Edit Barang</h3>
<button class="close-btn" onclick="closeModal('editItemModal')">&times;</button>
</div>
<div class="modal-body">
<form id="editItemForm">
<input type="hidden" name="item_id">
<div class="form-group">
<label>Foto Barang (Opsional - Kosongkan jika tidak ingin mengubah)</label>
<input type="file" name="photo" accept="image/*">
</div>
<div class="form-group">
<label>Nama Barang *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Kategori *</label>
<select name="category" required>
<option value="">Pilih Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label>Lokasi Ditemukan *</label>
<input type="text" name="location" required>
</div>
<div class="form-group">
<label>Deskripsi Keunikan *</label>
<textarea name="description" required></textarea>
</div>
<div class="form-group">
<label>Nama Pelapor *</label>
<input type="text" name="reporter_name" required>
</div>
<div class="form-group">
<label>Kontak Pelapor *</label>
<input type="text" name="reporter_contact" required>
</div>
<div class="form-group">
<label>Tanggal Ditemukan *</label>
<input type="date" name="date_found" required>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Update</button>
</form>
</div>
</div>
`;
document.body.appendChild(editItemModal);
}

View File

@ -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) => `
<div class="item-card" onclick="viewItemDetail(${item.id})">
<img src="${
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
}"
alt="${item.name}"
class="item-image"
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
<div class="item-body">
<h3 class="item-title">${item.name}</h3>
<div class="item-meta">
<span>📍 ${item.location}</span>
<span>📅 ${formatDate(item.date_found)}</span>
<span>${getStatusBadge(item.status)}</span>
</div>
${
item.status === "unclaimed"
? `<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); openClaimModal(${item.id})">Klaim Barang</button>`
: ""
}
</div>
</div>
`
)
.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 = `
<img src="${
item.photo_url || "https://via.placeholder.com/600x400?text=No+Image"
}"
alt="${item.name}"
style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 10px; margin-bottom: 20px;"
onerror="this.src='https://via.placeholder.com/600x400?text=No+Image'">
<h3>${item.name}</h3>
<div style="display: grid; gap: 15px; margin-top: 20px;">
<div><strong>Kategori:</strong> ${item.category}</div>
<div><strong>Lokasi Ditemukan:</strong> ${item.location}</div>
<div><strong>Tanggal Ditemukan:</strong> ${formatDate(
item.date_found
)}</div>
<div><strong>Status:</strong> ${getStatusBadge(item.status)}</div>
</div>
${
item.status === "unclaimed"
? `<button class="btn btn-primary" onclick="openClaimModal(${item.id})" style="width: 100%; margin-top: 20px;">Klaim Barang Ini</button>`
: ""
}
`;
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 = `
<form id="claimForm" onsubmit="submitClaim(event, ${itemId})">
<div class="form-group">
<label>Deskripsi Barang (Jelaskan ciri-ciri barang Anda) *</label>
<textarea name="description" required placeholder="Jelaskan ciri khusus barang yang hanya Anda tahu..."></textarea>
</div>
<div class="form-group">
<label>Bukti Pendukung (Opsional)</label>
<input type="file" name="proof" accept="image/*">
<small style="color: #64748b;">Upload foto barang saat Anda masih memilikinya (opsional)</small>
</div>
<div class="form-group">
<label>No. Kontak *</label>
<input type="text" name="contact" required placeholder="08123456789">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit Klaim</button>
</form>
`;
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 = '<span class="loading"></span> 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) => `
<div class="item-card">
<div class="item-body">
<h3 class="item-title">${item.name}</h3>
<div class="item-meta">
<span>🏷 ${item.category}</span>
<span>🎨 ${item.color}</span>
<span>📅 ${formatDate(item.date_lost)}</span>
${item.location ? `<span>📍 ${item.location}</span>` : ""}
</div>
<p style="color: #64748b; font-size: 0.9rem; margin-top: 10px;">${
item.description
}</p>
</div>
</div>
`
)
.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) => `
<div class="item-card">
<img src="${
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
}"
alt="${item.name}"
class="item-image"
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
<div class="item-body">
<h3 class="item-title">${item.name}</h3>
<div class="item-meta">
<span>📍 ${item.location}</span>
<span>📅 ${formatDate(item.date_found)}</span>
<span>${getStatusBadge(item.status)}</span>
</div>
</div>
</div>
`
)
.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) => `
<div class="item-card">
<div class="item-body">
<h3 class="item-title">${claim.item_name}</h3>
<div class="item-meta">
<span>📅 ${formatDate(claim.created_at)}</span>
<span>${getStatusBadge(claim.status)}</span>
</div>
<p style="color: #64748b; font-size: 0.9rem; margin-top: 10px;">
${claim.description}
</p>
${
claim.status === "rejected" && claim.notes
? `
<p style="color: #ef4444; font-size: 0.9rem; margin-top: 10px;">
<strong>Alasan ditolak:</strong> ${claim.notes}
</p>
`
: ""
}
</div>
</div>
`
)
.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 = '<span class="loading"></span> 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 = '<span class="loading"></span> 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 = `
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Klaim Barang</h3>
<button class="close-btn" onclick="closeModal('claimModal')">&times;</button>
</div>
<div class="modal-body" id="claimModalContent"></div>
</div>
`;
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 = `
<div class="modal-content modal-large">
<div class="modal-header">
<h3 class="modal-title">Detail Barang</h3>
<button class="close-btn" onclick="closeModal('itemDetailModal')">&times;</button>
</div>
<div class="modal-body" id="itemDetailContent"></div>
</div>
`;
document.body.appendChild(itemDetailModal);
}

View File

@ -0,0 +1,396 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Lost & Found</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #2563eb;
--primary-dark: #1e40af;
--danger: #ef4444;
--success: #10b981;
--light: #f8fafc;
--dark: #1e293b;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
max-width: 450px;
width: 100%;
}
.login-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
padding: 40px 30px;
text-align: center;
}
.login-header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.login-header p {
opacity: 0.9;
}
.login-body {
padding: 40px 30px;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--dark);
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
}
.form-group input.error {
border-color: var(--danger);
}
.error-message {
color: var(--danger);
font-size: 0.85rem;
margin-top: 5px;
display: none;
}
.error-message.show {
display: block;
}
.alert {
padding: 12px 15px;
border-radius: 10px;
margin-bottom: 20px;
display: none;
}
.alert.show {
display: block;
}
.alert-error {
background: #fee;
color: var(--danger);
border: 1px solid var(--danger);
}
.alert-success {
background: #efe;
color: var(--success);
border: 1px solid var(--success);
}
.btn-login {
width: 100%;
padding: 15px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-login:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.3);
}
.btn-login:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
}
.divider {
text-align: center;
margin: 25px 0;
color: #64748b;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 45%;
height: 1px;
background: #e2e8f0;
}
.divider::before {
left: 0;
}
.divider::after {
right: 0;
}
.register-link {
text-align: center;
margin-top: 20px;
}
.register-link a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.register-link a:hover {
text-decoration: underline;
}
.back-home {
text-align: center;
margin-top: 15px;
}
.back-home a {
color: #64748b;
text-decoration: none;
font-size: 0.9rem;
}
.back-home a:hover {
color: var(--primary);
}
.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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.login-container {
margin: 10px;
}
.login-header {
padding: 30px 20px;
}
.login-body {
padding: 30px 20px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>🔐 Login</h1>
<p>Masuk ke Lost & Found System</p>
</div>
<div class="login-body">
<div id="alertBox" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="mahasiswa@example.com" required>
<div class="error-message" id="emailError">Email tidak valid</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="********" required>
<div class="error-message" id="passwordError">Password minimal 6 karakter</div>
</div>
<button type="submit" class="btn-login" id="loginBtn">
Login
</button>
</form>
<div class="divider">atau</div>
<div class="register-link">
<p>Belum punya akun? <a href="/register">Register disini</a></p>
</div>
<div class="back-home">
<a href="/">← Kembali ke Beranda</a>
</div>
</div>
</div>
<script>
const API_URL = 'http://localhost:8080/api';
const loginForm = document.getElementById('loginForm');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('loginBtn');
const alertBox = document.getElementById('alertBox');
// Validasi Email
function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
// Show Alert
function showAlert(message, type = 'error') {
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
setTimeout(() => {
alertBox.classList.remove('show');
}, 5000);
}
// Clear errors
function clearErrors() {
document.querySelectorAll('.error-message').forEach(el => {
el.classList.remove('show');
});
document.querySelectorAll('input').forEach(el => {
el.classList.remove('error');
});
}
// Form submit
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
clearErrors();
const email = emailInput.value.trim();
const password = passwordInput.value;
// Validasi frontend
let hasError = false;
if (!validateEmail(email)) {
emailInput.classList.add('error');
document.getElementById('emailError').classList.add('show');
hasError = true;
}
if (password.length < 6) {
passwordInput.classList.add('error');
document.getElementById('passwordError').classList.add('show');
hasError = true;
}
if (hasError) return;
// Disable button dan tampilkan loading
loginBtn.disabled = true;
loginBtn.innerHTML = '<span class="loading"></span> Logging in...';
try {
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
// Simpan token dan user info
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
showAlert('Login berhasil! Mengalihkan...', 'success');
// Redirect berdasarkan role - REFACTORED: URL lebih simple
setTimeout(() => {
const role = data.user.role;
if (role === 'admin') {
window.location.href = '/admin'; // ✅ REFACTORED
} else if (role === 'manager') {
window.location.href = '/manager'; // ✅ REFACTORED
} else {
window.location.href = '/user'; // ✅ REFACTORED
}
}, 1000);
} else {
showAlert(data.error || 'Email atau password salah!');
}
} catch (error) {
console.error('Login error:', error);
showAlert('Terjadi kesalahan. Pastikan server berjalan di port 8080');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Login';
}
});
// Check jika sudah login - FIXED: gunakan underscore
window.addEventListener('load', () => {
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (token && user.role) {
// Redirect ke dashboard sesuai role
if (user.role === 'admin') {
window.location.href = '/admin'; // ✅ FIXED
} else if (user.role === 'manager') {
window.location.href = '/manager'; // ✅ FIXED
} else {
window.location.href = '/user'; // ✅ FIXED
}
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Manager - Lost & Found</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">🔍 Lost & Found</div>
<div class="navbar-menu">
<div class="user-info">
<div class="user-avatar" id="userAvatar">M</div>
<span id="userName">Manager</span>
<span class="user-role">Manager</span>
</div>
<button class="btn-logout" onclick="logout()">Logout</button>
</div>
</nav>
<div class="container">
<div class="page-header">
<h1>Dashboard Manager</h1>
<p>Kelola barang temuan dan verifikasi klaim</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Barang</h3>
<div class="stat-number" id="statTotalItems">0</div>
</div>
<div class="stat-card">
<h3>Pending Claim</h3>
<div class="stat-number stat-warning" id="statPendingClaims">0</div>
</div>
<div class="stat-card">
<h3>Verified</h3>
<div class="stat-number stat-success" id="statVerified">0</div>
</div>
<div class="stat-card">
<h3>Expired</h3>
<div class="stat-number stat-danger" id="statExpired">0</div>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('items')">📦 Kelola Barang</button>
<button class="tab-btn" onclick="switchTab('claims')">🤝 Verifikasi Klaim</button>
<button class="tab-btn" onclick="switchTab('lost')">😢 Barang Hilang</button>
<button class="tab-btn" onclick="switchTab('archive')">📂 Arsip</button>
</div>
<!-- Tab: Kelola Barang -->
<div id="itemsTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">Daftar Barang Ditemukan</h2>
<button class="btn btn-primary" onclick="openReportFoundModal()">+ Tambah Barang</button>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari barang..." id="searchItems">
<select class="filter-select" id="categoryFilterItems">
<option value="">Semua Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
<select class="filter-select" id="statusFilterItems">
<option value="">Semua Status</option>
<option value="unclaimed">Unclaimed</option>
<option value="pending_claim">Pending Claim</option>
<option value="verified">Verified</option>
<option value="case_closed">Case Closed</option>
</select>
<select class="filter-select" id="sortItems">
<option value="date_desc">Terbaru</option>
<option value="date_asc">Terlama</option>
<option value="name_asc">Nama A-Z</option>
<option value="name_desc">Nama Z-A</option>
</select>
</div>
<div class="items-grid" id="itemsGrid"></div>
</div>
</div>
<!-- Tab: Verifikasi Klaim -->
<div id="claimsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Daftar Klaim</h2>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari klaim..." id="searchClaims">
<select class="filter-select" id="statusFilterClaims">
<option value="">Semua Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div class="claims-list" id="claimsList"></div>
</div>
</div>
<!-- Tab: Barang Hilang -->
<div id="lostTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Barang Hilang</h2>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari barang hilang..." id="searchLost">
<select class="filter-select" id="categoryFilterLost">
<option value="">Semua Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="items-grid" id="lostItemsGrid"></div>
</div>
</div>
<!-- Tab: Arsip -->
<div id="archiveTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Arsip Barang</h2>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari arsip..." id="searchArchive">
<select class="filter-select" id="categoryFilterArchive">
<option value="">Semua Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="items-grid" id="archiveGrid"></div>
</div>
</div>
</div>
<!-- Modal Report Found -->
<div id="reportFoundModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Tambah Barang Ditemukan</h3>
<button class="close-btn" onclick="closeModal('reportFoundModal')">&times;</button>
</div>
<div class="modal-body">
<form id="reportFoundForm">
<div class="form-group">
<label>Foto Barang *</label>
<input type="file" name="photo" accept="image/*" required>
</div>
<div class="form-group">
<label>Nama Barang *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Kategori *</label>
<select name="category" required>
<option value="">Pilih Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label>Lokasi Ditemukan *</label>
<input type="text" name="location" required>
</div>
<div class="form-group">
<label>Deskripsi Keunikan (Rahasia) *</label>
<textarea name="description" required placeholder="Ciri khusus untuk verifikasi..."></textarea>
</div>
<div class="form-group">
<label>Nama Pelapor *</label>
<input type="text" name="reporter_name" required>
</div>
<div class="form-group">
<label>Kontak Pelapor *</label>
<input type="text" name="reporter_contact" required>
</div>
<div class="form-group">
<label>Tanggal Ditemukan *</label>
<input type="date" name="date_found" required>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit</button>
</form>
</div>
</div>
</div>
<!-- Modal Detail Item -->
<div id="itemDetailModal" class="modal">
<div class="modal-content modal-large">
<div class="modal-header">
<h3 class="modal-title">Detail Barang</h3>
<button class="close-btn" onclick="closeModal('itemDetailModal')">&times;</button>
</div>
<div class="modal-body" id="itemDetailContent"></div>
</div>
</div>
<!-- Modal Verify Claim -->
<div id="verifyClaimModal" class="modal">
<div class="modal-content modal-large">
<div class="modal-header">
<h3 class="modal-title">Verifikasi Klaim</h3>
<button class="close-btn" onclick="closeModal('verifyClaimModal')">&times;</button>
</div>
<div class="modal-body" id="verifyClaimContent"></div>
</div>
</div>
<!-- Modal Match Items -->
<div id="matchItemsModal" class="modal">
<div class="modal-content modal-large">
<div class="modal-header">
<h3 class="modal-title">Cari Barang yang Mirip</h3>
<button class="close-btn" onclick="closeModal('matchItemsModal')">&times;</button>
</div>
<div class="modal-body" id="matchItemsContent"></div>
</div>
</div>
<script src="js/main.js"></script>
<script src="js/dashboard_manager.js"></script>
</body>
</html>

View File

@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - Lost & Found</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #2563eb;
--primary-dark: #1e40af;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--light: #f8fafc;
--dark: #1e293b;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
max-width: 500px;
width: 100%;
margin: 20px auto;
}
.register-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
padding: 40px 30px;
text-align: center;
}
.register-header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.register-header p {
opacity: 0.9;
}
.register-body {
padding: 40px 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--dark);
font-weight: 600;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 15px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
}
.form-group input.error,
.form-group select.error {
border-color: var(--danger);
}
.error-message {
color: var(--danger);
font-size: 0.85rem;
margin-top: 5px;
display: none;
}
.error-message.show {
display: block;
}
.alert {
padding: 12px 15px;
border-radius: 10px;
margin-bottom: 20px;
display: none;
}
.alert.show {
display: block;
}
.alert-error {
background: #fee;
color: var(--danger);
border: 1px solid var(--danger);
}
.alert-success {
background: #efe;
color: var(--success);
border: 1px solid var(--success);
}
.password-strength {
margin-top: 5px;
font-size: 0.85rem;
}
.strength-weak {
color: var(--danger);
}
.strength-medium {
color: var(--warning);
}
.strength-strong {
color: var(--success);
}
.btn-register {
width: 100%;
padding: 15px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
margin-top: 10px;
}
.btn-register:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.3);
}
.btn-register:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
}
.divider {
text-align: center;
margin: 25px 0;
color: #64748b;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 45%;
height: 1px;
background: #e2e8f0;
}
.divider::before {
left: 0;
}
.divider::after {
right: 0;
}
.login-link {
text-align: center;
margin-top: 20px;
}
.login-link a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.login-link a:hover {
text-decoration: underline;
}
.back-home {
text-align: center;
margin-top: 15px;
}
.back-home a {
color: #64748b;
text-decoration: none;
font-size: 0.9rem;
}
.back-home a:hover {
color: var(--primary);
}
.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;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
@media (max-width: 768px) {
.register-container {
margin: 10px;
}
.register-header {
padding: 30px 20px;
}
.register-body {
padding: 30px 20px;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="register-container">
<div class="register-header">
<h1>🔐 Register</h1>
<p>Buat akun Lost & Found System</p>
</div>
<div class="register-body">
<div id="alertBox" class="alert"></div>
<form id="registerForm">
<div class="form-group">
<label for="name">Nama Lengkap</label>
<input type="text" id="name" name="name" placeholder="John Doe" required>
<div class="error-message" id="nameError">Nama minimal 3 karakter</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="mahasiswa@example.com" required>
<div class="error-message" id="emailError">Email tidak valid</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="nrp">NRP</label>
<input type="text" id="nrp" name="nrp" placeholder="5026211234" required>
<div class="error-message" id="nrpError">NRP minimal 10 digit</div>
</div>
<div class="form-group">
<label for="phone">No. Telepon</label>
<input type="tel" id="phone" name="phone" placeholder="08123456789" required>
<div class="error-message" id="phoneError">Nomor tidak valid</div>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="********" required>
<div class="password-strength" id="passwordStrength"></div>
<div class="error-message" id="passwordError">Password minimal 8 karakter</div>
</div>
<div class="form-group">
<label for="confirmPassword">Konfirmasi Password</label>
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="********" required>
<div class="error-message" id="confirmPasswordError">Password tidak cocok</div>
</div>
<button type="submit" class="btn-register" id="registerBtn">
Register
</button>
</form>
<div class="divider">atau</div>
<div class="login-link">
<p>Sudah punya akun? <a href="/login">Login disini</a></p>
</div>
<div class="back-home">
<a href="/">← Kembali ke Beranda</a>
</div>
</div>
</div>
<script>
const API_URL = 'http://localhost:8080/api';
const registerForm = document.getElementById('registerForm');
const nameInput = document.getElementById('name');
const emailInput = document.getElementById('email');
const nrpInput = document.getElementById('nrp');
const phoneInput = document.getElementById('phone');
const passwordInput = document.getElementById('password');
const confirmPasswordInput = document.getElementById('confirmPassword');
const registerBtn = document.getElementById('registerBtn');
const alertBox = document.getElementById('alertBox');
// Validasi Email
function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
// Validasi Phone
function validatePhone(phone) {
const re = /^(08|62)[0-9]{9,12}$/;
return re.test(phone);
}
// Check password strength
function checkPasswordStrength(password) {
const strengthEl = document.getElementById('passwordStrength');
let strength = 0;
if (password.length >= 8) strength++;
if (password.match(/[a-z]+/)) strength++;
if (password.match(/[A-Z]+/)) strength++;
if (password.match(/[0-9]+/)) strength++;
if (password.match(/[$@#&!]+/)) strength++;
if (password.length < 8) {
strengthEl.textContent = '';
strengthEl.className = 'password-strength';
} else if (strength <= 2) {
strengthEl.textContent = '⚠️ Password lemah';
strengthEl.className = 'password-strength strength-weak';
} else if (strength <= 4) {
strengthEl.textContent = '✓ Password sedang';
strengthEl.className = 'password-strength strength-medium';
} else {
strengthEl.textContent = '✓ Password kuat';
strengthEl.className = 'password-strength strength-strong';
}
}
// Show Alert
function showAlert(message, type = 'error') {
alertBox.textContent = message;
alertBox.className = `alert alert-${type} show`;
setTimeout(() => {
alertBox.classList.remove('show');
}, 5000);
}
// Clear errors
function clearErrors() {
document.querySelectorAll('.error-message').forEach(el => {
el.classList.remove('show');
});
document.querySelectorAll('input').forEach(el => {
el.classList.remove('error');
});
}
// Password strength checker
passwordInput.addEventListener('input', (e) => {
checkPasswordStrength(e.target.value);
});
// Form submit
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
clearErrors();
const name = nameInput.value.trim();
const email = emailInput.value.trim();
const nrp = nrpInput.value.trim();
const phone = phoneInput.value.trim();
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
// Validasi frontend
let hasError = false;
if (name.length < 3) {
nameInput.classList.add('error');
document.getElementById('nameError').classList.add('show');
hasError = true;
}
if (!validateEmail(email)) {
emailInput.classList.add('error');
document.getElementById('emailError').classList.add('show');
hasError = true;
}
if (nrp.length < 10) {
nrpInput.classList.add('error');
document.getElementById('nrpError').classList.add('show');
hasError = true;
}
if (!validatePhone(phone)) {
phoneInput.classList.add('error');
document.getElementById('phoneError').classList.add('show');
hasError = true;
}
if (password.length < 8) {
passwordInput.classList.add('error');
document.getElementById('passwordError').classList.add('show');
hasError = true;
}
if (password !== confirmPassword) {
confirmPasswordInput.classList.add('error');
document.getElementById('confirmPasswordError').classList.add('show');
hasError = true;
}
if (hasError) return;
// Disable button dan tampilkan loading
registerBtn.disabled = true;
registerBtn.innerHTML = '<span class="loading"></span> Registering...';
try {
const response = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
email,
nrp,
phone,
password
})
});
const data = await response.json();
if (response.ok) {
showAlert('Registrasi berhasil! Mengalihkan ke login...', 'success');
setTimeout(() => {
window.location.href = '/login';
}, 2000);
} else {
showAlert(data.error || 'Registrasi gagal!');
}
} catch (error) {
console.error('Register error:', error);
showAlert('Terjadi kesalahan. Pastikan server berjalan di port 8080');
} finally {
registerBtn.disabled = false;
registerBtn.textContent = 'Register';
}
});
// Check jika sudah login - REFACTORED: URL lebih simple
window.addEventListener('load', () => {
const token = localStorage.getItem('token');
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (token && user.role) {
// Redirect ke dashboard sesuai role
if (user.role === 'admin') {
window.location.href = '/admin'; // ✅ REFACTORED
} else if (user.role === 'manager') {
window.location.href = '/manager'; // ✅ REFACTORED
} else {
window.location.href = '/user'; // ✅ REFACTORED
}
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,638 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard User - Lost & Found</title>
<style>
* {
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 {
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;
}
.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 {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
color: var(--dark);
font-size: 2rem;
margin-bottom: 10px;
}
.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;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.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);
}
.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;
}
.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);
}
.search-box {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
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 {
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;
}
.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 {
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-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-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;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--secondary);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 20px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
transform: translateY(50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 15px;
}
.navbar-menu {
flex-direction: column;
width: 100%;
}
.items-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-brand">🔍 Lost & Found</div>
<div class="navbar-menu">
<div class="user-info">
<div class="user-avatar" id="userAvatar">U</div>
<span id="userName">User</span>
</div>
<button class="btn-logout" onclick="logout()">Logout</button>
</div>
</nav>
<div class="container">
<div class="page-header">
<h1>Dashboard User</h1>
<p>Selamat datang di Lost & Found System</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Barang Hilang Saya</h3>
<div class="stat-number" id="statLost">0</div>
</div>
<div class="stat-card">
<h3>Barang yang Saya Temukan</h3>
<div class="stat-number" id="statFound">0</div>
</div>
<div class="stat-card">
<h3>Klaim Saya</h3>
<div class="stat-number" id="statClaims">0</div>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('browse')">📦 Cari Barang</button>
<button class="tab-btn" onclick="switchTab('lost')">😢 Barang Hilang Saya</button>
<button class="tab-btn" onclick="switchTab('found')">🎉 Barang yang Saya Temukan</button>
<button class="tab-btn" onclick="switchTab('claims')">🤝 Riwayat Klaim</button>
</div>
<!-- Tab: Browse Items -->
<div id="browseTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">Barang Ditemukan</h2>
</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Cari barang..." id="searchInput">
<select class="filter-select" id="categoryFilter">
<option value="">Semua Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
<select class="filter-select" id="sortBy">
<option value="date_desc">Terbaru</option>
<option value="date_asc">Terlama</option>
<option value="name_asc">Nama A-Z</option>
<option value="name_desc">Nama Z-A</option>
</select>
</div>
<div class="items-grid" id="itemsGrid"></div>
</div>
</div>
<!-- Tab: My Lost Items -->
<div id="lostTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Barang Hilang Saya</h2>
<button class="btn btn-primary" onclick="openReportLostModal()">+ Lapor Kehilangan</button>
</div>
<div class="items-grid" id="lostItemsGrid"></div>
</div>
</div>
<!-- Tab: My Found Items -->
<div id="foundTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Barang yang Saya Temukan</h2>
<button class="btn btn-primary" onclick="openReportFoundModal()">+ Lapor Penemuan</button>
</div>
<div class="items-grid" id="foundItemsGrid"></div>
</div>
</div>
<!-- Tab: My Claims -->
<div id="claimsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">Riwayat Klaim</h2>
</div>
<div class="items-grid" id="claimsGrid"></div>
</div>
</div>
</div>
<!-- Modal Report Lost -->
<div id="reportLostModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Lapor Barang Hilang</h3>
<button class="close-btn" onclick="closeModal('reportLostModal')">&times;</button>
</div>
<div class="modal-body">
<form id="reportLostForm">
<div class="form-group">
<label>Nama Barang</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Kategori</label>
<select name="category" required>
<option value="">Pilih Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label>Warna</label>
<input type="text" name="color" required>
</div>
<div class="form-group">
<label>Lokasi Hilang (Opsional)</label>
<input type="text" name="location">
</div>
<div class="form-group">
<label>Deskripsi</label>
<textarea name="description" required></textarea>
</div>
<div class="form-group">
<label>Tanggal Hilang</label>
<input type="date" name="date_lost" required>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit Laporan</button>
</form>
</div>
</div>
</div>
<!-- Modal Report Found -->
<div id="reportFoundModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Lapor Penemuan Barang</h3>
<button class="close-btn" onclick="closeModal('reportFoundModal')">&times;</button>
</div>
<div class="modal-body">
<form id="reportFoundForm">
<div class="form-group">
<label>Foto Barang</label>
<input type="file" name="photo" accept="image/*" required>
</div>
<div class="form-group">
<label>Nama Barang</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Kategori</label>
<select name="category" required>
<option value="">Pilih Kategori</option>
<option value="pakaian">Pakaian</option>
<option value="alat_makan">Alat Makan</option>
<option value="aksesoris">Aksesoris</option>
<option value="elektronik">Elektronik</option>
<option value="alat_tulis">Alat Tulis</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div class="form-group">
<label>Lokasi Ditemukan</label>
<input type="text" name="location" required>
</div>
<div class="form-group">
<label>Deskripsi Keunikan (Rahasia untuk Verifikasi)</label>
<textarea name="description" required placeholder="Ciri khusus yang hanya pemilik tahu..."></textarea>
</div>
<div class="form-group">
<label>Tanggal Ditemukan</label>
<input type="date" name="date_found" required>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit Penemuan</button>
</form>
</div>
</div>
</div>
<script src="js/main.js"></script>
<script src="js/user.js"></script>
</body>
</html>