// cmd/server/main.go // cmd/server/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 log.Println("šŸ“Š Initializing database...") if err := config.InitDB(); err != nil { log.Fatalf("āŒ Failed to initialize database: %v", err) } defer config.CloseDB() // Run migrations log.Println("šŸ”„ Running 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 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") }) 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 with error handling log.Println("šŸ”„ Starting 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() log.Println("āœ… Background workers started") // 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 starting on http://localhost:%s\n", port) log.Printf("šŸ“ API available at http://localhost:%s/api\n", port) log.Printf("🌐 Frontend available at http://localhost:%s\n", port) log.Println("āœ… Ready to receive requests") 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 gracefully...") // Stop workers log.Println("šŸ”„ Stopping background workers...") expireWorker.Stop() auditWorker.Stop() matchingWorker.Stop() notificationWorker.Stop() log.Println("āœ… Background workers stopped") log.Println("āœ… Server exited gracefully") } -- database/enhancement.sql -- ============================================ -- ENHANCEMENT FOR LOST & FOUND DATABASE -- Stored Procedures, Functions, Triggers, Views -- ============================================ DELIMITER $$ -- ============================================ -- FUNCTIONS -- ============================================ -- Function: Hitung similarity score antara 2 string CREATE FUNCTION fn_calculate_similarity(str1 TEXT, str2 TEXT) RETURNS DECIMAL(5,2) DETERMINISTIC BEGIN DECLARE similarity DECIMAL(5,2); DECLARE len1 INT; DECLARE len2 INT; DECLARE common_words INT DEFAULT 0; SET len1 = LENGTH(str1); SET len2 = LENGTH(str2); -- Simple similarity calculation (bisa diganti dengan algorithm lebih kompleks) IF str1 = str2 THEN SET similarity = 100.00; ELSEIF LOCATE(LOWER(str1), LOWER(str2)) > 0 THEN SET similarity = 75.00; ELSE SET similarity = 50.00; END IF; RETURN similarity; END$$ -- Function: Check apakah item sudah expired CREATE FUNCTION fn_is_item_expired(item_id INT) RETURNS BOOLEAN READS SQL DATA BEGIN DECLARE is_expired BOOLEAN DEFAULT FALSE; DECLARE expire_date TIMESTAMP; SELECT expires_at INTO expire_date FROM items WHERE id = item_id AND deleted_at IS NULL; IF expire_date IS NOT NULL AND expire_date < NOW() THEN SET is_expired = TRUE; END IF; RETURN is_expired; END$$ -- Function: Hitung total items per kategori CREATE FUNCTION fn_count_items_by_category(cat_id INT) RETURNS INT READS SQL DATA BEGIN DECLARE total INT DEFAULT 0; SELECT COUNT(*) INTO total FROM items WHERE category_id = cat_id AND deleted_at IS NULL AND status = 'unclaimed'; RETURN total; END$$ -- ============================================ -- STORED PROCEDURES -- ============================================ -- Procedure: Create claim dengan transaction dan locking CREATE PROCEDURE sp_create_claim( IN p_item_id INT, IN p_user_id INT, IN p_description TEXT, IN p_proof_url VARCHAR(255), IN p_contact VARCHAR(50), OUT p_claim_id INT, OUT p_status VARCHAR(50), OUT p_message TEXT ) BEGIN DECLARE v_item_status VARCHAR(50); DECLARE v_item_desc TEXT; DECLARE v_similarity DECIMAL(5,2); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_status = 'error'; SET p_message = 'Transaction failed. Claim not created.'; SET p_claim_id = NULL; END; START TRANSACTION; -- Lock item untuk prevent concurrent claims SELECT status, description INTO v_item_status, v_item_desc FROM items WHERE id = p_item_id AND deleted_at IS NULL FOR UPDATE; -- Validasi: item harus unclaimed IF v_item_status != 'unclaimed' THEN SET p_status = 'error'; SET p_message = 'Item sudah diklaim atau tidak tersedia'; ROLLBACK; ELSE -- Insert claim INSERT INTO claims ( item_id, user_id, description, proof_url, contact, status ) VALUES ( p_item_id, p_user_id, p_description, p_proof_url, p_contact, 'pending' ); SET p_claim_id = LAST_INSERT_ID(); -- Calculate similarity score SET v_similarity = fn_calculate_similarity(p_description, v_item_desc); -- Insert verification record INSERT INTO claim_verifications ( claim_id, similarity_score, is_auto_matched ) VALUES ( p_claim_id, v_similarity, FALSE ); -- Create notification for managers INSERT INTO notifications ( user_id, type, title, message, entity_type, entity_id ) SELECT u.id, 'new_claim', 'Klaim Baru', CONCAT('Ada klaim baru untuk barang: ', i.name), 'claim', p_claim_id FROM users u CROSS JOIN items i WHERE u.role_id = 2 AND u.deleted_at IS NULL AND i.id = p_item_id; SET p_status = 'success'; SET p_message = 'Claim created successfully'; COMMIT; END IF; END$$ -- Procedure: Verify claim (approve/reject) CREATE PROCEDURE sp_verify_claim( IN p_claim_id INT, IN p_verifier_id INT, IN p_action VARCHAR(20), -- 'approve' or 'reject' IN p_notes TEXT, OUT p_status VARCHAR(50), OUT p_message TEXT ) BEGIN DECLARE v_item_id INT; DECLARE v_user_id INT; DECLARE v_claim_status VARCHAR(50); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_status = 'error'; SET p_message = 'Verification failed'; END; START TRANSACTION; -- Get claim details with lock SELECT item_id, user_id, status INTO v_item_id, v_user_id, v_claim_status FROM claims WHERE id = p_claim_id AND deleted_at IS NULL FOR UPDATE; IF v_claim_status != 'pending' THEN SET p_status = 'error'; SET p_message = 'Claim already processed'; ROLLBACK; ELSE -- Update claim status UPDATE claims SET status = p_action, notes = p_notes, verified_at = NOW(), verified_by = p_verifier_id WHERE id = p_claim_id; -- If approved, update item status IF p_action = 'approved' THEN UPDATE items SET status = 'claimed' WHERE id = v_item_id; -- Archive the item INSERT INTO archives ( item_id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, archived_reason, claimed_by ) SELECT id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, 'case_closed', v_user_id FROM items WHERE id = v_item_id; END IF; -- Notify user INSERT INTO notifications ( user_id, type, title, message, entity_type, entity_id ) VALUES ( v_user_id, CONCAT('claim_', p_action), IF(p_action = 'approved', 'Klaim Disetujui!', 'Klaim Ditolak'), CONCAT('Klaim Anda telah ', IF(p_action = 'approved', 'disetujui', 'ditolak')), 'claim', p_claim_id ); SET p_status = 'success'; SET p_message = CONCAT('Claim ', p_action, ' successfully'); COMMIT; END IF; END$$ -- Procedure: Archive expired items CREATE PROCEDURE sp_archive_expired_items( OUT p_archived_count INT ) BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_item_id INT; DECLARE cur CURSOR FOR SELECT id FROM items WHERE expires_at < NOW() AND status = 'unclaimed' AND deleted_at IS NULL; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; SET p_archived_count = 0; OPEN cur; read_loop: LOOP FETCH cur INTO v_item_id; IF done THEN LEAVE read_loop; END IF; -- Archive the item INSERT INTO archives ( item_id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, archived_reason ) SELECT id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, 'expired' FROM items WHERE id = v_item_id; -- Update item status UPDATE items SET status = 'expired' WHERE id = v_item_id; SET p_archived_count = p_archived_count + 1; END LOOP; CLOSE cur; END$$ -- Procedure: Auto-match lost items with found items CREATE PROCEDURE sp_auto_match_items() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_lost_id INT; DECLARE v_item_id INT; DECLARE v_similarity DECIMAL(5,2); DECLARE cur CURSOR FOR SELECT li.id, i.id, fn_calculate_similarity(li.description, i.description) FROM lost_items li CROSS JOIN items i WHERE li.status = 'active' AND i.status = 'unclaimed' AND li.category_id = i.category_id AND li.deleted_at IS NULL AND i.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM match_results mr WHERE mr.lost_item_id = li.id AND mr.item_id = i.id AND mr.deleted_at IS NULL ); DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur; match_loop: LOOP FETCH cur INTO v_lost_id, v_item_id, v_similarity; IF done THEN LEAVE match_loop; END IF; -- Only create match if similarity >= 70% IF v_similarity >= 70.00 THEN INSERT INTO match_results ( lost_item_id, item_id, similarity_score, matched_fields, is_notified ) VALUES ( v_lost_id, v_item_id, v_similarity, '{"category": 100, "description": ' || v_similarity || '}', FALSE ); -- Create notification INSERT INTO notifications ( user_id, type, title, message, entity_type, entity_id ) SELECT li.user_id, 'match_found', 'Barang yang Mirip Ditemukan!', CONCAT('Kami menemukan barang yang mirip: ', i.name), 'match', LAST_INSERT_ID() FROM lost_items li JOIN items i ON i.id = v_item_id WHERE li.id = v_lost_id; END IF; END LOOP; CLOSE cur; END$$ -- ============================================ -- TRIGGERS -- ============================================ -- Trigger: Auto-set expires_at saat item dibuat (3 bulan dari date_found) CREATE TRIGGER trg_items_before_insert BEFORE INSERT ON items FOR EACH ROW BEGIN IF NEW.expires_at IS NULL THEN SET NEW.expires_at = DATE_ADD(NEW.date_found, INTERVAL 3 MONTH); END IF; END$$ -- Trigger: Auto-log ke audit_logs saat user login CREATE TRIGGER trg_audit_log_after_user_update AFTER UPDATE ON users FOR EACH ROW BEGIN IF OLD.updated_at != NEW.updated_at THEN INSERT INTO audit_logs ( user_id, action, entity_type, entity_id, details ) VALUES ( NEW.id, 'update', 'user', NEW.id, CONCAT('User updated: ', NEW.name) ); END IF; END$$ -- Trigger: Auto-create revision log saat item di-update CREATE TRIGGER trg_items_after_update AFTER UPDATE ON items FOR EACH ROW BEGIN IF OLD.name != NEW.name THEN INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value) VALUES (NEW.id, NEW.reporter_id, 'name', OLD.name, NEW.name); END IF; IF OLD.description != NEW.description THEN INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value) VALUES (NEW.id, NEW.reporter_id, 'description', OLD.description, NEW.description); END IF; IF OLD.status != NEW.status THEN INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value) VALUES (NEW.id, NEW.reporter_id, 'status', OLD.status, NEW.status); END IF; END$$ -- Trigger: Prevent delete if item has active claims CREATE TRIGGER trg_items_before_delete BEFORE DELETE ON items FOR EACH ROW BEGIN DECLARE v_active_claims INT; SELECT COUNT(*) INTO v_active_claims FROM claims WHERE item_id = OLD.id AND status = 'pending' AND deleted_at IS NULL; IF v_active_claims > 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Cannot delete item with active claims'; END IF; END$$ -- Trigger: Auto-archive item when claimed CREATE TRIGGER trg_items_after_status_claimed AFTER UPDATE ON items FOR EACH ROW BEGIN IF OLD.status != 'claimed' AND NEW.status = 'claimed' THEN INSERT INTO archives ( item_id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, archived_reason ) VALUES ( NEW.id, NEW.name, NEW.category_id, NEW.photo_url, NEW.location, NEW.description, NEW.date_found, NEW.status, NEW.reporter_name, NEW.reporter_contact, 'case_closed' ); END IF; END$$ DELIMITER ; -- ============================================ -- VIEWS untuk ETL dan Reporting -- ============================================ -- View: Dashboard statistics CREATE OR REPLACE VIEW vw_dashboard_stats AS SELECT (SELECT COUNT(*) FROM items WHERE status = 'unclaimed' AND deleted_at IS NULL) AS total_unclaimed, (SELECT COUNT(*) FROM items WHERE status = 'claimed' AND deleted_at IS NULL) AS total_claimed, (SELECT COUNT(*) FROM lost_items WHERE status = 'active' AND deleted_at IS NULL) AS total_lost_reports, (SELECT COUNT(*) FROM claims WHERE status = 'pending' AND deleted_at IS NULL) AS pending_claims, (SELECT COUNT(*) FROM match_results WHERE is_notified = FALSE AND deleted_at IS NULL) AS unnotified_matches; -- View: Items dengan informasi kategori dan reporter CREATE OR REPLACE VIEW vw_items_detail AS SELECT i.id, i.name, c.name AS category_name, c.slug AS category_slug, i.photo_url, i.location, i.date_found, i.status, i.reporter_name, i.reporter_contact, i.expires_at, u.name AS reporter_user_name, u.email AS reporter_email, DATEDIFF(i.expires_at, NOW()) AS days_until_expire, i.created_at FROM items i JOIN categories c ON i.category_id = c.id JOIN users u ON i.reporter_id = u.id WHERE i.deleted_at IS NULL; -- View: Claims dengan detail lengkap CREATE OR REPLACE VIEW vw_claims_detail AS SELECT c.id, c.status, i.name AS item_name, cat.name AS category_name, u.name AS claimant_name, u.email AS claimant_email, u.phone AS claimant_phone, c.description AS claim_description, c.contact, cv.similarity_score, c.verified_at, v.name AS verified_by_name, c.notes, c.created_at FROM claims c JOIN items i ON c.item_id = i.id JOIN categories cat ON i.category_id = cat.id JOIN users u ON c.user_id = u.id LEFT JOIN claim_verifications cv ON c.id = cv.claim_id LEFT JOIN users v ON c.verified_by = v.id WHERE c.deleted_at IS NULL; -- View: Match results dengan detail CREATE OR REPLACE VIEW vw_match_results_detail AS SELECT mr.id, li.name AS lost_item_name, li.user_id AS lost_by_user_id, u.name AS lost_by_user_name, u.email AS lost_by_email, i.name AS found_item_name, i.reporter_name AS found_by_name, mr.similarity_score, mr.is_notified, mr.matched_at, i.id AS found_item_id, li.id AS lost_item_id FROM match_results mr JOIN lost_items li ON mr.lost_item_id = li.id JOIN items i ON mr.item_id = i.id JOIN users u ON li.user_id = u.id WHERE mr.deleted_at IS NULL ORDER BY mr.similarity_score DESC; -- View: Category statistics CREATE OR REPLACE VIEW vw_category_stats AS SELECT c.id, c.name, c.slug, COUNT(DISTINCT i.id) AS total_items, COUNT(DISTINCT CASE WHEN i.status = 'unclaimed' THEN i.id END) AS unclaimed_items, COUNT(DISTINCT CASE WHEN i.status = 'claimed' THEN i.id END) AS claimed_items, COUNT(DISTINCT li.id) AS total_lost_reports FROM categories c LEFT JOIN items i ON c.id = i.category_id AND i.deleted_at IS NULL LEFT JOIN lost_items li ON c.id = li.category_id AND li.deleted_at IS NULL WHERE c.deleted_at IS NULL GROUP BY c.id, c.name, c.slug; -- ============================================ -- EVENT SCHEDULER (untuk auto-archiving) -- ============================================ -- Enable event scheduler SET GLOBAL event_scheduler = ON; -- Event: Archive expired items setiap hari jam 1 pagi CREATE EVENT IF NOT EXISTS evt_archive_expired_items ON SCHEDULE EVERY 1 DAY STARTS CURRENT_TIMESTAMP + INTERVAL 1 DAY DO CALL sp_archive_expired_items(@count); -- Event: Auto-match items setiap 1 jam CREATE EVENT IF NOT EXISTS evt_auto_match_items ON SCHEDULE EVERY 1 HOUR DO CALL sp_auto_match_items(); -- ============================================ -- SUCCESS MESSAGE -- ============================================ SELECT 'āœ… Database enhancements created!' AS Status; SELECT 'šŸ“ Functions: 3' AS Info; SELECT 'āš™ļø Procedures: 4' AS Info; SELECT 'šŸ”” Triggers: 5' AS Info; SELECT 'šŸ“Š Views: 5' AS Info; SELECT 'ā° Events: 2' AS Info; -- database/schema.sql -- Lost & Found Database Schema (FIXED - SYNC with ERD) -- MySQL/MariaDB Database -- ============================================ -- CREATE DATABASE -- ============================================ DROP DATABASE IF EXISTS lost_and_found; CREATE DATABASE lost_and_found CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE lost_and_found; -- Set charset dan collation SET NAMES utf8mb4; SET CHARACTER SET utf8mb4; -- Drop tables if exists (untuk clean migration) DROP TABLE IF EXISTS notifications; DROP TABLE IF EXISTS revision_logs; DROP TABLE IF EXISTS verification_logs; DROP TABLE IF EXISTS match_results; DROP TABLE IF EXISTS claim_verifications; DROP TABLE IF EXISTS audit_logs; DROP TABLE IF EXISTS archives; DROP TABLE IF EXISTS claims; DROP TABLE IF EXISTS attachments; DROP TABLE IF EXISTS items; DROP TABLE IF EXISTS lost_items; DROP TABLE IF EXISTS categories; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS roles; -- ============================================ -- ROLES TABLE -- ============================================ CREATE TABLE roles ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) UNIQUE NOT NULL, description TEXT, permissions JSON DEFAULT NULL COMMENT 'RBAC permissions in JSON format', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, INDEX idx_roles_name (name), INDEX idx_roles_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- USERS TABLE -- ============================================ CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, nrp VARCHAR(20) UNIQUE DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, role_id INT UNSIGNED NOT NULL DEFAULT 3, status VARCHAR(20) DEFAULT 'active', last_login DATETIME DEFAULT NULL COMMENT 'Last login 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 (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, icon_url VARCHAR(255) DEFAULT NULL COMMENT 'Category icon URL', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, INDEX idx_categories_slug (slug), INDEX idx_categories_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- ITEMS TABLE (Barang Ditemukan) -- ============================================ CREATE TABLE items ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, category_id INT UNSIGNED NOT NULL, photo_url VARCHAR(255) DEFAULT NULL, location VARCHAR(200) NOT NULL, description TEXT NOT NULL COMMENT 'Public description', secret_details TEXT DEFAULT NULL COMMENT 'RAHASIA - untuk verifikasi klaim (hanya visible untuk owner/admin)', date_found DATE NOT NULL COMMENT 'Tanggal barang ditemukan', status VARCHAR(50) DEFAULT 'unclaimed' COMMENT 'unclaimed, claimed, expired', reporter_id INT UNSIGNED NOT NULL, reporter_name VARCHAR(100) NOT NULL, reporter_contact VARCHAR(50) NOT NULL, view_count INT DEFAULT 0 COMMENT 'Total views untuk analytics', expires_at TIMESTAMP NULL DEFAULT NULL COMMENT 'Tanggal barang akan dihapus (3 bulan dari date_found)', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE RESTRICT, INDEX idx_items_category_id (category_id), INDEX idx_items_status (status), INDEX idx_items_reporter_id (reporter_id), INDEX idx_items_date_found (date_found), INDEX idx_items_expires_at (expires_at), INDEX idx_items_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- LOST_ITEMS TABLE (Barang Hilang) -- ============================================ CREATE TABLE lost_items ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, name VARCHAR(100) NOT NULL, category_id INT UNSIGNED NOT NULL, color VARCHAR(50) DEFAULT NULL, location VARCHAR(200) DEFAULT NULL, description TEXT NOT NULL COMMENT 'Deskripsi untuk auto-matching', date_lost DATE NOT NULL COMMENT 'Tanggal barang hilang', status VARCHAR(50) DEFAULT 'active' COMMENT 'active, resolved', resolved_at DATETIME DEFAULT NULL COMMENT 'Kapan laporan hilang ditandai sebagai selesai', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, INDEX idx_lost_items_user_id (user_id), INDEX idx_lost_items_category_id (category_id), INDEX idx_lost_items_status (status), INDEX idx_lost_items_date_lost (date_lost), INDEX idx_lost_items_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- ATTACHMENTS TABLE (Foto Barang) -- ============================================ CREATE TABLE attachments ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to items, nullable for lost_items', lost_item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to lost_items, nullable for items', file_url VARCHAR(255) NOT NULL, file_type VARCHAR(50) DEFAULT NULL COMMENT 'jpg, png, gif, dll', file_size INT DEFAULT NULL COMMENT 'File size in bytes', upload_by_user_id INT UNSIGNED DEFAULT NULL, display_order INT DEFAULT 0 COMMENT 'Order untuk display multiple photos', is_primary BOOLEAN DEFAULT FALSE COMMENT 'Primary photo untuk display', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE, FOREIGN KEY (upload_by_user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_attachments_item_id (item_id), INDEX idx_attachments_lost_item_id (lost_item_id), INDEX idx_attachments_is_primary (is_primary) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- CLAIMS TABLE (Klaim Barang) -- ============================================ CREATE TABLE claims ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, item_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, description TEXT NOT NULL COMMENT 'Deskripsi dari user untuk verifikasi', proof_url VARCHAR(255) DEFAULT NULL, contact VARCHAR(50) NOT NULL, status VARCHAR(50) DEFAULT 'pending' COMMENT 'pending, approved, rejected', notes TEXT DEFAULT NULL COMMENT 'Admin verification notes', rejection_reason VARCHAR(255) DEFAULT NULL COMMENT 'Alasan penolakan klaim', attempt_count INT DEFAULT 1 COMMENT 'Jumlah percobaan klaim (fraud prevention)', verified_at TIMESTAMP NULL DEFAULT NULL, verified_by INT UNSIGNED DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_claims_item_id (item_id), INDEX idx_claims_user_id (user_id), INDEX idx_claims_status (status), INDEX idx_claims_verified_by (verified_by), INDEX idx_claims_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- CLAIM_VERIFICATIONS TABLE (Data Verifikasi Klaim) -- ============================================ CREATE TABLE claim_verifications ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, claim_id INT UNSIGNED UNIQUE NOT NULL, similarity_score DECIMAL(5,2) DEFAULT 0.00 COMMENT 'Similarity score 0-100', matched_keywords TEXT DEFAULT NULL COMMENT 'Keywords matched (JSON format)', verification_notes TEXT DEFAULT NULL, is_auto_matched BOOLEAN DEFAULT FALSE, verification_method VARCHAR(50) DEFAULT 'manual' COMMENT 'manual, auto, hybrid', metadata JSON DEFAULT NULL COMMENT 'Additional verification data (extensible)', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE, INDEX idx_claim_verifications_claim_id (claim_id), INDEX idx_claim_verifications_similarity_score (similarity_score), INDEX idx_claim_verifications_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- VERIFICATION_LOGS TABLE (Audit Trail Verifikasi) -- ============================================ CREATE TABLE verification_logs ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, verification_id INT UNSIGNED NOT NULL, verified_by_user_id INT UNSIGNED NOT NULL, action VARCHAR(50) NOT NULL COMMENT 'approve, reject, pending, review', reason TEXT DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (verification_id) REFERENCES claim_verifications(id) ON DELETE CASCADE, FOREIGN KEY (verified_by_user_id) REFERENCES users(id) ON DELETE RESTRICT, INDEX idx_verification_logs_verification_id (verification_id), INDEX idx_verification_logs_verified_by_user_id (verified_by_user_id), INDEX idx_verification_logs_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- MATCH_RESULTS TABLE (Hasil Auto-Matching) -- ============================================ CREATE TABLE match_results ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, lost_item_id INT UNSIGNED NOT NULL, item_id INT UNSIGNED NOT NULL, similarity_score DECIMAL(5,2) NOT NULL, matched_fields TEXT DEFAULT NULL COMMENT 'JSON format: {category, description, color}', match_reason VARCHAR(100) DEFAULT NULL COMMENT 'Reason for matching: color, location, description, etc', matched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_notified BOOLEAN DEFAULT FALSE, notified_at DATETIME DEFAULT NULL COMMENT 'Kapan user diberitahu tentang match', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, INDEX idx_match_results_lost_item_id (lost_item_id), INDEX idx_match_results_item_id (item_id), INDEX idx_match_results_similarity_score (similarity_score), INDEX idx_match_results_is_notified (is_notified), INDEX idx_match_results_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- ARCHIVES TABLE (Barang yang Diarsipkan) -- ============================================ CREATE TABLE archives ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, item_id INT UNSIGNED UNIQUE NOT NULL COMMENT 'Original item ID', name VARCHAR(100) NOT NULL, category_id INT UNSIGNED NOT NULL, photo_url VARCHAR(255) DEFAULT NULL, location VARCHAR(200) DEFAULT NULL, description TEXT DEFAULT NULL, date_found DATE DEFAULT NULL COMMENT 'Tanggal barang ditemukan (dari items)', status VARCHAR(50) DEFAULT NULL, reporter_name VARCHAR(100) DEFAULT NULL, reporter_contact VARCHAR(50) DEFAULT NULL, archived_reason VARCHAR(100) DEFAULT NULL COMMENT 'expired, case_closed', claimed_by INT UNSIGNED DEFAULT NULL, archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, FOREIGN KEY (claimed_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_archives_item_id (item_id), INDEX idx_archives_category_id (category_id), INDEX idx_archives_archived_reason (archived_reason), INDEX idx_archives_archived_at (archived_at), INDEX idx_archives_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- REVISION_LOGS TABLE (Audit Trail Edit Barang) -- ============================================ CREATE TABLE revision_logs ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, item_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL, field_name VARCHAR(50) NOT NULL, old_value TEXT DEFAULT NULL, new_value TEXT DEFAULT NULL, reason TEXT DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_revision_logs_item_id (item_id), INDEX idx_revision_logs_user_id (user_id), INDEX idx_revision_logs_created_at (created_at), INDEX idx_revision_logs_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- AUDIT_LOGS TABLE (System Audit Trail) -- ============================================ CREATE TABLE audit_logs ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED DEFAULT NULL, action VARCHAR(50) NOT NULL, entity_type VARCHAR(50) DEFAULT NULL, entity_id INT UNSIGNED DEFAULT NULL, details TEXT DEFAULT NULL, ip_address VARCHAR(50) DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, INDEX idx_audit_logs_user_id (user_id), INDEX idx_audit_logs_action (action), INDEX idx_audit_logs_entity_type (entity_type), INDEX idx_audit_logs_entity_id (entity_id), INDEX idx_audit_logs_created_at (created_at), INDEX idx_audit_logs_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- NOTIFICATIONS TABLE (Notifikasi User) -- ============================================ CREATE TABLE notifications ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(200) NOT NULL, message TEXT NOT NULL, entity_type VARCHAR(50) DEFAULT NULL, entity_id INT UNSIGNED DEFAULT NULL, channel VARCHAR(50) DEFAULT 'push' COMMENT 'email, sms, push', is_read BOOLEAN DEFAULT FALSE, read_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL DEFAULT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_notifications_user_id (user_id), INDEX idx_notifications_type (type), INDEX idx_notifications_is_read (is_read), INDEX idx_notifications_created_at (created_at), INDEX idx_notifications_deleted_at (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ============================================ -- SUCCESS MESSAGE -- ============================================ SELECT 'āœ… Database schema created successfully!' AS Status; SELECT 'šŸ“‹ Total tables: 15 (UPGRADED from 13)' AS Info; SELECT 'šŸ”‘ Indexes created on all tables' AS Info; SELECT 'šŸ”— Foreign keys with proper constraints' AS Info; SELECT '✨ NOW SYNC with ERD diagram!' AS Info; SELECT 'šŸ“ Next step: Run seed.sql to populate initial data' AS NextStep; -- database/seed.sql -- Lost & Found Database Seed Data (FIXED - SYNC with ERD) -- MySQL/MariaDB Database SET NAMES utf8mb4; SET CHARACTER SET utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ============================================ -- SEED ROLES -- ============================================ INSERT INTO roles (name, description, permissions) VALUES ('admin', 'Administrator with full access', JSON_OBJECT( 'users', JSON_OBJECT('create', true, 'read', true, 'update', true, 'delete', true), 'items', JSON_OBJECT('create', true, 'read', true, 'update', true, 'delete', true), 'claims', JSON_OBJECT('create', true, 'read', true, 'update', true, 'delete', true, 'verify', true), 'reports', JSON_OBJECT('access', true) )), ('manager', 'Manager/Cleaning Service - can verify claims', JSON_OBJECT( 'items', JSON_OBJECT('read', true, 'update', true), 'claims', JSON_OBJECT('read', true, 'verify', true), 'reports', JSON_OBJECT('access', true) )), ('user', 'Regular user/student', JSON_OBJECT( 'items', JSON_OBJECT('read', true), 'lost_items', JSON_OBJECT('create', true, 'read', true, 'update', true), 'claims', JSON_OBJECT('create', true, 'read', true) )); -- ============================================ -- SEED CATEGORIES -- ============================================ INSERT INTO categories (name, slug, description, icon_url) VALUES ('Pakaian', 'pakaian', 'Jaket, baju, celana, dll', '/icons/clothing.png'), ('Alat Makan', 'alat_makan', 'Botol minum, lunchbox, dll', '/icons/food.png'), ('Aksesoris', 'aksesoris', 'Jam tangan, gelang, kacamata, dll', '/icons/accessories.png'), ('Elektronik', 'elektronik', 'Kalkulator, mouse, headset, charger, dll', '/icons/electronics.png'), ('Alat Tulis', 'alat_tulis', 'Buku, pulpen, tipe-x, dll', '/icons/stationery.png'), ('Lainnya', 'lainnya', 'Kategori lainnya', '/icons/others.png'); -- ============================================ -- 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, last_login) VALUES ('Admin System', 'admin@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '1234567890', '081234567890', 1, 'active', '2024-02-10 14:30:00'); -- Manager Users INSERT INTO users (name, email, password, nrp, phone, role_id, status, last_login) VALUES ('Pak Budi', 'manager1@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567890', '081234567891', 2, 'active', '2024-02-10 13:00:00'), ('Bu Siti', 'manager2@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567891', '081234567892', 2, 'active', '2024-02-09 15:30:00'); -- Regular Users (Students) INSERT INTO users (name, email, password, nrp, phone, role_id, status, last_login) VALUES ('Ahmad Rizki', 'ahmad@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211001', '081234567893', 3, 'active', '2024-02-10 10:15:00'), ('Siti Nurhaliza', 'siti@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211002', '081234567894', 3, 'active', '2024-02-08 14:45:00'), ('Budi Santoso', 'budi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211003', '081234567895', 3, 'active', '2024-02-07 11:20:00'), ('Dewi Lestari', 'dewi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211004', '081234567896', 3, 'active', '2024-02-05 09:30:00'), ('Eko Prasetyo', 'eko@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211005', '081234567897', 3, 'active', '2024-02-04 16:00:00'); -- ============================================ -- SEED ITEMS (Barang Ditemukan) -- ============================================ INSERT INTO items (name, category_id, photo_url, location, description, secret_details, date_found, status, reporter_id, reporter_name, reporter_contact, view_count, expires_at) VALUES -- Pakaian ('Jaket Hitam Nike', 1, '/photos/item1.jpg', 'Gedung A lantai 2', 'Jaket hitam merk Nike ukuran M, ada logo putih di dada kiri, resleting berwarna silver', 'Resleting sudah rusak di bagian bawah, ada tambalan kecil di saku kanan', '2024-01-15', 'unclaimed', 2, 'Pak Budi', '081234567891', 24, '2024-04-15 09:30:00'), ('Sweater Abu-abu', 1, '/photos/item2.jpg', 'Perpustakaan', 'Sweater abu-abu polos, ada tulisan "ITS" di punggung dengan huruf biru', 'Kancing di tengah ada yang hilang, halus dan lembut', '2024-01-18', 'unclaimed', 3, 'Bu Siti', '081234567892', 18, '2024-04-18 14:20:00'), -- Alat Makan ('Botol Minum Tupperware Biru', 2, '/photos/item3.jpg', 'Kantin Utama', 'Botol minum tupperware warna biru tua, ada stiker nama "RARA" di samping, tutup warna putih', 'Stiker nama hampir hilang, ada goresan di dasar botol', '2024-01-20', 'unclaimed', 2, 'Pak Budi', '081234567891', 31, '2024-04-20 12:00:00'), ('Lunchbox Pink', 2, '/photos/item4.jpg', 'Gedung B lantai 1', 'Kotak makan plastik warna pink dengan 2 sekat, ada gambar Hello Kitty di tutup', 'Plastik agak kusam, ada bekas noda coklat di sekat bawah', '2024-01-22', 'unclaimed', 3, 'Bu Siti', '081234567892', 15, '2024-04-22 11:30:00'), -- Elektronik ('Kalkulator Casio FX-991ID Plus', 4, '/photos/item5.jpg', 'Ruang Kelas C101', 'Kalkulator scientific Casio FX-991ID Plus, warna hitam, ada goresan kecil di layar', 'Baterai masih bagus, layar LCD cerah, tidak ada tombol yang macet', '2024-01-25', 'unclaimed', 2, 'Pak Budi', '081234567891', 42, '2024-04-25 08:15:00'), ('Mouse Wireless Logitech', 4, '/photos/item6.jpg', 'Lab Komputer', 'Mouse wireless Logitech M185, warna merah, tidak ada baterai dan receiver USB', 'Receiver USB hilang, butuh baterai AA 2 pcs untuk penggantinya', '2024-01-28', 'unclaimed', 3, 'Bu Siti', '081234567892', 19, '2024-04-28 15:45:00'), ('Charger Laptop HP', 4, '/photos/item7.jpg', 'Perpustakaan meja 15', 'Charger laptop HP original 65W, kabel agak kusut, ujung konektor bulat', 'Konektor terlihat bagus, output stabil, tested working', '2024-02-01', 'unclaimed', 2, 'Pak Budi', '081234567891', 27, '2024-05-01 10:20:00'), -- Aksesoris ('Jam Tangan Casio G-Shock', 3, '/photos/item8.jpg', 'Toilet Gedung A', 'Jam tangan Casio G-Shock warna hitam dengan strip orange, tali karet, ada goresan di layar', 'Baterai berusia 2 tahun, tali karet masih lentur, tahan air', '2024-02-03', 'unclaimed', 3, 'Bu Siti', '081234567892', 35, '2024-05-03 13:00:00'), ('Kacamata Minus', 3, '/photos/item9.jpg', 'Masjid Kampus', 'Kacamata minus frame hitam persegi, lensa agak tebal, ada case warna coklat', 'Minus -4.00 untuk kedua mata, lensa anti radiasi, frame bagus', '2024-02-05', 'unclaimed', 2, 'Pak Budi', '081234567891', 22, '2024-05-05 16:30:00'), -- Alat Tulis ('Buku Kalkulus', 5, '/photos/item10.jpg', 'Gedung C lantai 3', 'Buku Kalkulus karangan Purcell edisi 9, sampul biru, ada coretan nama di halaman pertama (nama dicoret)', 'Halaman lengkap, tidak ada yang hilang, tulisan tangan di margin', '2024-02-08', 'unclaimed', 3, 'Bu Siti', '081234567892', 13, '2024-05-08 09:00:00'), ('Pensil Mekanik Rotring', 5, '/photos/item11.jpg', 'Studio Gambar', 'Pensil mekanik Rotring Rapid Pro 0.5mm warna silver, agak berat, ada penyok kecil di badan', 'Grip karet masih bagus, mekanisme click terasa presisi', '2024-02-10', 'unclaimed', 2, 'Pak Budi', '081234567891', 8, '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, resolved_at) VALUES -- User Ahmad (4, 'Jaket Hitam', 1, 'Hitam', 'Gedung A', 'Jaket hitam merk Nike ukuran M dengan logo putih di dada, resleting silver', '2024-01-15', 'active', NULL), (4, 'Kalkulator Casio Scientific', 4, 'Hitam', 'Ruang Kelas', 'Kalkulator scientific Casio FX-991ID Plus warna hitam', '2024-01-25', 'active', NULL), -- User Siti (5, 'Botol Minum Biru', 2, 'Biru', 'Kantin', 'Botol tupperware biru dengan stiker nama RARA', '2024-01-20', 'active', NULL), (5, 'Jam Tangan Casio', 3, 'Hitam', 'Toilet', 'Jam tangan G-Shock hitam dengan strip orange', '2024-02-03', 'active', NULL), -- User Budi (6, 'Mouse Wireless', 4, 'Merah', 'Lab Komputer', 'Mouse wireless Logitech warna merah tanpa receiver', '2024-01-28', 'active', NULL), -- User Dewi (7, 'Buku Kalkulus', 5, 'Biru', 'Gedung C', 'Buku Kalkulus Purcell edisi 9 sampul biru', '2024-02-08', 'active', NULL), -- User Eko (8, 'Kacamata Minus', 3, 'Hitam', 'Masjid', 'Kacamata frame hitam persegi dengan case coklat', '2024-02-05', 'active', NULL); -- ============================================ -- SEED ATTACHMENTS (Foto Barang) -- ============================================ INSERT INTO attachments (item_id, lost_item_id, file_url, file_type, file_size, upload_by_user_id, display_order, is_primary) VALUES -- Attachments untuk items (barang ditemukan) (1, NULL, '/photos/items/jaket-nike-1.jpg', 'jpg', 2048576, 2, 1, TRUE), (1, NULL, '/photos/items/jaket-nike-2.jpg', 'jpg', 1843200, 2, 2, FALSE), (3, NULL, '/photos/items/botol-tupperware-1.jpg', 'jpg', 1536000, 2, 1, TRUE), (5, NULL, '/photos/items/kalkulator-casio-1.jpg', 'jpg', 1920000, 2, 1, TRUE), (5, NULL, '/photos/items/kalkulator-casio-2.jpg', 'jpg', 1720320, 2, 2, FALSE), (8, NULL, '/photos/items/jam-tangan-1.jpg', 'jpg', 2150400, 3, 1, TRUE), -- Attachments untuk lost_items (barang hilang) (NULL, 1, '/photos/lost_items/jaket-hilang-1.jpg', 'jpg', 1843200, 4, 1, TRUE), (NULL, 3, '/photos/lost_items/botol-hilang-1.jpg', 'jpg', 1638400, 5, 1, TRUE), (NULL, 4, '/photos/lost_items/jam-tangan-hilang-1.jpg', 'jpg', 2048576, 5, 1, TRUE), (NULL, 5, '/photos/lost_items/mouse-hilang-1.jpg', 'jpg', 1434880, 6, 1, TRUE); -- ============================================ -- SEED CLAIMS (Klaim Barang) -- ============================================ INSERT INTO claims (item_id, user_id, description, proof_url, contact, status, notes, rejection_reason, attempt_count, verified_at, verified_by) VALUES -- Claim pending (belum diverifikasi) (1, 4, 'Jaket Nike hitam ukuran M, ada logo putih di dada kiri, resleting silver', '/proofs/claim1.jpg', '081234567893', 'pending', NULL, NULL, 1, NULL, NULL), (3, 5, 'Botol tupperware biru dengan stiker nama RARA di samping, tutup putih', '/proofs/claim2.jpg', '081234567894', 'pending', NULL, NULL, 1, NULL, NULL), -- Claim yang sudah approved (5, 4, 'Kalkulator Casio FX-991ID Plus warna hitam, ada goresan kecil di layar, baterai masih bagus', '/proofs/claim3.jpg', '081234567893', 'approved', 'Deskripsi cocok dengan secret details, barang diserahkan tanggal 2024-02-09', NULL, 1, '2024-02-09 10:30:00', 2); -- ============================================ -- SEED CLAIM_VERIFICATIONS -- ============================================ INSERT INTO claim_verifications (claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched, verification_method, metadata) VALUES (1, 85.50, '["jaket", "nike", "hitam", "logo", "putih", "resleting", "silver"]', 'High similarity detected in description and secret details', FALSE, 'manual', JSON_OBJECT( 'description_match', 85, 'keywords_match', 90, 'confidence', 'high', 'reviewer_notes', 'Sangat sesuai dengan foto dan deskripsi' )), (2, 92.30, '["botol", "tupperware", "biru", "stiker", "rara", "tutup", "putih"]', 'Very high similarity - likely owner, specific details match perfectly', FALSE, 'manual', JSON_OBJECT( 'description_match', 95, 'keywords_match', 94, 'confidence', 'very_high', 'reviewer_notes', 'Pemilik tahu detail spesifik dari botol' )), (3, 88.70, '["kalkulator", "casio", "hitam", "goresan", "layar", "baterai"]', 'Verified and approved - exact match with secret details', TRUE, 'hybrid', JSON_OBJECT( 'description_match', 92, 'keywords_match', 89, 'confidence', 'very_high', 'auto_match_score', 85, 'manual_review', 'Confirmed by manager' )); -- ============================================ -- SEED VERIFICATION_LOGS -- ============================================ INSERT INTO verification_logs (verification_id, verified_by_user_id, action, reason) VALUES (3, 2, 'approve', 'Deskripsi dan detail rahasia cocok sempurna. Klaim disetujui dan barang sudah diserahkan kepada Ahmad Rizki pada 2024-02-09 pukul 10:30 dengan tanda tangan.'), (1, 2, 'pending', 'Menunggu konfirmasi lebih lanjut dari manajer. Saat ini sedang dalam review awal.'), (2, 2, 'pending', 'Dalam proses verifikasi. Akan hubungi claimant untuk konfirmasi lebih detail.'); -- ============================================ -- SEED MATCH_RESULTS (Auto-Matching) -- ============================================ INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, match_reason, matched_at, is_notified, notified_at) VALUES (1, 1, 87.50, '{"name": 85, "category": 100, "description": 90, "color": 100}', 'color, description, category match', '2024-01-15 10:00:00', TRUE, '2024-01-15 10:15:00'), (2, 5, 89.20, '{"name": 88, "category": 100, "description": 91}', 'name, category, description match', '2024-01-25 09:00:00', TRUE, '2024-01-25 09:30:00'), (3, 3, 91.80, '{"name": 90, "category": 100, "color": 100, "description": 89}', 'color, category match perfectly', '2024-01-20 13:00:00', TRUE, '2024-01-20 13:45:00'), (4, 8, 86.40, '{"name": 82, "category": 100, "color": 100, "description": 85}', 'color, category match', '2024-02-03 14:00:00', TRUE, '2024-02-03 14:20:00'), (5, 6, 84.60, '{"name": 80, "category": 100, "color": 100, "description": 88}', 'color, category match', '2024-01-28 16:00:00', FALSE, NULL); -- ============================================ -- SEED NOTIFICATIONS -- ============================================ INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, channel, 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. Similarity Score: 87.5%', 'match', 1, 'push', TRUE, '2024-01-15 11:00:00'), (4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Kalkulator Casio FX-991ID Plus. Similarity Score: 89.2%', 'match', 2, 'push', FALSE, NULL), (5, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Botol Minum Tupperware Biru. Similarity Score: 91.8%', 'match', 3, 'push', 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, 'email', FALSE, NULL), -- Notifikasi untuk manager (pending claims) (2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Jaket Hitam Nike dari Ahmad Rizki. Similarity Score: 85.5%', 'claim', 1, 'push', FALSE, NULL), (2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Botol Minum Tupperware Biru dari Siti Nurhaliza. Similarity Score: 92.3%', 'claim', 2, 'push', 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 dengan secret details, barang diserahkan', '127.0.0.1', 'Mozilla/5.0'), -- Attachment upload (2, 'create', 'attachment', 1, 'Attachment uploaded for item 1', '127.0.0.1', 'Mozilla/5.0'), (4, 'create', 'attachment', 7, 'Attachment uploaded for lost_item 1', '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 'šŸ“ø Attachments created: 11 photos' AS Info; SELECT 'šŸŽ« Claims created: 3 (1 approved, 2 pending)' AS Info; SELECT 'āœ”ļø Claim verifications created: 3' AS Info; SELECT 'šŸ” Verification logs created: 3' AS Info; SELECT 'šŸ”— Match results created: 5' AS Info; SELECT 'šŸ”” Notifications created: 6' AS Info; SELECT 'šŸ“‹ Audit logs created: 13' 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 '✨ NOW 100% SYNC with ERD!' AS Status2; SELECT 'šŸš€ Ready to use! Database is production-ready' AS NextStep; // internal/config/config.go 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" } package config import ( "fmt" "log" "lost-and-found/internal/models" "os" "strings" "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) var db *gorm.DB // DatabaseConfig holds database connection configuration type DatabaseConfig struct { Host string Port string User string Password string DBName string Charset string ParseTime string Loc string } // GetDatabaseConfig returns database configuration from environment func GetDatabaseConfig() DatabaseConfig { return DatabaseConfig{ Host: getEnv("DB_HOST", "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() // Step 1: Connect to MySQL without specifying database (to create if not exists) if err := ensureDatabaseExists(config); err != nil { return err } // Step 2: Connect to the specific database dsn := fmt.Sprintf( "%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s&multiStatements=true", config.User, config.Password, config.Host, config.Port, config.DBName, config.Charset, config.ParseTime, config.Loc, ) // Configure GORM logger gormLogger := logger.Default if IsDevelopment() { gormLogger = logger.Default.LogMode(logger.Info) } else { gormLogger = logger.Default.LogMode(logger.Error) } // Open database connection var err error db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: gormLogger, NowFunc: func() time.Time { return time.Now().Local() }, }) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) } // Get underlying SQL database sqlDB, err := db.DB() if err != nil { return fmt.Errorf("failed to get database instance: %w", err) } // Set connection pool settings sqlDB.SetMaxIdleConns(10) sqlDB.SetMaxOpenConns(100) sqlDB.SetConnMaxLifetime(time.Hour) // Test connection if err := sqlDB.Ping(); err != nil { return fmt.Errorf("failed to ping database: %w", err) } log.Println("āœ… Database connected successfully") return nil } // ensureDatabaseExists checks if database exists, creates it if not func ensureDatabaseExists(config DatabaseConfig) error { // Connect to MySQL without specifying a database dsn := fmt.Sprintf( "%s:%s@tcp(%s:%s)/?charset=%s&parseTime=%s&loc=%s", config.User, config.Password, config.Host, config.Port, config.Charset, config.ParseTime, config.Loc, ) tempDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return fmt.Errorf("failed to connect to MySQL server: %w", err) } log.Printf("šŸ” Checking if database '%s' exists...", config.DBName) // Check if database exists var dbExists int64 if err := tempDB.Raw( "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", config.DBName, ).Scan(&dbExists).Error; err != nil { return fmt.Errorf("failed to check database existence: %w", err) } if dbExists == 0 { log.Printf("šŸ“ Creating database '%s'...", config.DBName) createSQL := fmt.Sprintf( "CREATE DATABASE IF NOT EXISTS %s CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", config.DBName, ) if err := tempDB.Exec(createSQL).Error; err != nil { return fmt.Errorf("failed to create database: %w", err) } log.Printf("āœ… Database '%s' created successfully", config.DBName) } else { log.Printf("āœ… Database '%s' already exists", config.DBName) } // Close temporary connection sqlDB, _ := tempDB.DB() sqlDB.Close() return nil } // GetDB returns the database instance func GetDB() *gorm.DB { return db } // RunMigrations runs database migrations from SQL files func RunMigrations(db *gorm.DB) error { log.Println("šŸ“Š Starting database migrations...") // Check if tables already exist if db.Migrator().HasTable(&models.Role{}) { log.Println("āœ… Database tables already exist, skipping migration") return nil } log.Println("šŸ“‹ Tables not found, running migration scripts...") // Step 1: Run schema.sql if err := runSQLFile(db, "database/schema.sql"); err != nil { return fmt.Errorf("āŒ Failed to run schema.sql: %w", err) } log.Println("āœ… Schema created successfully") // Step 2: Run seed.sql if err := runSQLFile(db, "database/seed.sql"); err != nil { return fmt.Errorf("āŒ Failed to run seed.sql: %w", err) } log.Println("āœ… Seed data inserted successfully") // Step 3: Run enhancement.sql (optional - for triggers, procedures, etc) if err := runSQLFile(db, "database/enhancement.sql"); err != nil { log.Printf("āš ļø Warning: Failed to run enhancement.sql: %v", err) log.Println("šŸ’” Enhancement features (triggers, procedures) may not be available") } else { log.Println("āœ… Enhancement features loaded successfully") } log.Println("šŸŽ‰ Database migration completed!") log.Println("šŸ”§ Default admin: admin@lostandfound.com / password123") return nil } // runSQLFile executes SQL from file func runSQLFile(db *gorm.DB, filepath string) error { // Check if file exists if _, err := os.Stat(filepath); os.IsNotExist(err) { return fmt.Errorf("file not found: %s", filepath) } log.Printf("šŸ“„ Reading SQL file: %s", filepath) // Read file content, err := os.ReadFile(filepath) if err != nil { return fmt.Errorf("failed to read file: %w", err) } // Split SQL content by delimiter sqlContent := string(content) // Remove comments and empty lines sqlContent = removeComments(sqlContent) // Split by DELIMITER if exists (for procedures/triggers) if strings.Contains(sqlContent, "DELIMITER") { return executeSQLWithDelimiter(db, sqlContent) } // Execute SQL normally if err := db.Exec(sqlContent).Error; err != nil { return fmt.Errorf("failed to execute SQL: %w", err) } log.Printf("āœ… SQL file executed: %s", filepath) return nil } // executeSQLWithDelimiter handles SQL with custom delimiters (for procedures/triggers) func executeSQLWithDelimiter(db *gorm.DB, content string) error { // Split by DELIMITER changes parts := strings.Split(content, "DELIMITER") for i, part := range parts { part = strings.TrimSpace(part) if part == "" || part == "$$" || part == ";" { continue } // Remove the delimiter declaration line lines := strings.Split(part, "\n") if len(lines) > 0 && (strings.HasPrefix(lines[0], "$$") || strings.HasPrefix(lines[0], ";")) { lines = lines[1:] } part = strings.Join(lines, "\n") // Split by custom delimiter ($$) if i%2 == 1 { // Odd parts use $$ delimiter statements := strings.Split(part, "$$") for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" || stmt == ";" { continue } if err := db.Exec(stmt).Error; err != nil { log.Printf("āš ļø Warning executing statement: %v", err) // Don't fail on enhancement errors (triggers, procedures) // These might fail if they already exist or MySQL version issues } } } else { // Even parts use ; delimiter statements := strings.Split(part, ";") for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } if err := db.Exec(stmt).Error; err != nil { return fmt.Errorf("failed to execute statement: %w", err) } } } } return nil } // removeComments removes SQL comments from content func removeComments(sql string) string { lines := strings.Split(sql, "\n") var cleaned []string for _, line := range lines { line = strings.TrimSpace(line) // Skip empty lines if line == "" { continue } // Skip single-line comments if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "#") { continue } // Keep the line cleaned = append(cleaned, line) } return strings.Join(cleaned, "\n") } // CloseDB closes database connection func CloseDB() error { if db != nil { sqlDB, err := db.DB() if err != nil { return err } return sqlDB.Close() } return nil } // Helper function to get environment variable with default value func getEnv(key, defaultValue string) string { value := os.Getenv(key) if value == "" { return defaultValue } return value } // internal/config/jwt.go package config import ( "errors" "os" "time" "github.com/golang-jwt/jwt/v5" ) var jwtConfig *JWTConfig // JWTConfig holds JWT configuration type JWTConfig struct { SecretKey string ExpirationHours int Issuer string } // JWTClaims represents the JWT claims type JWTClaims struct { UserID uint `json:"user_id"` Email string `json:"email"` Role string `json:"role"` jwt.RegisteredClaims } // InitJWT initializes JWT configuration func InitJWT() { secretKey := os.Getenv("JWT_SECRET_KEY") if secretKey == "" { secretKey = "your-secret-key-change-this-in-production" // Default for development } jwtConfig = &JWTConfig{ SecretKey: secretKey, ExpirationHours: 24 * 7, // 7 days Issuer: "lost-and-found-system", } } // GetJWTConfig returns JWT configuration func GetJWTConfig() JWTConfig { if jwtConfig == nil { InitJWT() } return *jwtConfig } // GenerateToken generates a new JWT token for a user func GenerateToken(userID uint, email, role string) (string, error) { config := GetJWTConfig() // Create claims claims := JWTClaims{ UserID: userID, Email: email, Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(config.ExpirationHours))), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: config.Issuer, }, } // Create token token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) // Sign token with secret key tokenString, err := token.SignedString([]byte(config.SecretKey)) if err != nil { return "", err } return tokenString, nil } // ValidateToken validates a JWT token and returns the claims func ValidateToken(tokenString string) (*JWTClaims, error) { config := GetJWTConfig() // Parse token token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { // Validate signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.New("invalid signing method") } return []byte(config.SecretKey), nil }) if err != nil { return nil, err } // Extract claims if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { return claims, nil } return nil, errors.New("invalid token") } // RefreshToken generates a new token with extended expiration func RefreshToken(oldTokenString string) (string, error) { // Validate old token claims, err := ValidateToken(oldTokenString) if err != nil { return "", err } // Generate new token with same user info return GenerateToken(claims.UserID, claims.Email, claims.Role) } // ExtractUserID extracts user ID from token string func ExtractUserID(tokenString string) (uint, error) { claims, err := ValidateToken(tokenString) if err != nil { return 0, err } return claims.UserID, nil } // ExtractRole extracts role from token string func ExtractRole(tokenString string) (string, error) { claims, err := ValidateToken(tokenString) if err != nil { return "", err } return claims.Role, nil } // internal/controllers/admin_controller.go package controllers import ( "lost-and-found/internal/repositories" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type AdminController struct { 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) } // internal/controllers/archive_controller.go package controllers import ( "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type ArchiveController struct { archiveService *services.ArchiveService } func NewArchiveController(db *gorm.DB) *ArchiveController { return &ArchiveController{ archiveService: services.NewArchiveService(db), } } // GetAllArchives gets all archived items // GET /api/archives func (c *ArchiveController) GetAllArchives(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) reason := ctx.Query("reason") search := ctx.Query("search") archives, total, err := c.archiveService.GetAllArchives(page, limit, reason, search) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get archives", err.Error()) return } utils.SendPaginatedResponse(ctx, http.StatusOK, "Archives retrieved", archives, total, page, limit) } // GetArchiveByID gets archive by ID // GET /api/archives/:id func (c *ArchiveController) GetArchiveByID(ctx *gin.Context) { id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid archive ID", err.Error()) return } archive, err := c.archiveService.GetArchiveByID(uint(id)) if err != nil { utils.ErrorResponse(ctx, http.StatusNotFound, "Archive not found", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Archive retrieved", archive.ToResponse()) } // GetArchiveStats gets archive statistics // GET /api/archives/stats func (c *ArchiveController) GetArchiveStats(ctx *gin.Context) { stats, err := c.archiveService.GetArchiveStats() if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Archive stats retrieved", stats) } // internal/controllers/auth_controller.go package controllers import ( "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "github.com/gin-gonic/gin" "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) } // internal/controllers/category_controller.go package controllers import ( "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type CategoryController struct { categoryService *services.CategoryService } func NewCategoryController(db *gorm.DB) *CategoryController { return &CategoryController{ categoryService: services.NewCategoryService(db), } } // GetAllCategories gets all categories // GET /api/categories func (c *CategoryController) GetAllCategories(ctx *gin.Context) { categories, err := c.categoryService.GetAllCategories() if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get categories", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Categories retrieved", categories) } // GetCategoryByID gets category by ID // GET /api/categories/:id func (c *CategoryController) GetCategoryByID(ctx *gin.Context) { id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error()) return } category, err := c.categoryService.GetCategoryByID(uint(id)) if err != nil { utils.ErrorResponse(ctx, http.StatusNotFound, "Category not found", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Category retrieved", category.ToResponse()) } // CreateCategory creates a new category (admin only) // POST /api/categories func (c *CategoryController) CreateCategory(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) var req services.CreateCategoryRequest if err := ctx.ShouldBindJSON(&req); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() category, err := c.categoryService.CreateCategory(admin.ID, req, ipAddress, userAgent) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create category", err.Error()) return } utils.SuccessResponse(ctx, http.StatusCreated, "Category created", category.ToResponse()) } // UpdateCategory updates a category (admin only) // PUT /api/categories/:id func (c *CategoryController) UpdateCategory(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error()) return } var req services.UpdateCategoryRequest if err := ctx.ShouldBindJSON(&req); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() category, err := c.categoryService.UpdateCategory(admin.ID, uint(categoryID), req, ipAddress, userAgent) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update category", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Category updated", category.ToResponse()) } // DeleteCategory deletes a category (admin only) // DELETE /api/categories/:id func (c *CategoryController) DeleteCategory(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() if err := c.categoryService.DeleteCategory(admin.ID, uint(categoryID), ipAddress, userAgent); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete category", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Category deleted", nil) } // internal/controllers/claim_controller.go package controllers import ( "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type ClaimController struct { claimService *services.ClaimService verificationService *services.VerificationService } func NewClaimController(db *gorm.DB) *ClaimController { return &ClaimController{ claimService: services.NewClaimService(db), verificationService: services.NewVerificationService(db), } } // GetAllClaims gets all claims // GET /api/claims func (c *ClaimController) GetAllClaims(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) status := ctx.Query("status") var itemID, userID *uint if itemIDStr := ctx.Query("item_id"); itemIDStr != "" { id, _ := strconv.ParseUint(itemIDStr, 10, 32) itemID = new(uint) *itemID = uint(id) } // If regular user, only show their claims if userObj, exists := ctx.Get("user"); exists { user := userObj.(*models.User) if user.IsUser() { userID = &user.ID } } claims, total, err := c.claimService.GetAllClaims(page, limit, status, itemID, userID) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error()) return } 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 } // internal/controllers/item_controller.go package controllers import ( "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type 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) } // internal/controllers/lost_item_controller.go package controllers import ( "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type LostItemController struct { lostItemService *services.LostItemService } func NewLostItemController(db *gorm.DB) *LostItemController { return &LostItemController{ lostItemService: services.NewLostItemService(db), } } // GetAllLostItems gets all lost items // GET /api/lost-items func (c *LostItemController) GetAllLostItems(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) status := ctx.Query("status") category := ctx.Query("category") search := ctx.Query("search") 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) } // internal/controllers/match_controller.go package controllers import ( "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type MatchController struct { matchService *services.MatchService } func NewMatchController(db *gorm.DB) *MatchController { return &MatchController{ matchService: services.NewMatchService(db), } } // FindSimilarItems finds similar items for a lost item // POST /api/lost-items/:id/find-similar func (c *MatchController) FindSimilarItems(ctx *gin.Context) { userObj, _ := ctx.Get("user") user := userObj.(*models.User) lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) return } // Only allow managers or the owner to search // Add ownership check here if needed results, err := c.matchService.FindSimilarItems(uint(lostItemID)) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to find similar items", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Similar items found", gin.H{ "total": len(results), "matches": results, "user_id": user.ID, }) } // GetMatchesForLostItem gets all matches for a lost item // GET /api/lost-items/:id/matches func (c *MatchController) GetMatchesForLostItem(ctx *gin.Context) { lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) return } matches, err := c.matchService.GetMatchesForLostItem(uint(lostItemID)) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches) } // GetMatchesForItem gets all matches for an item // GET /api/items/:id/matches func (c *MatchController) GetMatchesForItem(ctx *gin.Context) { itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) return } matches, err := c.matchService.GetMatchesForItem(uint(itemID)) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches) } // internal/controllers/report_controller.go package controllers import ( "fmt" "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type ReportController struct { exportService *services.ExportService } func NewReportController(db *gorm.DB) *ReportController { return &ReportController{ exportService: services.NewExportService(db), } } // ExportReport exports report based on request // POST /api/reports/export func (c *ReportController) ExportReport(ctx *gin.Context) { userObj, _ := ctx.Get("user") user := userObj.(*models.User) var req services.ExportRequest if err := ctx.ShouldBindJSON(&req); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() var buffer *[]byte var filename string var contentType string var err error // Generate report based on type and format switch req.Type { case "items": if req.Format == "pdf" { buf, e := c.exportService.ExportItemsToPDF(req, user.ID, ipAddress, userAgent) if e != nil { err = e } else { data := buf.Bytes() buffer = &data filename = fmt.Sprintf("items_report_%s.pdf", time.Now().Format("20060102")) contentType = "application/pdf" } } else { buf, e := c.exportService.ExportItemsToExcel(req, user.ID, ipAddress, userAgent) if e != nil { err = e } else { data := buf.Bytes() buffer = &data filename = fmt.Sprintf("items_report_%s.xlsx", time.Now().Format("20060102")) contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" } } case "archives": buf, e := c.exportService.ExportArchivesToPDF(req, user.ID, ipAddress, userAgent) if e != nil { err = e } else { data := buf.Bytes() buffer = &data filename = fmt.Sprintf("archives_report_%s.pdf", time.Now().Format("20060102")) contentType = "application/pdf" } case "claims": buf, e := c.exportService.ExportClaimsToPDF(req, user.ID, ipAddress, userAgent) if e != nil { err = e } else { data := buf.Bytes() buffer = &data filename = fmt.Sprintf("claims_report_%s.pdf", time.Now().Format("20060102")) contentType = "application/pdf" } default: utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid report type", "") return } if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to generate report", err.Error()) return } // Set headers ctx.Header("Content-Type", contentType) ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) ctx.Header("Content-Length", fmt.Sprintf("%d", len(*buffer))) // Send file ctx.Data(http.StatusOK, contentType, *buffer) } // internal/controllers/user_controller.go package controllers import ( "lost-and-found/internal/models" "lost-and-found/internal/services" "lost-and-found/internal/utils" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type UserController struct { userService *services.UserService } func NewUserController(db *gorm.DB) *UserController { return &UserController{ userService: services.NewUserService(db), } } // GetProfile gets user profile // GET /api/user/profile func (c *UserController) GetProfile(ctx *gin.Context) { userObj, _ := ctx.Get("user") user := userObj.(*models.User) profile, err := c.userService.GetProfile(user.ID) if err != nil { utils.ErrorResponse(ctx, http.StatusNotFound, "Profile not found", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Profile retrieved", profile.ToResponse()) } // UpdateProfile updates user profile // PUT /api/user/profile func (c *UserController) UpdateProfile(ctx *gin.Context) { userObj, _ := ctx.Get("user") user := userObj.(*models.User) var req services.UpdateProfileRequest if err := ctx.ShouldBindJSON(&req); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() updatedUser, err := c.userService.UpdateProfile(user.ID, req, ipAddress, userAgent) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Update failed", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Profile updated", updatedUser.ToResponse()) } // ChangePassword changes user password // POST /api/user/change-password func (c *UserController) ChangePassword(ctx *gin.Context) { userObj, _ := ctx.Get("user") user := userObj.(*models.User) var req services.ChangePasswordRequest if err := ctx.ShouldBindJSON(&req); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() if err := c.userService.ChangePassword(user.ID, req, ipAddress, userAgent); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Password change failed", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Password changed successfully", nil) } // GetStats gets user statistics // GET /api/user/stats func (c *UserController) GetStats(ctx *gin.Context) { userObj, _ := ctx.Get("user") user := userObj.(*models.User) stats, err := c.userService.GetUserStats(user.ID) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "Stats retrieved", stats) } // GetAllUsers gets all users (admin only) // GET /api/admin/users func (c *UserController) GetAllUsers(ctx *gin.Context) { page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) users, total, err := c.userService.GetAllUsers(page, limit) if err != nil { utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get users", err.Error()) return } var responses []models.UserResponse for _, user := range users { responses = append(responses, user.ToResponse()) } utils.SendPaginatedResponse(ctx, http.StatusOK, "Users retrieved", responses, total, page, limit) } // GetUserByID gets user by ID (admin only) // GET /api/admin/users/:id func (c *UserController) GetUserByID(ctx *gin.Context) { id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) return } user, err := c.userService.GetUserByID(uint(id)) if err != nil { utils.ErrorResponse(ctx, http.StatusNotFound, "User not found", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "User retrieved", user.ToResponse()) } // UpdateUserRole updates user role (admin only) // PATCH /api/admin/users/:id/role func (c *UserController) UpdateUserRole(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) return } var req struct { RoleID uint `json:"role_id" binding:"required"` } if err := ctx.ShouldBindJSON(&req); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() if err := c.userService.UpdateUserRole(admin.ID, uint(userID), req.RoleID, ipAddress, userAgent); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "User role updated", nil) } // BlockUser blocks a user (admin only) // POST /api/admin/users/:id/block func (c *UserController) BlockUser(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() if err := c.userService.BlockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to block user", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "User blocked", nil) } // UnblockUser unblocks a user (admin only) // POST /api/admin/users/:id/unblock func (c *UserController) UnblockUser(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() if err := c.userService.UnblockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to unblock user", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "User unblocked", nil) } // DeleteUser deletes a user (admin only) // DELETE /api/admin/users/:id func (c *UserController) DeleteUser(ctx *gin.Context) { adminObj, _ := ctx.Get("user") admin := adminObj.(*models.User) userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) if err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) return } ipAddress := ctx.ClientIP() userAgent := ctx.Request.UserAgent() if err := c.userService.DeleteUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil { utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete user", err.Error()) return } utils.SuccessResponse(ctx, http.StatusOK, "User deleted", nil) } // internal/middleware/cors.go package middleware import ( "github.com/gin-gonic/gin" ) // CORSMiddleware handles CORS func CORSMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*") ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true") ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") if ctx.Request.Method == "OPTIONS" { ctx.AbortWithStatus(204) return } ctx.Next() } } // internal/middleware/jwt_middleware.go package middleware import ( "lost-and-found/internal/config" "lost-and-found/internal/repositories" "lost-and-found/internal/utils" "net/http" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // JWTMiddleware validates JWT token func JWTMiddleware(db *gorm.DB) gin.HandlerFunc { return func(ctx *gin.Context) { // Get token from Authorization header authHeader := ctx.GetHeader("Authorization") if authHeader == "" { utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authorization header required", "") ctx.Abort() return } // Check if Bearer token parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid authorization format", "") ctx.Abort() return } tokenString := parts[1] // Validate token claims, err := config.ValidateToken(tokenString) if err != nil { utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid or expired token", err.Error()) ctx.Abort() return } // Get user from database userRepo := repositories.NewUserRepository(db) user, err := userRepo.FindByID(claims.UserID) if err != nil { utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "") ctx.Abort() return } // Check if user is blocked if user.IsBlocked() { utils.ErrorResponse(ctx, http.StatusForbidden, "Account is blocked", "") ctx.Abort() return } // Set user in context ctx.Set("user", user) ctx.Set("user_id", user.ID) ctx.Set("user_role", user.Role.Name) ctx.Next() } } // OptionalJWTMiddleware validates JWT token if present (for public routes that can benefit from auth) func OptionalJWTMiddleware(db *gorm.DB) gin.HandlerFunc { return func(ctx *gin.Context) { authHeader := ctx.GetHeader("Authorization") if authHeader == "" { ctx.Next() return } parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { ctx.Next() return } tokenString := parts[1] claims, err := config.ValidateToken(tokenString) if err != nil { ctx.Next() return } userRepo := repositories.NewUserRepository(db) user, err := userRepo.FindByID(claims.UserID) if err != nil { ctx.Next() return } if !user.IsBlocked() { ctx.Set("user", user) ctx.Set("user_id", user.ID) ctx.Set("user_role", user.Role.Name) } ctx.Next() } } // internal/middleware/logger.go package middleware import ( "fmt" "time" "github.com/gin-gonic/gin" ) // LoggerMiddleware logs HTTP requests func LoggerMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { // Start timer startTime := time.Now() // Process request ctx.Next() // Calculate latency latency := time.Since(startTime) // Get request info method := ctx.Request.Method path := ctx.Request.URL.Path statusCode := ctx.Writer.Status() clientIP := ctx.ClientIP() // Get user ID if authenticated userID := "guest" if id, exists := ctx.Get("user_id"); exists { userID = fmt.Sprintf("%v", id) } // Log format fmt.Printf("[%s] %s | %3d | %13v | %15s | %s | User: %s\n", time.Now().Format("2006-01-02 15:04:05"), method, statusCode, latency, clientIP, path, userID, ) } } // internal/middleware/rate_limiter.go package middleware import ( "lost-and-found/internal/utils" "net/http" "sync" "time" "github.com/gin-gonic/gin" ) // RateLimiter stores rate limit data type RateLimiter struct { visitors map[string]*Visitor mu sync.RWMutex rate int // requests per window window time.Duration // time window } // Visitor represents a visitor's rate limit data type Visitor struct { lastSeen time.Time count int } var limiter *RateLimiter // InitRateLimiter initializes the rate limiter func InitRateLimiter(rate int, window time.Duration) { limiter = &RateLimiter{ visitors: make(map[string]*Visitor), rate: rate, window: window, } // Cleanup old visitors every minute go limiter.cleanupVisitors() } // cleanupVisitors removes old visitor entries func (rl *RateLimiter) cleanupVisitors() { for { time.Sleep(time.Minute) rl.mu.Lock() for ip, visitor := range rl.visitors { if time.Since(visitor.lastSeen) > rl.window { delete(rl.visitors, ip) } } rl.mu.Unlock() } } // getVisitor retrieves or creates a visitor func (rl *RateLimiter) getVisitor(ip string) *Visitor { rl.mu.Lock() defer rl.mu.Unlock() visitor, exists := rl.visitors[ip] if !exists { visitor = &Visitor{ lastSeen: time.Now(), count: 0, } rl.visitors[ip] = visitor } return visitor } // isAllowed checks if request is allowed func (rl *RateLimiter) isAllowed(ip string) bool { visitor := rl.getVisitor(ip) rl.mu.Lock() defer rl.mu.Unlock() // Reset count if window has passed if time.Since(visitor.lastSeen) > rl.window { visitor.count = 0 visitor.lastSeen = time.Now() } // Check if limit exceeded if visitor.count >= rl.rate { return false } visitor.count++ visitor.lastSeen = time.Now() return true } // RateLimiterMiddleware applies rate limiting func RateLimiterMiddleware() gin.HandlerFunc { // Initialize rate limiter (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() } } // internal/middleware/role_middleware.go package middleware import ( "lost-and-found/internal/models" "lost-and-found/internal/utils" "net/http" "github.com/gin-gonic/gin" ) // 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) } // internal/models/archive.go package models import ( "time" "gorm.io/gorm" ) // Archive represents an archived item type Archive struct { ID uint `gorm:"primaryKey" json:"id"` ItemID uint `gorm:"not null;uniqueIndex" json:"item_id"` // 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(), } } // internal/models/audit_log.go package models import ( "time" "gorm.io/gorm" ) // AuditLog represents an audit log entry type AuditLog struct { ID uint `gorm:"primaryKey" json:"id"` UserID *uint `json:"user_id"` // Nullable for system actions User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` Action string `gorm:"type:varchar(50);not null" json:"action"` // create, update, delete, verify, login, etc. EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, user, etc. EntityID *uint `json:"entity_id"` Details string `gorm:"type:text" json:"details"` IPAddress string `gorm:"type:varchar(50)" json:"ip_address"` UserAgent string `gorm:"type:varchar(255)" json:"user_agent"` CreatedAt time.Time `json:"created_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // TableName specifies the table name for AuditLog model func (AuditLog) TableName() string { return "audit_logs" } // Action constants const ( ActionCreate = "create" ActionUpdate = "update" ActionDelete = "delete" ActionVerify = "verify" ActionLogin = "login" ActionLogout = "logout" ActionBlock = "block" ActionUnblock = "unblock" ActionApprove = "approve" ActionReject = "reject" ActionExport = "export" ) // Entity type constants const ( EntityItem = "item" EntityLostItem = "lost_item" EntityClaim = "claim" EntityUser = "user" EntityCategory = "category" EntityArchive = "archive" ) // AuditLogResponse represents audit log data for API responses type AuditLogResponse struct { ID uint `json:"id"` UserName string `json:"user_name,omitempty"` Action string `json:"action"` EntityType string `json:"entity_type"` EntityID *uint `json:"entity_id"` Details string `json:"details"` IPAddress string `json:"ip_address"` CreatedAt time.Time `json:"created_at"` } // ToResponse converts AuditLog to AuditLogResponse func (a *AuditLog) ToResponse() AuditLogResponse { userName := "System" if a.User != nil && a.User.ID != 0 { userName = a.User.Name } return AuditLogResponse{ ID: a.ID, UserName: userName, Action: a.Action, EntityType: a.EntityType, EntityID: a.EntityID, Details: a.Details, IPAddress: a.IPAddress, CreatedAt: a.CreatedAt, } } // CreateAuditLog creates a new audit log entry func CreateAuditLog(db *gorm.DB, userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error { log := &AuditLog{ UserID: userID, Action: action, EntityType: entityType, EntityID: entityID, Details: details, IPAddress: ipAddress, UserAgent: userAgent, } return db.Create(log).Error } // internal/models/category.go package models import ( "time" "gorm.io/gorm" ) // Category represents an item category type Category struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"type:varchar(100);not null" json:"name"` Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"` Description string `gorm:"type:text" json:"description"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // Relationships Items []Item `gorm:"foreignKey:CategoryID" json:"items,omitempty"` LostItems []LostItem `gorm:"foreignKey:CategoryID" json:"lost_items,omitempty"` } // TableName specifies the table name for Category model func (Category) TableName() string { return "categories" } // CategoryResponse represents category data for API responses type CategoryResponse struct { ID uint `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` ItemCount int64 `json:"item_count,omitempty"` CreatedAt time.Time `json:"created_at"` } // ToResponse converts Category to CategoryResponse func (c *Category) ToResponse() CategoryResponse { return CategoryResponse{ ID: c.ID, Name: c.Name, Slug: c.Slug, Description: c.Description, CreatedAt: c.CreatedAt, } } // internal/models/claim_verification.go package models import ( "time" "gorm.io/gorm" ) // ClaimVerification represents verification data for a claim type ClaimVerification struct { ID uint `gorm:"primaryKey" json:"id"` ClaimID uint `gorm:"not null;uniqueIndex" json:"claim_id"` Claim Claim `gorm:"foreignKey:ClaimID" json:"claim,omitempty"` SimilarityScore float64 `gorm:"type:decimal(5,2);default:0" json:"similarity_score"` // Percentage match (0-100) MatchedKeywords string `gorm:"type:text" json:"matched_keywords"` // Keywords that matched VerificationNotes string `gorm:"type:text" json:"verification_notes"` // Manager's notes IsAutoMatched bool `gorm:"default:false" json:"is_auto_matched"` // Was it auto-matched? CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // TableName specifies the table name for ClaimVerification model func (ClaimVerification) TableName() string { return "claim_verifications" } // IsHighMatch checks if similarity score is high (>= 70%) func (cv *ClaimVerification) IsHighMatch() bool { return cv.SimilarityScore >= 70.0 } // IsMediumMatch checks if similarity score is medium (50-69%) func (cv *ClaimVerification) IsMediumMatch() bool { return cv.SimilarityScore >= 50.0 && cv.SimilarityScore < 70.0 } // IsLowMatch checks if similarity score is low (< 50%) func (cv *ClaimVerification) IsLowMatch() bool { return cv.SimilarityScore < 50.0 } // GetMatchLevel returns the match level as string func (cv *ClaimVerification) GetMatchLevel() string { if cv.IsHighMatch() { return "high" } else if cv.IsMediumMatch() { return "medium" } return "low" } // ClaimVerificationResponse represents verification data for API responses type ClaimVerificationResponse struct { ID uint `json:"id"` ClaimID uint `json:"claim_id"` SimilarityScore float64 `json:"similarity_score"` MatchLevel string `json:"match_level"` MatchedKeywords string `json:"matched_keywords"` VerificationNotes string `json:"verification_notes"` IsAutoMatched bool `json:"is_auto_matched"` CreatedAt time.Time `json:"created_at"` } // ToResponse converts ClaimVerification to ClaimVerificationResponse func (cv *ClaimVerification) ToResponse() ClaimVerificationResponse { return ClaimVerificationResponse{ ID: cv.ID, ClaimID: cv.ClaimID, SimilarityScore: cv.SimilarityScore, MatchLevel: cv.GetMatchLevel(), MatchedKeywords: cv.MatchedKeywords, VerificationNotes: cv.VerificationNotes, IsAutoMatched: cv.IsAutoMatched, CreatedAt: cv.CreatedAt, } } // internal/models/claim.go 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, } } // internal/models/item.go 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, } } // internal/models/lost_item.go 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, } } // internal/models/match_result.go package models import ( "time" "gorm.io/gorm" ) // MatchResult represents auto-matching result between lost item and found item type MatchResult struct { ID uint `gorm:"primaryKey" json:"id"` LostItemID uint `gorm:"not null" json:"lost_item_id"` LostItem LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"` ItemID uint `gorm:"not null" json:"item_id"` Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"` SimilarityScore float64 `gorm:"type:decimal(5,2)" json:"similarity_score"` // Percentage match (0-100) MatchedFields string `gorm:"type:text" json:"matched_fields"` // JSON of matched fields MatchedAt time.Time `gorm:"not null" json:"matched_at"` IsNotified bool `gorm:"default:false" json:"is_notified"` // Was user notified? CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // TableName specifies the table name for MatchResult model func (MatchResult) TableName() string { return "match_results" } // BeforeCreate hook func (mr *MatchResult) BeforeCreate(tx *gorm.DB) error { if mr.MatchedAt.IsZero() { mr.MatchedAt = time.Now() } return nil } // IsHighMatch checks if similarity score is high (>= 70%) func (mr *MatchResult) IsHighMatch() bool { return mr.SimilarityScore >= 70.0 } // IsMediumMatch checks if similarity score is medium (50-69%) func (mr *MatchResult) IsMediumMatch() bool { return mr.SimilarityScore >= 50.0 && mr.SimilarityScore < 70.0 } // IsLowMatch checks if similarity score is low (< 50%) func (mr *MatchResult) IsLowMatch() bool { return mr.SimilarityScore < 50.0 } // GetMatchLevel returns the match level as string func (mr *MatchResult) GetMatchLevel() string { if mr.IsHighMatch() { return "high" } else if mr.IsMediumMatch() { return "medium" } return "low" } // MatchResultResponse represents match result data for API responses type MatchResultResponse struct { ID uint `json:"id"` LostItemID uint `json:"lost_item_id"` LostItemName string `json:"lost_item_name"` ItemID uint `json:"item_id"` ItemName string `json:"item_name"` ItemPhotoURL string `json:"item_photo_url"` ItemLocation string `json:"item_location"` ItemDateFound time.Time `json:"item_date_found"` ItemStatus string `json:"item_status"` SimilarityScore float64 `json:"similarity_score"` MatchLevel string `json:"match_level"` MatchedFields string `json:"matched_fields"` MatchedAt time.Time `json:"matched_at"` IsNotified bool `json:"is_notified"` } // ToResponse converts MatchResult to MatchResultResponse func (mr *MatchResult) ToResponse() MatchResultResponse { lostItemName := "" if mr.LostItem.ID != 0 { lostItemName = mr.LostItem.Name } itemName := "" itemPhotoURL := "" itemLocation := "" itemDateFound := time.Time{} itemStatus := "" if mr.Item.ID != 0 { itemName = mr.Item.Name itemPhotoURL = mr.Item.PhotoURL itemLocation = mr.Item.Location itemDateFound = mr.Item.DateFound itemStatus = mr.Item.Status } return MatchResultResponse{ ID: mr.ID, LostItemID: mr.LostItemID, LostItemName: lostItemName, ItemID: mr.ItemID, ItemName: itemName, ItemPhotoURL: itemPhotoURL, ItemLocation: itemLocation, ItemDateFound: itemDateFound, ItemStatus: itemStatus, SimilarityScore: mr.SimilarityScore, MatchLevel: mr.GetMatchLevel(), MatchedFields: mr.MatchedFields, MatchedAt: mr.MatchedAt, IsNotified: mr.IsNotified, } } // ItemMatchResponse represents simplified item data for matching display type ItemMatchResponse struct { ID uint `json:"id"` Name string `json:"name"` PhotoURL string `json:"photo_url"` Location string `json:"location"` DateFound time.Time `json:"date_found"` Status string `json:"status"` Similarity float64 `json:"similarity"` } // internal/models/notification.go package models import ( "time" "gorm.io/gorm" ) // Notification represents a notification for a user type Notification struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"not null" json:"user_id"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` Type string `gorm:"type:varchar(50);not null" json:"type"` // match_found, claim_approved, claim_rejected, item_expired, etc. Title string `gorm:"type:varchar(200);not null" json:"title"` Message string `gorm:"type:text;not null" json:"message"` EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, match, etc. EntityID *uint `json:"entity_id"` IsRead bool `gorm:"default:false" json:"is_read"` ReadAt *time.Time `json:"read_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // TableName specifies the table name for Notification model func (Notification) TableName() string { return "notifications" } // Notification type constants const ( NotificationMatchFound = "match_found" NotificationClaimApproved = "claim_approved" NotificationClaimRejected = "claim_rejected" NotificationItemExpired = "item_expired" NotificationNewClaim = "new_claim" NotificationItemReturned = "item_returned" ) // MarkAsRead marks the notification as read func (n *Notification) MarkAsRead() { n.IsRead = true now := time.Now() n.ReadAt = &now } // NotificationResponse represents notification data for API responses type NotificationResponse struct { ID uint `json:"id"` Type string `json:"type"` Title string `json:"title"` Message string `json:"message"` EntityType string `json:"entity_type"` EntityID *uint `json:"entity_id"` IsRead bool `json:"is_read"` ReadAt *time.Time `json:"read_at"` CreatedAt time.Time `json:"created_at"` } // ToResponse converts Notification to NotificationResponse func (n *Notification) ToResponse() NotificationResponse { return NotificationResponse{ ID: n.ID, Type: n.Type, Title: n.Title, Message: n.Message, EntityType: n.EntityType, EntityID: n.EntityID, IsRead: n.IsRead, ReadAt: n.ReadAt, CreatedAt: n.CreatedAt, } } // CreateNotification creates a new notification func CreateNotification(db *gorm.DB, userID uint, notifType, title, message, entityType string, entityID *uint) error { notification := &Notification{ UserID: userID, Type: notifType, Title: title, Message: message, EntityType: entityType, EntityID: entityID, } return db.Create(notification).Error } // CreateMatchNotification creates a notification for match found func CreateMatchNotification(db *gorm.DB, userID uint, itemName string, matchID uint) error { return CreateNotification( db, userID, NotificationMatchFound, "Barang yang Mirip Ditemukan!", "Kami menemukan barang yang mirip dengan laporan kehilangan Anda: "+itemName, "match", &matchID, ) } // CreateClaimApprovedNotification creates a notification for approved claim func CreateClaimApprovedNotification(db *gorm.DB, userID uint, itemName string, claimID uint) error { return CreateNotification( db, userID, NotificationClaimApproved, "Klaim Disetujui!", "Klaim Anda untuk barang '"+itemName+"' telah disetujui. Silakan ambil barang di tempat yang ditentukan.", "claim", &claimID, ) } // CreateClaimRejectedNotification creates a notification for rejected claim func CreateClaimRejectedNotification(db *gorm.DB, userID uint, itemName, reason string, claimID uint) error { return CreateNotification( db, userID, NotificationClaimRejected, "Klaim Ditolak", "Klaim Anda untuk barang '"+itemName+"' ditolak. Alasan: "+reason, "claim", &claimID, ) } // internal/models/revision_log.go package models import ( "time" "gorm.io/gorm" ) // RevisionLog represents revision history for item edits type RevisionLog struct { ID uint `gorm:"primaryKey" json:"id"` ItemID uint `gorm:"not null" json:"item_id"` Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"` UserID uint `gorm:"not null" json:"user_id"` // Who made the edit User User `gorm:"foreignKey:UserID" json:"user,omitempty"` FieldName string `gorm:"type:varchar(50);not null" json:"field_name"` // Which field was edited OldValue string `gorm:"type:text" json:"old_value"` NewValue string `gorm:"type:text" json:"new_value"` Reason string `gorm:"type:text" json:"reason"` // Why was it edited CreatedAt time.Time `json:"created_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // TableName specifies the table name for RevisionLog model func (RevisionLog) TableName() string { return "revision_logs" } // RevisionLogResponse represents revision log data for API responses type RevisionLogResponse struct { ID uint `json:"id"` ItemID uint `json:"item_id"` UserName string `json:"user_name"` FieldName string `json:"field_name"` OldValue string `json:"old_value"` NewValue string `json:"new_value"` Reason string `json:"reason"` CreatedAt time.Time `json:"created_at"` } // ToResponse converts RevisionLog to RevisionLogResponse func (rl *RevisionLog) ToResponse() RevisionLogResponse { userName := "" if rl.User.ID != 0 { userName = rl.User.Name } return RevisionLogResponse{ ID: rl.ID, ItemID: rl.ItemID, UserName: userName, FieldName: rl.FieldName, OldValue: rl.OldValue, NewValue: rl.NewValue, Reason: rl.Reason, CreatedAt: rl.CreatedAt, } } // CreateRevisionLog creates a new revision log entry func CreateRevisionLog(db *gorm.DB, itemID, userID uint, fieldName, oldValue, newValue, reason string) error { log := &RevisionLog{ ItemID: itemID, UserID: userID, FieldName: fieldName, OldValue: oldValue, NewValue: newValue, Reason: reason, } return db.Create(log).Error } // internal/models/role.go package models import ( "time" "gorm.io/gorm" ) // Role represents a user role in the system type Role struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"` Description string `gorm:"type:text" json:"description"` 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 } // internal/models/user.go 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, } } // internal/repositories/archive_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type ArchiveRepository struct { db *gorm.DB } func NewArchiveRepository(db *gorm.DB) *ArchiveRepository { return &ArchiveRepository{db: db} } // Create creates a new archive record func (r *ArchiveRepository) Create(archive *models.Archive) error { return r.db.Create(archive).Error } // FindByID finds archive by ID func (r *ArchiveRepository) FindByID(id uint) (*models.Archive, error) { var archive models.Archive err := r.db.Preload("Category").Preload("Claimer").Preload("Claimer.Role").First(&archive, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("archive not found") } return nil, err } return &archive, nil } // FindAll returns all archived items with filters func (r *ArchiveRepository) FindAll(page, limit int, reason, search string) ([]models.Archive, int64, error) { var archives []models.Archive var total int64 query := r.db.Model(&models.Archive{}) // Apply filters if reason != "" { query = query.Where("archived_reason = ?", reason) } if search != "" { 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 } // internal/repositories/audit_log_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type AuditLogRepository struct { db *gorm.DB } func NewAuditLogRepository(db *gorm.DB) *AuditLogRepository { return &AuditLogRepository{db: db} } // Create creates a new audit log func (r *AuditLogRepository) Create(log *models.AuditLog) error { return r.db.Create(log).Error } // FindByID finds audit log by ID func (r *AuditLogRepository) FindByID(id uint) (*models.AuditLog, error) { var log models.AuditLog err := r.db.Preload("User").Preload("User.Role").First(&log, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("audit log not found") } return nil, err } return &log, nil } // FindAll returns all audit logs with filters func (r *AuditLogRepository) FindAll(page, limit int, action, entityType string, userID *uint) ([]models.AuditLog, int64, error) { var logs []models.AuditLog var total int64 query := r.db.Model(&models.AuditLog{}) // Apply filters if action != "" { query = query.Where("action = ?", action) } if entityType != "" { query = query.Where("entity_type = ?", entityType) } if userID != nil { query = query.Where("user_id = ?", *userID) } // Count total if err := query.Count(&total).Error; err != nil { return nil, 0, err } // Get paginated results offset := (page - 1) * limit err := query.Preload("User").Preload("User.Role"). Order("created_at DESC"). Offset(offset).Limit(limit).Find(&logs).Error if err != nil { return nil, 0, err } return logs, total, nil } // FindByUser finds audit logs by user func (r *AuditLogRepository) FindByUser(userID uint, page, limit int) ([]models.AuditLog, int64, error) { return r.FindAll(page, limit, "", "", &userID) } // FindByEntity finds audit logs by entity func (r *AuditLogRepository) FindByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLog, int64, error) { var logs []models.AuditLog var total int64 query := r.db.Model(&models.AuditLog{}). Where("entity_type = ? AND entity_id = ?", entityType, entityID) // Count total if err := query.Count(&total).Error; err != nil { return nil, 0, err } // Get paginated results offset := (page - 1) * limit err := query.Preload("User").Preload("User.Role"). Order("created_at DESC"). Offset(offset).Limit(limit).Find(&logs).Error if err != nil { return nil, 0, err } return logs, total, nil } // Log creates a new audit log entry (helper method) func (r *AuditLogRepository) Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error { return models.CreateAuditLog(r.db, userID, action, entityType, entityID, details, ipAddress, userAgent) } // internal/repositories/category_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type CategoryRepository struct { db *gorm.DB } func NewCategoryRepository(db *gorm.DB) *CategoryRepository { return &CategoryRepository{db: db} } // Create creates a new category func (r *CategoryRepository) Create(category *models.Category) error { return r.db.Create(category).Error } // FindByID finds category by ID func (r *CategoryRepository) FindByID(id uint) (*models.Category, error) { var category models.Category err := r.db.First(&category, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("category not found") } return nil, err } return &category, nil } // FindBySlug finds category by slug func (r *CategoryRepository) FindBySlug(slug string) (*models.Category, error) { var category models.Category err := r.db.Where("slug = ?", slug).First(&category).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("category not found") } return nil, err } return &category, nil } // FindAll returns all categories func (r *CategoryRepository) FindAll() ([]models.Category, error) { var categories []models.Category err := r.db.Order("name ASC").Find(&categories).Error return categories, err } // Update updates category data func (r *CategoryRepository) Update(category *models.Category) error { return r.db.Save(category).Error } // Delete soft deletes a category func (r *CategoryRepository) Delete(id uint) error { return r.db.Delete(&models.Category{}, id).Error } // GetCategoryWithItemCount gets category with item count func (r *CategoryRepository) GetCategoryWithItemCount(id uint) (*models.Category, int64, error) { category, err := r.FindByID(id) if err != nil { return nil, 0, err } var count int64 if err := r.db.Model(&models.Item{}).Where("category_id = ?", id).Count(&count).Error; err != nil { return nil, 0, err } return category, count, nil } // GetAllWithItemCount gets all categories with item counts func (r *CategoryRepository) GetAllWithItemCount() ([]models.CategoryResponse, error) { var categories []models.Category if err := r.db.Order("name ASC").Find(&categories).Error; err != nil { return nil, err } var responses []models.CategoryResponse for _, cat := range categories { var count int64 if err := r.db.Model(&models.Item{}).Where("category_id = ?", cat.ID).Count(&count).Error; err != nil { return nil, err } response := cat.ToResponse() response.ItemCount = count responses = append(responses, response) } return responses, nil } // internal/repositories/claim_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type ClaimRepository struct { db *gorm.DB } func NewClaimRepository(db *gorm.DB) *ClaimRepository { return &ClaimRepository{db: db} } // Create creates a new claim func (r *ClaimRepository) Create(claim *models.Claim) error { return r.db.Create(claim).Error } // FindByID finds claim by ID func (r *ClaimRepository) FindByID(id uint) (*models.Claim, error) { var claim models.Claim err := r.db.Preload("Item").Preload("Item.Category"). Preload("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) } // internal/repositories/claim_verification_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type ClaimVerificationRepository struct { db *gorm.DB } func NewClaimVerificationRepository(db *gorm.DB) *ClaimVerificationRepository { return &ClaimVerificationRepository{db: db} } // Create creates a new claim verification func (r *ClaimVerificationRepository) Create(verification *models.ClaimVerification) error { return r.db.Create(verification).Error } // FindByID finds claim verification by ID func (r *ClaimVerificationRepository) FindByID(id uint) (*models.ClaimVerification, error) { var verification models.ClaimVerification err := r.db.Preload("Claim").First(&verification, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("claim verification not found") } return nil, err } return &verification, nil } // FindByClaimID finds claim verification by claim ID func (r *ClaimVerificationRepository) FindByClaimID(claimID uint) (*models.ClaimVerification, error) { var verification models.ClaimVerification err := r.db.Where("claim_id = ?", claimID).Preload("Claim").First(&verification).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil // Return nil if not found (not an error) } return nil, err } return &verification, nil } // Update updates claim verification func (r *ClaimVerificationRepository) Update(verification *models.ClaimVerification) error { return r.db.Save(verification).Error } // Delete deletes a claim verification func (r *ClaimVerificationRepository) Delete(id uint) error { return r.db.Delete(&models.ClaimVerification{}, id).Error } // FindHighMatches finds high match verifications (>= 70%) func (r *ClaimVerificationRepository) FindHighMatches() ([]models.ClaimVerification, error) { var verifications []models.ClaimVerification err := r.db.Where("similarity_score >= ?", 70.0). Preload("Claim").Preload("Claim.Item").Preload("Claim.User"). Order("similarity_score DESC").Find(&verifications).Error return verifications, err } // internal/repositories/item_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "time" "gorm.io/gorm" ) type ItemRepository struct { db *gorm.DB } func NewItemRepository(db *gorm.DB) *ItemRepository { return &ItemRepository{db: db} } // Create creates a new item func (r *ItemRepository) Create(item *models.Item) error { return r.db.Create(item).Error } // FindByID finds item by ID func (r *ItemRepository) FindByID(id uint) (*models.Item, error) { var item models.Item err := r.db.Preload("Category").Preload("Reporter").Preload("Reporter.Role").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 } // internal/repositories/lost_item_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type LostItemRepository struct { db *gorm.DB } func NewLostItemRepository(db *gorm.DB) *LostItemRepository { return &LostItemRepository{db: db} } // Create creates a new lost item report func (r *LostItemRepository) Create(lostItem *models.LostItem) error { return r.db.Create(lostItem).Error } // FindByID finds lost item by ID func (r *LostItemRepository) FindByID(id uint) (*models.LostItem, error) { var lostItem models.LostItem err := r.db.Preload("Category").Preload("User").Preload("User.Role").First(&lostItem, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("lost item not found") } return nil, err } return &lostItem, nil } // 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 } // internal/repositories/match_result_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type MatchResultRepository struct { db *gorm.DB } func NewMatchResultRepository(db *gorm.DB) *MatchResultRepository { return &MatchResultRepository{db: db} } // Create creates a new match result func (r *MatchResultRepository) Create(match *models.MatchResult) error { return r.db.Create(match).Error } // FindByID finds match result by ID func (r *MatchResultRepository) FindByID(id uint) (*models.MatchResult, error) { var match models.MatchResult err := r.db.Preload("LostItem").Preload("LostItem.User"). Preload("Item").Preload("Item.Category"). First(&match, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("match result not found") } return nil, err } return &match, nil } // FindAll returns all match results with filters func (r *MatchResultRepository) FindAll(page, limit int, lostItemID, itemID *uint) ([]models.MatchResult, int64, error) { var matches []models.MatchResult var total int64 query := r.db.Model(&models.MatchResult{}) // Apply filters if lostItemID != nil { query = query.Where("lost_item_id = ?", *lostItemID) } if itemID != nil { query = query.Where("item_id = ?", *itemID) } // Count total if err := query.Count(&total).Error; err != nil { return nil, 0, err } // Get paginated results offset := (page - 1) * limit err := query.Preload("LostItem").Preload("LostItem.User"). Preload("Item").Preload("Item.Category"). Order("similarity_score DESC"). Offset(offset).Limit(limit).Find(&matches).Error if err != nil { return nil, 0, err } return matches, total, nil } // FindByLostItem finds match results for a lost item func (r *MatchResultRepository) FindByLostItem(lostItemID uint) ([]models.MatchResult, error) { var matches []models.MatchResult err := r.db.Where("lost_item_id = ?", lostItemID). Preload("Item").Preload("Item.Category"). Order("similarity_score DESC").Find(&matches).Error return matches, err } // FindByItem finds match results for an item func (r *MatchResultRepository) FindByItem(itemID uint) ([]models.MatchResult, error) { var matches []models.MatchResult err := r.db.Where("item_id = ?", itemID). Preload("LostItem").Preload("LostItem.User"). Order("similarity_score DESC").Find(&matches).Error return matches, err } // Update updates match result func (r *MatchResultRepository) Update(match *models.MatchResult) error { return r.db.Save(match).Error } // MarkAsNotified marks match result as notified func (r *MatchResultRepository) MarkAsNotified(id uint) error { return r.db.Model(&models.MatchResult{}).Where("id = ?", id).Update("is_notified", true).Error } // FindUnnotifiedMatches finds match results that haven't been notified func (r *MatchResultRepository) FindUnnotifiedMatches() ([]models.MatchResult, error) { var matches []models.MatchResult err := r.db.Where("is_notified = ?", false). Preload("LostItem").Preload("LostItem.User"). Preload("Item").Preload("Item.Category"). Order("matched_at ASC").Find(&matches).Error return matches, err } // Delete deletes a match result func (r *MatchResultRepository) Delete(id uint) error { return r.db.Delete(&models.MatchResult{}, id).Error } // CheckExistingMatch checks if a match already exists func (r *MatchResultRepository) CheckExistingMatch(lostItemID, itemID uint) (bool, error) { var count int64 err := r.db.Model(&models.MatchResult{}). Where("lost_item_id = ? AND item_id = ?", lostItemID, itemID). Count(&count).Error if err != nil { return false, err } return count > 0, nil } // internal/repositories/notification_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type NotificationRepository struct { db *gorm.DB } func NewNotificationRepository(db *gorm.DB) *NotificationRepository { return &NotificationRepository{db: db} } // Create creates a new notification func (r *NotificationRepository) Create(notification *models.Notification) error { return r.db.Create(notification).Error } // FindByID finds notification by ID func (r *NotificationRepository) FindByID(id uint) (*models.Notification, error) { var notification models.Notification err := r.db.Preload("User").First(¬ification, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("notification not found") } return nil, err } return ¬ification, nil } // FindByUser finds notifications for a user func (r *NotificationRepository) FindByUser(userID uint, page, limit int, onlyUnread bool) ([]models.Notification, int64, error) { var notifications []models.Notification var total int64 query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID) // Filter unread if specified if onlyUnread { query = query.Where("is_read = ?", false) } // Count total if err := query.Count(&total).Error; err != nil { return nil, 0, err } // Get paginated results offset := (page - 1) * limit err := query.Order("created_at DESC"). Offset(offset).Limit(limit).Find(¬ifications).Error if err != nil { return nil, 0, err } return notifications, total, nil } // MarkAsRead marks a notification as read func (r *NotificationRepository) MarkAsRead(id uint) error { notification, err := r.FindByID(id) if err != nil { return err } notification.MarkAsRead() return r.db.Save(notification).Error } // MarkAllAsRead marks all notifications for a user as read func (r *NotificationRepository) MarkAllAsRead(userID uint) error { return r.db.Model(&models.Notification{}). Where("user_id = ? AND is_read = ?", userID, false). Update("is_read", true).Error } // Delete deletes a notification func (r *NotificationRepository) Delete(id uint) error { return r.db.Delete(&models.Notification{}, id).Error } // DeleteAllForUser deletes all notifications for a user func (r *NotificationRepository) DeleteAllForUser(userID uint) error { return r.db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error } // CountUnread counts unread notifications for a user func (r *NotificationRepository) CountUnread(userID uint) (int64, error) { var count int64 err := r.db.Model(&models.Notification{}). Where("user_id = ? AND is_read = ?", userID, false). Count(&count).Error return count, err } // Notify creates a notification (helper method) func (r *NotificationRepository) Notify(userID uint, notifType, title, message, entityType string, entityID *uint) error { return models.CreateNotification(r.db, userID, notifType, title, message, entityType, entityID) } // internal/repositories/revision_log_repo.go package repositories import ( "errors" "lost-and-found/internal/models" "gorm.io/gorm" ) type RevisionLogRepository struct { db *gorm.DB } func NewRevisionLogRepository(db *gorm.DB) *RevisionLogRepository { return &RevisionLogRepository{db: db} } // Create creates a new revision log func (r *RevisionLogRepository) Create(log *models.RevisionLog) error { return r.db.Create(log).Error } // FindByID finds revision log by ID func (r *RevisionLogRepository) FindByID(id uint) (*models.RevisionLog, error) { var log models.RevisionLog err := r.db.Preload("Item").Preload("User").Preload("User.Role").First(&log, id).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("revision log not found") } return nil, err } return &log, nil } // FindByItem finds revision logs for an item func (r *RevisionLogRepository) FindByItem(itemID uint, page, limit int) ([]models.RevisionLog, int64, error) { var logs []models.RevisionLog var total int64 query := r.db.Model(&models.RevisionLog{}).Where("item_id = ?", itemID) // Count total if err := query.Count(&total).Error; err != nil { return nil, 0, err } // Get paginated results offset := (page - 1) * limit err := query.Preload("User").Preload("User.Role"). Order("created_at DESC"). Offset(offset).Limit(limit).Find(&logs).Error if err != nil { return nil, 0, err } return logs, total, nil } // FindAll returns all revision logs with filters func (r *RevisionLogRepository) FindAll(page, limit int, userID *uint) ([]models.RevisionLog, int64, error) { var logs []models.RevisionLog var total int64 query := r.db.Model(&models.RevisionLog{}) // Apply filters if userID != nil { query = query.Where("user_id = ?", *userID) } // Count total if err := query.Count(&total).Error; err != nil { return nil, 0, err } // Get paginated results offset := (page - 1) * limit err := query.Preload("Item").Preload("User").Preload("User.Role"). Order("created_at DESC"). Offset(offset).Limit(limit).Find(&logs).Error if err != nil { return nil, 0, err } return logs, total, nil } // Log creates a new revision log entry (helper method) func (r *RevisionLogRepository) Log(itemID, userID uint, fieldName, oldValue, newValue, reason string) error { return models.CreateRevisionLog(r.db, itemID, userID, fieldName, oldValue, newValue, reason) } // internal/repositories/role_repo.go 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 } // internal/repositories/user_repo.go 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 } // internal/routes/routes.go 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) } } } // internal/services/archive_service.go package services import ( "lost-and-found/internal/models" "lost-and-found/internal/repositories" "gorm.io/gorm" ) type ArchiveService struct { archiveRepo *repositories.ArchiveRepository auditLogRepo *repositories.AuditLogRepository } func NewArchiveService(db *gorm.DB) *ArchiveService { return &ArchiveService{ archiveRepo: repositories.NewArchiveRepository(db), auditLogRepo: repositories.NewAuditLogRepository(db), } } // GetAllArchives gets all archived items func (s *ArchiveService) GetAllArchives(page, limit int, reason, search string) ([]models.ArchiveResponse, int64, error) { archives, total, err := s.archiveRepo.FindAll(page, limit, reason, search) if err != nil { return nil, 0, err } var responses []models.ArchiveResponse for _, archive := range archives { responses = append(responses, archive.ToResponse()) } return responses, total, nil } // GetArchiveByID gets archive by ID func (s *ArchiveService) GetArchiveByID(id uint) (*models.Archive, error) { return s.archiveRepo.FindByID(id) } // GetArchiveByItemID gets archive by original item ID func (s *ArchiveService) GetArchiveByItemID(itemID uint) (*models.Archive, error) { return s.archiveRepo.FindByItemID(itemID) } // GetArchiveStats gets archive statistics func (s *ArchiveService) GetArchiveStats() (map[string]interface{}, error) { stats := make(map[string]interface{}) // Count by reason expiredCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonExpired) if err != nil { return nil, err } stats["expired"] = expiredCount caseClosedCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonCaseClosed) if err != nil { return nil, err } stats["case_closed"] = caseClosedCount stats["total"] = expiredCount + caseClosedCount return stats, nil } // internal/services/audit_service.go package services import ( "lost-and-found/internal/models" "lost-and-found/internal/repositories" "gorm.io/gorm" ) type AuditService struct { auditLogRepo *repositories.AuditLogRepository } func NewAuditService(db *gorm.DB) *AuditService { return &AuditService{ auditLogRepo: repositories.NewAuditLogRepository(db), } } // GetAllAuditLogs gets all audit logs func (s *AuditService) GetAllAuditLogs(page, limit int, action, entityType string, userID *uint) ([]models.AuditLogResponse, int64, error) { logs, total, err := s.auditLogRepo.FindAll(page, limit, action, entityType, userID) if err != nil { return nil, 0, err } var responses []models.AuditLogResponse for _, log := range logs { responses = append(responses, log.ToResponse()) } return responses, total, nil } // GetAuditLogsByUser gets audit logs by user func (s *AuditService) GetAuditLogsByUser(userID uint, page, limit int) ([]models.AuditLogResponse, int64, error) { logs, total, err := s.auditLogRepo.FindByUser(userID, page, limit) if err != nil { return nil, 0, err } var responses []models.AuditLogResponse for _, log := range logs { responses = append(responses, log.ToResponse()) } return responses, total, nil } // GetAuditLogsByEntity gets audit logs by entity func (s *AuditService) GetAuditLogsByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLogResponse, int64, error) { logs, total, err := s.auditLogRepo.FindByEntity(entityType, entityID, page, limit) if err != nil { return nil, 0, err } var responses []models.AuditLogResponse for _, log := range logs { responses = append(responses, log.ToResponse()) } return responses, total, nil } // LogAction creates a new audit log entry func (s *AuditService) LogAction(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error { return s.auditLogRepo.Log(userID, action, entityType, entityID, details, ipAddress, userAgent) } // internal/services/auth_service.go package services import ( "errors" "log" "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 } func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) { // DEBUG: Print request info log.Printf("šŸ” [LOGIN] Attempting login for email: %s", req.Email) log.Printf("šŸ” [LOGIN] Password length: %d characters", len(req.Password)) // Find user by email user, err := s.userRepo.FindByEmail(req.Email) if err != nil { log.Printf("āŒ [LOGIN] User not found in database: %s - Error: %v", req.Email, err) return nil, errors.New("invalid email or password") } log.Printf("āœ… [LOGIN] User found in DB - ID: %d, Name: %s, Email: %s", user.ID, user.Name, user.Email) log.Printf("šŸ” [LOGIN] User role ID: %d", user.RoleID) log.Printf("šŸ” [LOGIN] User status: %s", user.Status) log.Printf("šŸ” [LOGIN] Password hash from DB: %s", user.Password) // Check if user is blocked if user.IsBlocked() { log.Printf("āŒ [LOGIN] User is blocked: %s", user.Email) return nil, errors.New("account is blocked") } log.Printf("āœ… [LOGIN] User is active, checking password...") // Verify password log.Printf("šŸ” [LOGIN] Checking password: '%s' against hash", req.Password) passwordMatch := utils.CheckPasswordHash(req.Password, user.Password) log.Printf("šŸ” [LOGIN] Password match result: %v", passwordMatch) if !passwordMatch { log.Printf("āŒ [LOGIN] Password mismatch for user: %s", user.Email) return nil, errors.New("invalid email or password") } log.Printf("āœ… [LOGIN] Password correct! Generating token...") // Check if Role is loaded if user.Role.ID == 0 { log.Printf("āš ļø [LOGIN] Role not loaded! RoleID: %d, Role.Name: %s", user.RoleID, user.Role.Name) log.Printf("šŸ”§ [LOGIN] Attempting to load role manually...") // Reload user with role user, err = s.userRepo.FindByID(user.ID) if err != nil { log.Printf("āŒ [LOGIN] Failed to reload user with role: %v", err) return nil, errors.New("failed to load user data") } log.Printf("āœ… [LOGIN] Role reloaded - Role.ID: %d, Role.Name: %s", user.Role.ID, user.Role.Name) } else { log.Printf("āœ… [LOGIN] Role loaded - ID: %d, Name: %s", user.Role.ID, user.Role.Name) } // Generate JWT token token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name) if err != nil { log.Printf("āŒ [LOGIN] Failed to generate token: %v", err) return nil, errors.New("failed to generate token") } log.Printf("āœ… [LOGIN] Token generated successfully for %s (Role: %s)", user.Email, user.Role.Name) // Log audit s.auditLogRepo.Log(&user.ID, models.ActionLogin, models.EntityUser, &user.ID, "User logged in", ipAddress, userAgent) log.Printf("āœ… [LOGIN] Audit log created") log.Printf("šŸŽ‰ [LOGIN] Login successful for %s - Returning response", user.Email) 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) } // internal/services/category_service.go package services import ( "errors" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "strings" "gorm.io/gorm" ) type CategoryService struct { categoryRepo *repositories.CategoryRepository auditLogRepo *repositories.AuditLogRepository } func NewCategoryService(db *gorm.DB) *CategoryService { return &CategoryService{ categoryRepo: repositories.NewCategoryRepository(db), auditLogRepo: repositories.NewAuditLogRepository(db), } } // CreateCategoryRequest represents category creation data type CreateCategoryRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` } // UpdateCategoryRequest represents category update data type UpdateCategoryRequest struct { Name string `json:"name"` Description string `json:"description"` } // GetAllCategories gets all categories func (s *CategoryService) GetAllCategories() ([]models.CategoryResponse, error) { return s.categoryRepo.GetAllWithItemCount() } // GetCategoryByID gets category by ID func (s *CategoryService) GetCategoryByID(id uint) (*models.Category, error) { return s.categoryRepo.FindByID(id) } // GetCategoryBySlug gets category by slug func (s *CategoryService) GetCategoryBySlug(slug string) (*models.Category, error) { return s.categoryRepo.FindBySlug(slug) } // CreateCategory creates a new category (admin only) func (s *CategoryService) CreateCategory(adminID uint, req CreateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) { // Generate slug from name slug := s.generateSlug(req.Name) // Check if slug already exists existing, _ := s.categoryRepo.FindBySlug(slug) if existing != nil { return nil, errors.New("category with similar name already exists") } category := &models.Category{ Name: req.Name, Slug: slug, Description: req.Description, } if err := s.categoryRepo.Create(category); err != nil { return nil, errors.New("failed to create category") } // Log audit s.auditLogRepo.Log(&adminID, models.ActionCreate, models.EntityCategory, &category.ID, "Category created: "+category.Name, ipAddress, userAgent) return category, nil } // UpdateCategory updates a category (admin only) func (s *CategoryService) UpdateCategory(adminID, categoryID uint, req UpdateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) { category, err := s.categoryRepo.FindByID(categoryID) if err != nil { return nil, err } // Update fields if req.Name != "" { category.Name = req.Name category.Slug = s.generateSlug(req.Name) } if req.Description != "" { category.Description = req.Description } if err := s.categoryRepo.Update(category); err != nil { return nil, errors.New("failed to update category") } // Log audit s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityCategory, &categoryID, "Category updated: "+category.Name, ipAddress, userAgent) return category, nil } // DeleteCategory deletes a category (admin only) func (s *CategoryService) DeleteCategory(adminID, categoryID uint, ipAddress, userAgent string) error { category, err := s.categoryRepo.FindByID(categoryID) if err != nil { return err } // Check if category has items _, count, err := s.categoryRepo.GetCategoryWithItemCount(categoryID) if err != nil { return err } if count > 0 { return errors.New("cannot delete category with existing items") } if err := s.categoryRepo.Delete(categoryID); err != nil { return errors.New("failed to delete category") } // Log audit s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityCategory, &categoryID, "Category deleted: "+category.Name, ipAddress, userAgent) return nil } // generateSlug generates URL-friendly slug from name func (s *CategoryService) generateSlug(name string) string { slug := strings.ToLower(name) slug = strings.ReplaceAll(slug, " ", "_") slug = strings.ReplaceAll(slug, "/", "_") // Remove special characters validChars := "abcdefghijklmnopqrstuvwxyz0123456789_" result := "" for _, char := range slug { if strings.ContainsRune(validChars, char) { result += string(char) } } return result } // internal/services/claim_service.go 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 } // internal/services/export_service.go package services import ( "bytes" "fmt" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "lost-and-found/internal/utils" "time" "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") } // internal/services/item_service.go 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 } // internal/services/lost_item_service.go 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 } // internal/services/match_service.go package services import ( "encoding/json" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "lost-and-found/internal/utils" "gorm.io/gorm" ) type MatchService struct { db *gorm.DB // Tambahkan ini matchRepo *repositories.MatchResultRepository itemRepo *repositories.ItemRepository lostItemRepo *repositories.LostItemRepository notificationRepo *repositories.NotificationRepository } func NewMatchService(db *gorm.DB) *MatchService { return &MatchService{ db: db, // Tambahkan ini matchRepo: repositories.NewMatchResultRepository(db), itemRepo: repositories.NewItemRepository(db), lostItemRepo: repositories.NewLostItemRepository(db), notificationRepo: repositories.NewNotificationRepository(db), } } // MatchedField represents a matched field between items type MatchedField struct { Field string `json:"field"` LostValue string `json:"lost_value"` FoundValue string `json:"found_value"` Score float64 `json:"score"` } // FindSimilarItems finds similar items for a lost item report func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) { lostItem, err := s.lostItemRepo.FindByID(lostItemID) if err != nil { return nil, err } // Search for items in same category items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color) if err != nil { return nil, err } var results []models.MatchResultResponse for _, item := range items { // Calculate similarity score, matchedFields := s.calculateSimilarity(lostItem, &item) // Only include if score is reasonable (>= 30%) if score >= 30.0 { // Check if match already exists exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID) if !exists { // Create match result matchedFieldsJSON, _ := json.Marshal(matchedFields) match := &models.MatchResult{ LostItemID: lostItemID, ItemID: item.ID, SimilarityScore: score, MatchedFields: string(matchedFieldsJSON), IsNotified: false, } s.matchRepo.Create(match) // Reload with relations match, _ = s.matchRepo.FindByID(match.ID) results = append(results, match.ToResponse()) } } } return results, nil } // GetMatchesForLostItem gets all matches for a lost item func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) { matches, err := s.matchRepo.FindByLostItem(lostItemID) if err != nil { return nil, err } var responses []models.MatchResultResponse for _, match := range matches { responses = append(responses, match.ToResponse()) } return responses, nil } // GetMatchesForItem gets all matches for an item func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) { matches, err := s.matchRepo.FindByItem(itemID) if err != nil { return nil, err } var responses []models.MatchResultResponse for _, match := range matches { responses = append(responses, match.ToResponse()) } return responses, nil } // AutoMatchNewItem automatically matches a new item with lost items func (s *MatchService) AutoMatchNewItem(itemID uint) error { item, err := s.itemRepo.FindByID(itemID) if err != nil { return err } // Find active lost items in same category lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID) if err != nil { return err } for _, lostItem := range lostItems { // Calculate similarity score, matchedFields := s.calculateSimilarity(&lostItem, item) // Create match if score is high enough (>= 50% for auto-match) if score >= 50.0 { // Check if match already exists exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID) if !exists { matchedFieldsJSON, _ := json.Marshal(matchedFields) match := &models.MatchResult{ LostItemID: lostItem.ID, ItemID: itemID, SimilarityScore: score, MatchedFields: string(matchedFieldsJSON), IsNotified: false, } s.matchRepo.Create(match) // Send notification to lost item owner - PERBAIKAN DI SINI models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID) s.matchRepo.MarkAsNotified(match.ID) } } } return nil } // calculateSimilarity calculates similarity between lost item and found item func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) { var matchedFields []MatchedField 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 } // internal/services/notification_service.go package services import ( "errors" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "gorm.io/gorm" ) type NotificationService struct { db *gorm.DB // Tambahkan ini notificationRepo *repositories.NotificationRepository } func NewNotificationService(db *gorm.DB) *NotificationService { return &NotificationService{ db: db, // Tambahkan ini notificationRepo: repositories.NewNotificationRepository(db), } } // GetUserNotifications gets notifications for a user func (s *NotificationService) GetUserNotifications(userID uint, page, limit int, onlyUnread bool) ([]models.NotificationResponse, int64, error) { notifications, total, err := s.notificationRepo.FindByUser(userID, page, limit, onlyUnread) if err != nil { return nil, 0, err } var responses []models.NotificationResponse for _, notification := range notifications { responses = append(responses, notification.ToResponse()) } return responses, total, nil } // GetNotificationByID gets notification by ID func (s *NotificationService) GetNotificationByID(userID, notificationID uint) (*models.Notification, error) { notification, err := s.notificationRepo.FindByID(notificationID) if err != nil { return nil, err } // Check ownership if notification.UserID != userID { return nil, errors.New("unauthorized") } return notification, nil } // MarkAsRead marks a notification as read func (s *NotificationService) MarkAsRead(userID, notificationID uint) error { notification, err := s.notificationRepo.FindByID(notificationID) if err != nil { return err } // Check ownership if notification.UserID != userID { return errors.New("unauthorized") } return s.notificationRepo.MarkAsRead(notificationID) } // MarkAllAsRead marks all notifications as read for a user func (s *NotificationService) MarkAllAsRead(userID uint) error { return s.notificationRepo.MarkAllAsRead(userID) } // DeleteNotification deletes a notification func (s *NotificationService) DeleteNotification(userID, notificationID uint) error { notification, err := s.notificationRepo.FindByID(notificationID) if err != nil { return err } // Check ownership if notification.UserID != userID { return errors.New("unauthorized") } return s.notificationRepo.Delete(notificationID) } // DeleteAllNotifications deletes all notifications for a user func (s *NotificationService) DeleteAllNotifications(userID uint) error { return s.notificationRepo.DeleteAllForUser(userID) } // CountUnread counts unread notifications for a user func (s *NotificationService) CountUnread(userID uint) (int64, error) { return s.notificationRepo.CountUnread(userID) } // CreateNotification creates a new notification func (s *NotificationService) CreateNotification(userID uint, notifType, title, message, entityType string, entityID *uint) error { return s.notificationRepo.Notify(userID, notifType, title, message, entityType, entityID) } // SendMatchNotification sends notification when a match is found func (s *NotificationService) SendMatchNotification(userID uint, itemName string, matchID uint) error { return models.CreateMatchNotification(s.db, userID, itemName, matchID) } // SendClaimApprovedNotification sends notification when claim is approved func (s *NotificationService) SendClaimApprovedNotification(userID uint, itemName string, claimID uint) error { return models.CreateClaimApprovedNotification(s.db, userID, itemName, claimID) } // SendClaimRejectedNotification sends notification when claim is rejected func (s *NotificationService) SendClaimRejectedNotification(userID uint, itemName, reason string, claimID uint) error { return models.CreateClaimRejectedNotification(s.db, userID, itemName, reason, claimID) } // internal/services/user_service.go package services import ( "errors" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "lost-and-found/internal/utils" "gorm.io/gorm" ) type UserService struct { 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 } // internal/services/verification_service.go package services import ( "encoding/json" "errors" "lost-and-found/internal/models" "lost-and-found/internal/repositories" "lost-and-found/internal/utils" "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) } // internal/utils/error.go package utils import "fmt" // AppError represents a custom application error type AppError struct { Code string Message string Err error } func (e *AppError) Error() string { if e.Err != nil { return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err) } return fmt.Sprintf("%s: %s", e.Code, e.Message) } // NewAppError creates a new application error func NewAppError(code, message string, err error) *AppError { return &AppError{ Code: code, Message: message, Err: err, } } // Common error codes const ( ErrCodeValidation = "VALIDATION_ERROR" ErrCodeNotFound = "NOT_FOUND" ErrCodeUnauthorized = "UNAUTHORIZED" ErrCodeForbidden = "FORBIDDEN" ErrCodeInternal = "INTERNAL_ERROR" ErrCodeDuplicate = "DUPLICATE_ERROR" ErrCodeBadRequest = "BAD_REQUEST" ) // Error constructors func ValidationError(message string) *AppError { return NewAppError(ErrCodeValidation, message, nil) } func NotFoundError(message string) *AppError { return NewAppError(ErrCodeNotFound, message, nil) } func UnauthorizedError(message string) *AppError { return NewAppError(ErrCodeUnauthorized, message, nil) } func ForbiddenError(message string) *AppError { return NewAppError(ErrCodeForbidden, message, nil) } func InternalError(message string, err error) *AppError { return NewAppError(ErrCodeInternal, message, err) } func DuplicateError(message string) *AppError { return NewAppError(ErrCodeDuplicate, message, nil) } func BadRequestError(message string) *AppError { return NewAppError(ErrCodeBadRequest, message, nil) } // internal/utils/excel_export.go package utils import ( "bytes" "fmt" "github.com/xuri/excelize/v2" ) // ExcelExporter handles Excel file generation type ExcelExporter struct { file *excelize.File sheetName string rowIndex int } // NewExcelExporter creates a new Excel exporter func NewExcelExporter() *ExcelExporter { f := excelize.NewFile() return &ExcelExporter{ file: f, sheetName: "Sheet1", rowIndex: 1, } } // SetSheetName sets the active sheet name func (e *ExcelExporter) SetSheetName(name string) { e.file.SetSheetName("Sheet1", name) e.sheetName = name } // AddRow adds a row of data func (e *ExcelExporter) AddRow(data []string) error { for colIndex, value := range data { cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex) if err := e.file.SetCellValue(e.sheetName, cell, value); err != nil { return err } } e.rowIndex++ return nil } // AddHeader adds a header row with bold style func (e *ExcelExporter) AddHeader(headers []string) error { style, err := e.file.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, }, Fill: excelize.Fill{ Type: "pattern", Color: []string{"#D3D3D3"}, Pattern: 1, }, }) if err != nil { return err } for colIndex, header := range headers { cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex) if err := e.file.SetCellValue(e.sheetName, cell, header); err != nil { return err } if err := e.file.SetCellStyle(e.sheetName, cell, cell, style); err != nil { return err } } e.rowIndex++ return nil } // AutoSizeColumns auto-sizes columns func (e *ExcelExporter) AutoSizeColumns(columnCount int) { for i := 0; i < columnCount; i++ { col := getColumnName(i) e.file.SetColWidth(e.sheetName, col, col, 15) } } // Output returns the Excel file as bytes func (e *ExcelExporter) Output() (*bytes.Buffer, error) { buf := new(bytes.Buffer) if err := e.file.Write(buf); err != nil { return nil, err } return buf, nil } // getColumnName converts column index to Excel column name (A, B, C, ..., AA, AB, ...) func getColumnName(index int) string { name := "" for index >= 0 { name = string(rune('A'+(index%26))) + name index = index/26 - 1 } return name } // internal/utils/hash.go package utils import ( "golang.org/x/crypto/bcrypt" ) // HashPassword hashes a password using bcrypt func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) return string(bytes), err } // CheckPasswordHash compares a password with a hash func CheckPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } // internal/utils/image_handler.go 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) } // internal/utils/matching.go 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" } // internal/utils/pdf_export.go package utils import ( "bytes" "fmt" "github.com/jung-kurt/gofpdf" ) // PDFExporter handles PDF generation type PDFExporter struct { pdf *gofpdf.Fpdf } // NewPDFExporter creates a new PDF exporter func NewPDFExporter() *PDFExporter { pdf := gofpdf.New("P", "mm", "A4", "") pdf.AddPage() 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 } // internal/utils/response.go package utils import ( "github.com/gin-gonic/gin" ) // Response represents a standard API response type Response struct { Success bool `json:"success"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` Error string `json:"error,omitempty"` } // PaginatedResponse represents a paginated API response type PaginatedResponse struct { Success bool `json:"success"` Message string `json:"message"` Data interface{} `json:"data"` Pagination Pagination `json:"pagination"` } // Pagination represents pagination metadata type Pagination struct { CurrentPage int `json:"current_page"` PerPage int `json:"per_page"` TotalPages int `json:"total_pages"` TotalRecords int64 `json:"total_records"` } // SuccessResponse sends a success response func SuccessResponse(ctx *gin.Context, statusCode int, message string, data interface{}) { ctx.JSON(statusCode, Response{ Success: true, Message: message, Data: data, }) } // ErrorResponse sends an error response func ErrorResponse(ctx *gin.Context, statusCode int, message string, error string) { ctx.JSON(statusCode, Response{ Success: false, Message: message, Error: error, }) } // SendPaginatedResponse sends a paginated response (nama fungsi diubah untuk menghindari konflik) func SendPaginatedResponse(ctx *gin.Context, statusCode int, message string, data interface{}, total int64, page, limit int) { totalPages := int(total) / limit if int(total)%limit != 0 { totalPages++ } ctx.JSON(statusCode, PaginatedResponse{ Success: true, Message: message, Data: data, Pagination: Pagination{ CurrentPage: page, PerPage: limit, TotalPages: totalPages, TotalRecords: total, }, }) } // internal/utils/similarity.go package utils import ( "math" "strings" "unicode" ) // CalculateStringSimilarity calculates similarity between two strings using Levenshtein distance func CalculateStringSimilarity(s1, s2 string) float64 { // Normalize strings s1 = normalizeString(s1) s2 = normalizeString(s2) if s1 == s2 { return 1.0 } if len(s1) == 0 || len(s2) == 0 { return 0.0 } // Calculate Levenshtein distance distance := levenshteinDistance(s1, s2) maxLen := math.Max(float64(len(s1)), float64(len(s2))) similarity := 1.0 - (float64(distance) / maxLen) return math.Max(0, similarity) } // levenshteinDistance calculates the Levenshtein distance between two strings func levenshteinDistance(s1, s2 string) int { len1 := len(s1) len2 := len(s2) // Create a 2D slice for dynamic programming dp := make([][]int, len1+1) for i := range dp { dp[i] = make([]int, len2+1) } // Initialize first row and column for i := 0; i <= len1; i++ { dp[i][0] = i } for j := 0; j <= len2; j++ { dp[0][j] = j } // Fill the dp table for i := 1; i <= len1; i++ { for j := 1; j <= len2; j++ { cost := 0 if s1[i-1] != s2[j-1] { cost = 1 } dp[i][j] = min3( dp[i-1][j]+1, // deletion dp[i][j-1]+1, // insertion dp[i-1][j-1]+cost, // substitution ) } } return dp[len1][len2] } // ExtractKeywords extracts keywords from a string func ExtractKeywords(text string) []string { // Normalize and split text text = normalizeString(text) words := strings.Fields(text) // Filter stopwords and short words var keywords []string stopwords := getStopwords() for _, word := range words { if len(word) > 2 && !contains(stopwords, word) { keywords = append(keywords, word) } } return keywords } // FindMatchedKeywords finds common keywords between two lists func FindMatchedKeywords(keywords1, keywords2 []string) []string { var matched []string for _, k1 := range keywords1 { for _, k2 := range keywords2 { if strings.EqualFold(k1, k2) || CalculateStringSimilarity(k1, k2) > 0.8 { if !contains(matched, k1) { matched = append(matched, k1) } break } } } return matched } // normalizeString normalizes a string (lowercase, remove extra spaces) func normalizeString(s string) string { // Convert to lowercase s = strings.ToLower(s) // Remove punctuation and extra spaces var result strings.Builder for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) { result.WriteRune(r) } else { result.WriteRune(' ') } } // Remove multiple spaces s = strings.Join(strings.Fields(result.String()), " ") return strings.TrimSpace(s) } // getStopwords returns common Indonesian stopwords func getStopwords() []string { return []string{ "dan", "atau", "dengan", "untuk", "dari", "ke", "di", "yang", "ini", "itu", "ada", "adalah", "akan", "telah", "sudah", "pada", "oleh", "sebagai", "dalam", "juga", "saya", "kamu", "dia", "kita", "mereka", "kami", "the", "a", "an", "of", "to", "in", "for", "on", "at", "by", "with", "from", } } // contains checks if a slice contains a string func contains(slice []string, str string) bool { for _, s := range slice { if strings.EqualFold(s, str) { return true } } return false } // min3 returns the minimum of three integers func min3(a, b, c int) int { if a < b { if a < c { return a } return c } if b < c { return b } return c } // internal/utils/validator.go package utils import ( "regexp" "strings" "github.com/go-playground/validator/v10" ) var validate *validator.Validate // InitValidator initializes the validator func InitValidator() { validate = validator.New() // Register custom validators validate.RegisterValidation("phone", validatePhone) validate.RegisterValidation("nrp", validateNRP) } // ValidateStruct validates a struct func ValidateStruct(s interface{}) error { if validate == nil { InitValidator() } return validate.Struct(s) } // validatePhone validates Indonesian phone numbers func validatePhone(fl validator.FieldLevel) bool { phone := fl.Field().String() if phone == "" { return true // Allow empty (use required tag separately) } // Remove spaces and dashes phone = strings.ReplaceAll(phone, " ", "") phone = strings.ReplaceAll(phone, "-", "") // Check if starts with 0 or +62 or 62 pattern := `^(0|\+?62)[0-9]{8,12}$` matched, _ := regexp.MatchString(pattern, phone) return matched } // validateNRP validates NRP (Nomor Registrasi Pokok) func validateNRP(fl validator.FieldLevel) bool { nrp := fl.Field().String() if nrp == "" { return true // Allow empty (use required tag separately) } // NRP format: 10 digits pattern := `^[0-9]{10}$` matched, _ := regexp.MatchString(pattern, nrp) return matched } // IsValidEmail checks if email is valid func IsValidEmail(email string) bool { pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` matched, _ := regexp.MatchString(pattern, email) return matched } // IsValidURL checks if URL is valid func IsValidURL(url string) bool { pattern := `^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$` matched, _ := regexp.MatchString(pattern, url) return matched } // SanitizeString removes potentially dangerous characters func SanitizeString(s string) string { // Remove control characters s = strings.Map(func(r rune) rune { if r < 32 && r != '\n' && r != '\r' && r != '\t' { return -1 } return r }, s) return strings.TrimSpace(s) } // internal/workers/audit_worker.go 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() } // internal/workers/expire_worker.go 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() } // internal/workers/matching_worker.go 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() } // internal/workers/notification_worker.go 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() } /* web/css/style.css */ * { 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; } } // web/js/admin.js // Dashboard Admin JavaScript - FIXED ENDPOINTS let allUsers = []; let allCategories = []; let allAuditLogs = []; // Initialize dashboard window.addEventListener("DOMContentLoaded", async () => { const user = checkAuth(); if (!user || user.role !== "admin") { window.location.href = "/login"; return; } await loadStats(); await loadUsers(); setupSearchAndFilters(); setupReportFilters(); }); // Load statistics - FIXED async function loadStats() { try { const stats = await apiCall("/api/admin/dashboard"); document.getElementById("statTotalUsers").textContent = stats.total_users || 0; document.getElementById("statTotalItems").textContent = stats.total_items || 0; document.getElementById("statTotalClaims").textContent = stats.total_claims || 0; document.getElementById("statTotalArchive").textContent = stats.total_archive || 0; } catch (error) { console.error("Error loading stats:", error); } } // Load users - CORRECT (sudah sesuai) async function loadUsers() { try { const response = await apiCall("/api/admin/users"); allUsers = response.data || []; renderUsers(allUsers); } catch (error) { console.error("Error loading users:", error); showAlert("Gagal memuat data user", "danger"); } } // Render users function renderUsers(users) { const tbody = document.getElementById("usersTableBody"); if (!users || users.length === 0) { tbody.innerHTML = ` Belum ada data user `; return; } tbody.innerHTML = users .map( (user) => ` ${user.name} ${user.email} ${user.nrp} ${getRoleBadge(user.role)} ${getStatusBadge(user.status || "active")} ${ user.status === "active" ? `` : `` } ` ) .join(""); } // Edit user async function editUser(userId) { try { const user = await apiCall(`/api/admin/users/${userId}`); const form = document.getElementById("editUserForm"); form.elements.user_id.value = user.id; form.elements.name.value = user.name; form.elements.email.value = user.email; form.elements.nrp.value = user.nrp; form.elements.phone.value = user.phone || ""; form.elements.role.value = user.role; openModal("editUserModal"); } catch (error) { console.error("Error loading user:", error); showAlert("Gagal memuat data user", "danger"); } } // Submit edit user document .getElementById("editUserForm") ?.addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(e.target); const userId = formData.get("user_id"); const role = formData.get("role"); try { const submitBtn = e.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.innerHTML = ' Menyimpan...'; // Update role await apiCall(`/api/admin/users/${userId}/role`, { method: "PATCH", body: JSON.stringify({ role }), }); showAlert("User berhasil diupdate!", "success"); closeModal("editUserModal"); await loadUsers(); } catch (error) { console.error("Error updating user:", error); showAlert(error.message || "Gagal update user", "danger"); } finally { const submitBtn = e.target.querySelector('button[type="submit"]'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "Update User"; } } }); // Block user async function blockUser(userId) { if (!confirmAction("Block user ini?")) return; try { await apiCall(`/api/admin/users/${userId}/block`, { method: "POST", }); showAlert("User berhasil diblock!", "success"); await loadUsers(); } catch (error) { console.error("Error blocking user:", error); showAlert(error.message || "Gagal block user", "danger"); } } // Unblock user async function unblockUser(userId) { if (!confirmAction("Unblock user ini?")) return; try { await apiCall(`/api/admin/users/${userId}/unblock`, { method: "POST", }); showAlert("User berhasil di-unblock!", "success"); await loadUsers(); } catch (error) { console.error("Error unblocking user:", error); showAlert(error.message || "Gagal unblock user", "danger"); } } // Load categories async function loadCategories() { try { const response = await apiCall("/api/categories"); allCategories = response.data || []; renderCategories(allCategories); } catch (error) { console.error("Error loading categories:", error); showAlert("Gagal memuat data kategori", "danger"); } } // Render categories function renderCategories(categories) { const grid = document.getElementById("categoriesGrid"); if (!categories || categories.length === 0) { grid.innerHTML = `
šŸ·ļø

Belum ada kategori

`; return; } const icons = { pakaian: "šŸ‘•", alat_makan: "šŸ½ļø", aksesoris: "šŸ‘“", elektronik: "šŸ’»", alat_tulis: "āœļø", lainnya: "šŸ“¦", }; grid.innerHTML = categories .map( (cat) => `
${icons[cat.slug] || "šŸ“¦"}

${cat.name}

${ cat.description ? `

${cat.description}

` : "" }
` ) .join(""); } // Submit add category document .getElementById("addCategoryForm") ?.addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(e.target); const data = Object.fromEntries(formData); try { const submitBtn = e.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.innerHTML = ' Menyimpan...'; await apiCall("/api/admin/categories", { method: "POST", body: JSON.stringify(data), }); showAlert("Kategori berhasil ditambahkan!", "success"); closeModal("addCategoryModal"); e.target.reset(); await loadCategories(); } catch (error) { console.error("Error adding category:", error); showAlert(error.message || "Gagal menambahkan kategori", "danger"); } finally { const submitBtn = e.target.querySelector('button[type="submit"]'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "Tambah Kategori"; } } }); // Edit category async function editCategory(catId) { const newName = prompt("Nama kategori baru:"); if (!newName) return; try { await apiCall(`/api/admin/categories/${catId}`, { method: "PUT", body: JSON.stringify({ name: newName }), }); showAlert("Kategori berhasil diupdate!", "success"); await loadCategories(); } catch (error) { console.error("Error updating category:", error); showAlert(error.message || "Gagal update kategori", "danger"); } } // Delete category async function deleteCategory(catId) { if (!confirmAction("Hapus kategori ini?")) return; try { await apiCall(`/api/admin/categories/${catId}`, { method: "DELETE", }); showAlert("Kategori berhasil dihapus!", "success"); await loadCategories(); } catch (error) { console.error("Error deleting category:", error); showAlert(error.message || "Gagal hapus kategori", "danger"); } } // Setup report filters - SIMPLIFIED (remove preview) function setupReportFilters() { // Remove preview functionality since endpoint doesn't exist // Just setup the export buttons } // Export report async function exportReport(format) { const period = document.getElementById("reportPeriod")?.value; const type = document.getElementById("reportType")?.value; const startDate = document.getElementById("reportStartDate")?.value; const endDate = document.getElementById("reportEndDate")?.value; let url = `/api/admin/reports/export?format=${format}&period=${period}&type=${type}`; if (period === "custom" && startDate && endDate) { url += `&start_date=${startDate}&end_date=${endDate}`; } try { const token = getToken(); const response = await fetch(`${API_URL}${url}`, { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { throw new Error("Export failed"); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = downloadUrl; a.download = `laporan_${type}_${period}.${ format === "pdf" ? "pdf" : "xlsx" }`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(downloadUrl); a.remove(); showAlert( `Laporan ${format.toUpperCase()} berhasil didownload!`, "success" ); } catch (error) { console.error("Error exporting report:", error); showAlert("Gagal export laporan", "danger"); } } // Load audit logs async function loadAudit() { try { const response = await apiCall("/api/admin/audit-logs"); allAuditLogs = response.data || []; renderAuditLogs(allAuditLogs); } catch (error) { console.error("Error loading audit logs:", error); showAlert("Gagal memuat audit log", "danger"); } } // Render audit logs function renderAuditLogs(logs) { const tbody = document.getElementById("auditTableBody"); if (!logs || logs.length === 0) { tbody.innerHTML = ` Belum ada audit log `; return; } tbody.innerHTML = logs .map( (log) => ` ${formatDateTime(log.created_at)} ${log.user_name} ${log.action} ${log.details || "-"} ${log.ip_address || "-"} ` ) .join(""); } // Setup search and filters function setupSearchAndFilters() { const searchUsers = document.getElementById("searchUsers"); const roleFilter = document.getElementById("roleFilter"); const statusFilter = document.getElementById("statusFilter"); const performUsersSearch = debounce(() => { const searchTerm = searchUsers?.value.toLowerCase() || ""; const role = roleFilter?.value || ""; const status = statusFilter?.value || ""; let filtered = allUsers.filter((user) => { const matchesSearch = user.name.toLowerCase().includes(searchTerm) || user.email.toLowerCase().includes(searchTerm) || user.nrp.includes(searchTerm); const matchesRole = !role || user.role === role; const matchesStatus = !status || user.status === status; return matchesSearch && matchesRole && matchesStatus; }); renderUsers(filtered); }, 300); searchUsers?.addEventListener("input", performUsersSearch); roleFilter?.addEventListener("change", performUsersSearch); statusFilter?.addEventListener("change", performUsersSearch); } // web/js/main.js // 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) { // FIXED: Jangan langsung redirect di sini // Biarkan halaman yang memanggil yang handle redirect return null; } return user; } // Logout - FIXED function logout() { if (confirm("Apakah Anda yakin ingin logout?")) { clearAuth(); window.location.href = "/login"; // FIXED: tambah / } } // API call helper async function apiCall(endpoint, options = {}) { const token = getToken(); const defaultOptions = { headers: { "Content-Type": "application/json", ...(token && { Authorization: `Bearer ${token}` }), }, }; const finalOptions = { ...defaultOptions, ...options, headers: { ...defaultOptions.headers, ...options.headers, }, }; try { const response = await fetch(`${API_URL}${endpoint}`, finalOptions); const data = await response.json(); if (!response.ok) { // Handle 401 Unauthorized if (response.status === 401) { showAlert("Session expired. Please login again.", "danger"); setTimeout(() => { clearAuth(); window.location.href = "/login"; // FIXED: tambah / }, 1500); throw new Error("Unauthorized"); } throw new Error(data.error || "Request failed"); } return data; } catch (error) { console.error("API call error:", error); throw error; } } // API call for file upload - FIXED: support PUT method async function apiUpload(endpoint, formData, method = "POST") { const token = getToken(); try { const response = await fetch(`${API_URL}${endpoint}`, { method: method, // FIXED: bisa POST atau PUT headers: { ...(token && { Authorization: `Bearer ${token}` }), // JANGAN set Content-Type untuk FormData, browser akan set otomatis dengan boundary }, body: formData, }); const data = await response.json(); if (!response.ok) { if (response.status === 401) { showAlert("Session expired. Please login again.", "danger"); setTimeout(() => { clearAuth(); window.location.href = "/login"; // FIXED: tambah / }, 1500); throw new Error("Unauthorized"); } throw new Error(data.error || "Upload failed"); } return data; } catch (error) { console.error("Upload error:", error); throw error; } } // Tab switching function switchTab(tabName) { // Remove active class from all tabs document.querySelectorAll(".tab-btn").forEach((btn) => { btn.classList.remove("active"); }); document.querySelectorAll(".tab-content").forEach((content) => { content.classList.remove("active"); }); // Add active class to selected tab event.target.classList.add("active"); document.getElementById(tabName + "Tab").classList.add("active"); // Trigger load function for specific tab if exists const loadFunctionName = `load${capitalize(tabName)}`; if (typeof window[loadFunctionName] === "function") { window[loadFunctionName](); } } // Modal utilities function openModal(modalId) { document.getElementById(modalId).classList.add("active"); } function closeModal(modalId) { document.getElementById(modalId).classList.remove("active"); } // Close modal when clicking outside window.addEventListener("click", (e) => { if (e.target.classList.contains("modal")) { e.target.classList.remove("active"); } }); // Alert notification function showAlert(message, type = "info") { const alertDiv = document.createElement("div"); alertDiv.className = `alert alert-${type}`; alertDiv.textContent = message; alertDiv.style.cssText = ` position: fixed; top: 80px; right: 20px; padding: 15px 20px; border-radius: 10px; box-shadow: 0 5px 15px rgba(0,0,0,0.2); z-index: 9999; animation: slideInRight 0.3s; `; // Set colors based on type const colors = { success: { bg: "#d1fae5", color: "#10b981", border: "#10b981" }, danger: { bg: "#fee2e2", color: "#ef4444", border: "#ef4444" }, warning: { bg: "#fef3c7", color: "#f59e0b", border: "#f59e0b" }, info: { bg: "#dbeafe", color: "#2563eb", border: "#2563eb" }, }; const colorScheme = colors[type] || colors.info; alertDiv.style.background = colorScheme.bg; alertDiv.style.color = colorScheme.color; alertDiv.style.border = `2px solid ${colorScheme.border}`; document.body.appendChild(alertDiv); setTimeout(() => { alertDiv.style.animation = "slideOutRight 0.3s"; setTimeout(() => alertDiv.remove(), 300); }, 3000); } // Format date function formatDate(dateString) { const date = new Date(dateString); const options = { year: "numeric", month: "long", day: "numeric" }; return date.toLocaleDateString("id-ID", options); } // Format datetime function formatDateTime(dateString) { const date = new Date(dateString); const options = { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", }; return date.toLocaleDateString("id-ID", options); } // Capitalize first letter function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } // Get status badge HTML function getStatusBadge(status) { const statusMap = { unclaimed: { label: "Unclaimed", class: "badge-primary" }, pending_claim: { label: "Pending Claim", class: "badge-warning" }, verified: { label: "Verified", class: "badge-success" }, case_closed: { label: "Case Closed", class: "badge-success" }, expired: { label: "Expired", class: "badge-danger" }, pending: { label: "Pending", class: "badge-warning" }, approved: { label: "Approved", class: "badge-success" }, rejected: { label: "Rejected", class: "badge-danger" }, active: { label: "Active", class: "badge-success" }, blocked: { label: "Blocked", class: "badge-danger" }, }; const statusInfo = statusMap[status] || { label: status, class: "badge-primary", }; return `${statusInfo.label}`; } // Get role badge HTML function getRoleBadge(role) { const roleMap = { admin: { label: "Admin", class: "badge-danger" }, manager: { label: "Manager", class: "badge-warning" }, user: { label: "User", class: "badge-primary" }, }; const roleInfo = roleMap[role] || { label: role, class: "badge-primary" }; return `${roleInfo.label}`; } // Confirm dialog function confirmAction(message) { return confirm(message); } // Loading state function setLoading(elementId, isLoading) { const element = document.getElementById(elementId); if (!element) return; if (isLoading) { element.innerHTML = `

Loading...

`; } } // Empty state function showEmptyState(elementId, icon, message) { const element = document.getElementById(elementId); if (!element) return; element.innerHTML = `
${icon}

Tidak ada data

${message}

`; } // Debounce function for search function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Add CSS animation styles const style = document.createElement("style"); style.textContent = ` @keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideOutRight { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } `; document.head.appendChild(style); // Initialize user info in navbar function initUserInfo() { const user = getCurrentUser(); if (!user) return; const userNameEl = document.getElementById("userName"); const userAvatarEl = document.getElementById("userAvatar"); if (userNameEl) userNameEl.textContent = user.name; if (userAvatarEl) userAvatarEl.textContent = user.name.charAt(0).toUpperCase(); } // Initialize on page load window.addEventListener("DOMContentLoaded", () => { initUserInfo(); }); // web/js/manager.js // Dashboard Manager JavaScript - FIXED ENDPOINTS let allItems = []; let allClaims = []; let allLostItems = []; let allArchive = []; // Initialize dashboard window.addEventListener("DOMContentLoaded", async () => { const user = checkAuth(); if (!user || user.role !== "manager") { window.location.href = "/login"; return; } await loadStats(); await loadItems(); setupSearchAndFilters(); }); // Load statistics - FIXED async function loadStats() { try { const stats = await apiCall("/api/manager/dashboard"); document.getElementById("statTotalItems").textContent = stats.total_items || 0; document.getElementById("statPendingClaims").textContent = stats.pending_claims || 0; document.getElementById("statVerified").textContent = stats.verified || 0; document.getElementById("statExpired").textContent = stats.expired || 0; } catch (error) { console.error("Error loading stats:", error); } } // Load items - FIXED async function loadItems() { setLoading("itemsGrid", true); try { const response = await apiCall("/api/items"); allItems = response.data || []; renderItems(allItems); } catch (error) { console.error("Error loading items:", error); showEmptyState("itemsGrid", "šŸ“¦", "Gagal memuat data barang"); } } // Render items function renderItems(items) { const grid = document.getElementById("itemsGrid"); if (!items || items.length === 0) { showEmptyState("itemsGrid", "šŸ“¦", "Belum ada barang"); return; } grid.innerHTML = items .map( (item) => `
${item.name}

${item.name}

šŸ“ ${item.location} šŸ“… ${formatDate(item.date_found)} ${getStatusBadge(item.status)}
${ item.status !== "case_closed" && item.status !== "expired" ? `` : "" } ${ item.status === "verified" ? `` : "" }
` ) .join(""); } // View item detail - FIXED async function viewItemDetail(itemId) { try { const item = await apiCall(`/api/items/${itemId}`); const modalContent = document.getElementById("itemDetailContent"); modalContent.innerHTML = ` ${item.name}

${item.name}

Kategori: ${item.category}
Lokasi Ditemukan: ${item.location}
Tanggal Ditemukan: ${formatDate( item.date_found )}
Status: ${getStatusBadge(item.status)}
Pelapor: ${item.reporter_name}
Kontak: ${item.reporter_contact}
Deskripsi Keunikan (Rahasia):
${item.description}
`; openModal("itemDetailModal"); } catch (error) { console.error("Error loading item detail:", error); showAlert("Gagal memuat detail barang", "danger"); } } // Edit item - FIXED async function editItem(itemId) { try { const item = await apiCall(`/api/items/${itemId}`); const form = document.getElementById("editItemForm"); form.elements.item_id.value = item.id; form.elements.name.value = item.name; form.elements.category.value = item.category; form.elements.location.value = item.location; form.elements.description.value = item.description; form.elements.reporter_name.value = item.reporter_name; form.elements.reporter_contact.value = item.reporter_contact; form.elements.date_found.value = item.date_found.split("T")[0]; openModal("editItemModal"); } catch (error) { console.error("Error loading item:", error); showAlert("Gagal memuat data barang", "danger"); } } // Submit edit item - FIXED document .getElementById("editItemForm") ?.addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(e.target); const itemId = formData.get("item_id"); formData.delete("item_id"); try { const submitBtn = e.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.innerHTML = ' Menyimpan...'; await apiUpload(`/api/items/${itemId}`, formData, "PUT"); showAlert("Barang berhasil diupdate!", "success"); closeModal("editItemModal"); await loadItems(); await loadStats(); } catch (error) { console.error("Error updating item:", error); showAlert(error.message || "Gagal update barang", "danger"); } finally { const submitBtn = e.target.querySelector('button[type="submit"]'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "Update"; } } }); // Close case - FIXED async function closeCase(itemId) { if (!confirmAction("Tutup kasus ini? Barang akan dipindahkan ke arsip.")) return; try { await apiCall(`/api/items/${itemId}/status`, { method: "PATCH", body: JSON.stringify({ status: "case_closed" }), }); showAlert("Kasus berhasil ditutup!", "success"); await loadItems(); await loadStats(); } catch (error) { console.error("Error closing case:", error); showAlert(error.message || "Gagal menutup kasus", "danger"); } } // Load claims - FIXED async function loadClaims() { setLoading("claimsList", true); try { const response = await apiCall("/api/claims"); allClaims = response.data || []; renderClaims(allClaims); } catch (error) { console.error("Error loading claims:", error); document.getElementById("claimsList").innerHTML = `
šŸ¤

Gagal memuat data klaim

`; } } // Render claims function renderClaims(claims) { const list = document.getElementById("claimsList"); if (!claims || claims.length === 0) { list.innerHTML = `
šŸ¤

Belum ada klaim yang masuk

`; return; } list.innerHTML = claims .map( (claim) => `

${claim.item_name}

${getStatusBadge(claim.status)}
Pengklaim: ${claim.user_name}
Kontak: ${claim.contact}
Tanggal Klaim: ${formatDate( claim.created_at )}
${ claim.match_percentage ? `
Match: ${claim.match_percentage}%
` : "" }
Deskripsi dari Pengklaim:
${claim.description}
${ claim.status === "pending" ? `
` : "" } ${ claim.status === "rejected" && claim.notes ? `
Alasan: ${claim.notes}
` : "" }
` ) .join(""); } // Verify claim - FIXED async function verifyClaim(claimId) { try { const claim = await apiCall(`/api/claims/${claimId}`); const modalContent = document.getElementById("verifyClaimContent"); modalContent.innerHTML = `

Deskripsi Asli Barang

${claim.item_description}

Deskripsi dari Pengklaim

${claim.description}
${ claim.proof_url ? `

Bukti Pendukung

` : "" } ${ claim.match_percentage ? `
Similarity Match: ${claim.match_percentage}%
` : "" }
Info Pengklaim:
Nama: ${claim.user_name}
Kontak: ${claim.contact}
`; openModal("verifyClaimModal"); } catch (error) { console.error("Error loading claim:", error); showAlert("Gagal memuat data klaim", "danger"); } } // Approve claim - FIXED async function approveClaim(claimId) { const notes = prompt("Catatan (opsional):"); try { await apiCall(`/api/claims/${claimId}/verify`, { method: "POST", body: JSON.stringify({ approved: true, notes: notes || "", }), }); showAlert("Klaim berhasil diapprove!", "success"); closeModal("verifyClaimModal"); await loadClaims(); await loadItems(); await loadStats(); } catch (error) { console.error("Error approving claim:", error); showAlert(error.message || "Gagal approve klaim", "danger"); } } // Reject claim - FIXED async function rejectClaim(claimId) { const notes = prompt("Alasan penolakan (wajib):"); if (!notes) { showAlert("Alasan penolakan harus diisi!", "warning"); return; } try { await apiCall(`/api/claims/${claimId}/verify`, { method: "POST", body: JSON.stringify({ approved: false, notes, }), }); showAlert("Klaim berhasil ditolak!", "success"); closeModal("verifyClaimModal"); await loadClaims(); await loadStats(); } catch (error) { console.error("Error rejecting claim:", error); showAlert(error.message || "Gagal reject klaim", "danger"); } } // Load lost items - FIXED async function loadLost() { setLoading("lostItemsGrid", true); try { const response = await apiCall("/api/lost-items"); allLostItems = response.data || []; renderLostItems(allLostItems); } catch (error) { console.error("Error loading lost items:", error); showEmptyState("lostItemsGrid", "😢", "Gagal memuat data barang hilang"); } } // Render lost items function renderLostItems(items) { const grid = document.getElementById("lostItemsGrid"); if (!items || items.length === 0) { showEmptyState("lostItemsGrid", "😢", "Belum ada laporan barang hilang"); return; } grid.innerHTML = items .map( (item) => `

${item.name}

šŸ·ļø ${item.category} šŸŽØ ${item.color} šŸ“… ${formatDate(item.date_lost)} ${item.location ? `šŸ“ ${item.location}` : ""}

${ item.description }

Pelapor: ${item.user_name}
` ) .join(""); } // Find similar items - FIXED async function findSimilarItems(lostItemId) { try { setLoading("matchItemsContent", true); openModal("matchItemsModal"); const response = await apiCall(`/api/lost-items/${lostItemId}/matches`); const matches = response.data || []; const modalContent = document.getElementById("matchItemsContent"); if (matches.length === 0) { modalContent.innerHTML = `
šŸ”

Tidak ada barang yang cocok

Belum ada barang ditemukan yang mirip dengan laporan ini

`; return; } modalContent.innerHTML = `

Ditemukan ${ matches.length } barang yang mungkin cocok:

${matches .map( (match) => `
${match.name}
${match.similarity}% Match

${match.name}

šŸ“ ${match.location} šŸ“… ${formatDate(match.date_found)} ${getStatusBadge(match.status)}
` ) .join("")}
`; } catch (error) { console.error("Error finding similar items:", error); document.getElementById("matchItemsContent").innerHTML = `
āŒ

Gagal mencari barang yang mirip

`; } } // Load archive - FIXED async function loadArchive() { setLoading("archiveGrid", true); try { const response = await apiCall("/api/archives"); allArchive = response.data || []; renderArchive(allArchive); } catch (error) { console.error("Error loading archive:", error); showEmptyState("archiveGrid", "šŸ“‚", "Gagal memuat data arsip"); } } // Render archive function renderArchive(items) { const grid = document.getElementById("archiveGrid"); if (!items || items.length === 0) { showEmptyState("archiveGrid", "šŸ“‚", "Belum ada barang di arsip"); return; } grid.innerHTML = items .map( (item) => `
${item.name}

${item.name}

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

${item.name}

šŸ“ ${item.location} šŸ“… ${formatDate(item.date_found)} ${getStatusBadge(item.status)}
${ item.status === "unclaimed" ? `` : "" }
` ) .join(""); } // View item detail - CORRECT (sudah sesuai) async function viewItemDetail(itemId) { try { const item = await apiCall(`/api/items/${itemId}`); const modalContent = document.getElementById("itemDetailContent"); modalContent.innerHTML = ` ${item.name}

${item.name}

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

${item.name}

šŸ·ļø ${item.category} šŸŽØ ${item.color} šŸ“… ${formatDate(item.date_lost)} ${item.location ? `šŸ“ ${item.location}` : ""}

${ item.description }

` ) .join(""); } // Load my found items - FIXED async function loadFound() { setLoading("foundItemsGrid", true); try { const response = await apiCall("/api/user/items"); const items = response.data || []; renderFoundItems(items); } catch (error) { console.error("Error loading found items:", error); showEmptyState( "foundItemsGrid", "šŸŽ‰", "Gagal memuat data barang yang ditemukan" ); } } // Render found items function renderFoundItems(items) { const grid = document.getElementById("foundItemsGrid"); if (!items || items.length === 0) { showEmptyState( "foundItemsGrid", "šŸŽ‰", "Anda belum melaporkan penemuan barang" ); return; } grid.innerHTML = items .map( (item) => `
${item.name}

${item.name}

šŸ“ ${item.location} šŸ“… ${formatDate(item.date_found)} ${getStatusBadge(item.status)}
` ) .join(""); } // Load my claims - CORRECT (sudah sesuai) async function loadClaims() { setLoading("claimsGrid", true); try { const response = await apiCall("/api/user/claims"); allClaims = response.data || []; renderClaims(allClaims); } catch (error) { console.error("Error loading claims:", error); showEmptyState("claimsGrid", "šŸ¤", "Gagal memuat data klaim"); } } // Render claims function renderClaims(claims) { const grid = document.getElementById("claimsGrid"); if (!claims || claims.length === 0) { showEmptyState("claimsGrid", "šŸ¤", "Anda belum pernah mengajukan klaim"); return; } grid.innerHTML = claims .map( (claim) => `

${claim.item_name}

šŸ“… ${formatDate(claim.created_at)} ${getStatusBadge(claim.status)}

${claim.description}

${ claim.status === "rejected" && claim.notes ? `

Alasan ditolak: ${claim.notes}

` : "" }
` ) .join(""); } // Report lost item function openReportLostModal() { openModal("reportLostModal"); } // Submit lost item report - CORRECT (sudah sesuai) document .getElementById("reportLostForm") ?.addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(e.target); const data = Object.fromEntries(formData); try { const submitBtn = e.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.innerHTML = ' Mengirim...'; await apiCall("/api/lost-items", { method: "POST", body: JSON.stringify(data), }); showAlert("Laporan kehilangan berhasil disubmit!", "success"); closeModal("reportLostModal"); e.target.reset(); await loadLost(); await loadStats(); } catch (error) { console.error("Error submitting lost item:", error); showAlert(error.message || "Gagal submit laporan", "danger"); } finally { const submitBtn = e.target.querySelector('button[type="submit"]'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "Submit Laporan"; } } }); // Report found item function openReportFoundModal() { openModal("reportFoundModal"); } // Submit found item report - CORRECT (sudah sesuai) document .getElementById("reportFoundForm") ?.addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(e.target); try { const submitBtn = e.target.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.innerHTML = ' Mengirim...'; await apiUpload("/api/items", formData); showAlert("Laporan penemuan berhasil disubmit!", "success"); closeModal("reportFoundModal"); e.target.reset(); await loadFound(); await loadStats(); } catch (error) { console.error("Error submitting found item:", error); showAlert(error.message || "Gagal submit laporan", "danger"); } finally { const submitBtn = e.target.querySelector('button[type="submit"]'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = "Submit Penemuan"; } } }); // Setup search and filters function setupSearchAndFilters() { const searchInput = document.getElementById("searchInput"); const categoryFilter = document.getElementById("categoryFilter"); const sortBy = document.getElementById("sortBy"); const performSearch = debounce(() => { const searchTerm = searchInput?.value.toLowerCase() || ""; const category = categoryFilter?.value || ""; const sort = sortBy?.value || "date_desc"; let filtered = allItems.filter((item) => { const matchesSearch = item.name.toLowerCase().includes(searchTerm) || item.location.toLowerCase().includes(searchTerm); const matchesCategory = !category || item.category === category; return matchesSearch && matchesCategory; }); // Sort filtered.sort((a, b) => { switch (sort) { case "date_desc": return new Date(b.date_found) - new Date(a.date_found); case "date_asc": return new Date(a.date_found) - new Date(b.date_found); case "name_asc": return a.name.localeCompare(b.name); case "name_desc": return b.name.localeCompare(a.name); default: return 0; } }); renderItems(filtered); }, 300); searchInput?.addEventListener("input", performSearch); categoryFilter?.addEventListener("change", performSearch); sortBy?.addEventListener("change", performSearch); } // Create claim modal if not exists if (!document.getElementById("claimModal")) { const claimModal = document.createElement("div"); claimModal.id = "claimModal"; claimModal.className = "modal"; claimModal.innerHTML = ` `; document.body.appendChild(claimModal); } // Create item detail modal if not exists if (!document.getElementById("itemDetailModal")) { const itemDetailModal = document.createElement("div"); itemDetailModal.id = "itemDetailModal"; itemDetailModal.className = "modal"; itemDetailModal.innerHTML = ` `; document.body.appendChild(itemDetailModal); } Dashboard Admin - Lost & Found

Total User

0

Total Barang

0

Total Klaim

0

Di Arsip

0

Daftar User

Nama Email NRP Role Status Aksi

Kelola Kategori

Export Laporan

Filter Laporan

Preview Laporan

Total Barang: -
Diklaim: -
Expired: -

Audit Log

Waktu User Aksi Detail IP Address
Lost & Found System

šŸ” Lost & Found System

Sistem Manajemen Barang Hilang & Temuan Kampus

šŸ“¢

Lapor Kehilangan

Laporkan barang yang hilang dengan mudah dan cepat

šŸ“¦

Temukan Barang

Cari barang temuanmu di database kami

šŸ¤

Klaim Barang

Proses klaim yang aman dengan verifikasi

⚔

Auto Matching

Sistem otomatis mencocokkan barang hilang

0
Barang Ditemukan
0
Sudah Diklaim
0
Pengguna Terdaftar
Login - Lost & Found

šŸ” Login

Masuk ke Lost & Found System

Email tidak valid
Password minimal 6 karakter
atau
Dashboard Manager - Lost & Found

Total Barang

0

Pending Claim

0

Verified

0

Expired

0

Daftar Barang Ditemukan

Daftar Klaim

Barang Hilang

Arsip Barang

Register - Lost & Found

šŸ” Register

Buat akun Lost & Found System

Nama minimal 3 karakter
Email tidak valid
NRP minimal 10 digit
Nomor tidak valid
Password minimal 8 karakter
Password tidak cocok
atau
Dashboard User - Lost & Found

Barang Hilang Saya

0

Barang yang Saya Temukan

0

Klaim Saya

0

Barang Ditemukan

Barang Hilang Saya

Barang yang Saya Temukan

Riwayat Klaim

# .env # 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=* // go.mod module lost-and-found go 1.25.0 require ( github.com/gin-gonic/gin v1.11.0 github.com/go-playground/validator/v10 v10.28.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/joho/godotenv v1.5.1 github.com/jung-kurt/gofpdf v1.16.2 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/xuri/excelize/v2 v2.10.0 golang.org/x/crypto v0.44.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/tiendc/go-deepcopy v1.7.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) 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=