From 8da420c148a13758f9cde7523e7f6112bb0e2456 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Mon, 17 Nov 2025 12:17:44 +0700 Subject: [PATCH] UAS --- lost-and-found/.env | 23 + .../.vscode/code-counter/code-counter.db | Bin 0 -> 24576 bytes .../reports/code-counter-report.html | 1 + lost-and-found/Makefile | 0 lost-and-found/README.md | 0 lost-and-found/cmd/server/main.go | 121 +++ lost-and-found/database/schema.sql | 300 +++++++ lost-and-found/database/seed.sql | 202 +++++ go.mod => lost-and-found/go.mod | 0 lost-and-found/go.sum | 131 +++ lost-and-found/internal/config/config.go | 66 ++ lost-and-found/internal/config/database.go | 145 ++++ lost-and-found/internal/config/jwt.go | 131 +++ .../internal/controllers/admin_controller.go | 98 +++ .../controllers/archive_controller.go | 68 ++ .../internal/controllers/auth_controller.go | 102 +++ .../controllers/category_controller.go | 129 +++ .../internal/controllers/claim_controller.go | 247 ++++++ .../internal/controllers/item_controller.go | 222 +++++ .../controllers/lost_item_controller.go | 193 +++++ .../internal/controllers/match_controller.go | 86 ++ .../internal/controllers/report_controller.go | 109 +++ .../internal/controllers/user_controller.go | 237 ++++++ lost-and-found/internal/middleware/cors.go | 22 + .../internal/middleware/jwt_middleware.go | 105 +++ lost-and-found/internal/middleware/logger.go | 45 + .../internal/middleware/rate_limiter.go | 112 +++ .../internal/middleware/role_middleware.go | 56 ++ lost-and-found/internal/models/archive.go | 110 +++ lost-and-found/internal/models/audit_log.go | 98 +++ lost-and-found/internal/models/category.go | 48 ++ lost-and-found/internal/models/claim.go | 164 ++++ .../internal/models/claim_verification.go | 77 ++ lost-and-found/internal/models/item.go | 152 ++++ lost-and-found/internal/models/lost_item.go | 93 +++ .../internal/models/match_result.go | 128 +++ .../internal/models/notification.go | 127 +++ .../internal/models/revision_log.go | 72 ++ lost-and-found/internal/models/role.go | 52 ++ lost-and-found/internal/models/user.go | 104 +++ .../internal/repositories/archive_repo.go | 91 +++ .../internal/repositories/audit_log_repo.go | 104 +++ .../internal/repositories/category_repo.go | 101 +++ .../internal/repositories/claim_repo.go | 145 ++++ .../repositories/claim_verification_repo.go | 66 ++ .../internal/repositories/item_repo.go | 158 ++++ .../internal/repositories/lost_item_repo.go | 127 +++ .../repositories/match_result_repo.go | 124 +++ .../repositories/notification_repo.go | 103 +++ .../repositories/revision_log_repo.go | 92 +++ .../internal/repositories/role_repo.go | 64 ++ .../internal/repositories/user_repo.go | 152 ++++ lost-and-found/internal/routes/routes.go | 128 +++ .../internal/services/archive_service.go | 67 ++ .../internal/services/audit_service.go | 68 ++ .../internal/services/auth_service.go | 172 ++++ .../internal/services/category_service.go | 147 ++++ .../internal/services/claim_service.go | 268 ++++++ .../internal/services/export_service.go | 254 ++++++ .../internal/services/item_service.go | 211 +++++ .../internal/services/lost_item_service.go | 207 +++++ .../internal/services/match_service.go | 239 ++++++ .../internal/services/notification_service.go | 115 +++ .../internal/services/user_service.go | 190 +++++ .../internal/services/verification_service.go | 153 ++++ lost-and-found/internal/utils/error.go | 66 ++ lost-and-found/internal/utils/excel_export.go | 99 +++ lost-and-found/internal/utils/hash.go | 17 + .../internal/utils/image_handler.go | 187 +++++ lost-and-found/internal/utils/matching.go | 102 +++ lost-and-found/internal/utils/pdf_export.go | 105 +++ lost-and-found/internal/utils/response.go | 67 ++ lost-and-found/internal/utils/similarity.go | 159 ++++ lost-and-found/internal/utils/validator.go | 84 ++ .../internal/workers/audit_worker.go | 72 ++ .../internal/workers/expire_worker.go | 103 +++ .../internal/workers/matching_worker.go | 89 ++ .../internal/workers/notification_worker.go | 116 +++ lost-and-found/setup.go | 254 ++++++ lost-and-found/uploads/claims/.gitkeep | 0 lost-and-found/uploads/items/.gitkeep | 0 lost-and-found/uploads/lost_items/.gitkeep | 0 lost-and-found/web/admin.html | 316 ++++++++ lost-and-found/web/css/style.css | 675 +++++++++++++++ lost-and-found/web/index.html | 322 ++++++++ lost-and-found/web/js/admin.js | 434 ++++++++++ lost-and-found/web/js/main.js | 349 ++++++++ lost-and-found/web/js/manager.js | 767 ++++++++++++++++++ lost-and-found/web/js/user.js | 490 +++++++++++ lost-and-found/web/login.html | 396 +++++++++ lost-and-found/web/manager.html | 253 ++++++ lost-and-found/web/register.html | 519 ++++++++++++ lost-and-found/web/user.html | 638 +++++++++++++++ 93 files changed, 14401 insertions(+) create mode 100644 lost-and-found/.env create mode 100644 lost-and-found/.vscode/code-counter/code-counter.db create mode 100644 lost-and-found/.vscode/code-counter/reports/code-counter-report.html create mode 100644 lost-and-found/Makefile create mode 100644 lost-and-found/README.md create mode 100644 lost-and-found/cmd/server/main.go create mode 100644 lost-and-found/database/schema.sql create mode 100644 lost-and-found/database/seed.sql rename go.mod => lost-and-found/go.mod (100%) create mode 100644 lost-and-found/go.sum create mode 100644 lost-and-found/internal/config/config.go create mode 100644 lost-and-found/internal/config/database.go create mode 100644 lost-and-found/internal/config/jwt.go create mode 100644 lost-and-found/internal/controllers/admin_controller.go create mode 100644 lost-and-found/internal/controllers/archive_controller.go create mode 100644 lost-and-found/internal/controllers/auth_controller.go create mode 100644 lost-and-found/internal/controllers/category_controller.go create mode 100644 lost-and-found/internal/controllers/claim_controller.go create mode 100644 lost-and-found/internal/controllers/item_controller.go create mode 100644 lost-and-found/internal/controllers/lost_item_controller.go create mode 100644 lost-and-found/internal/controllers/match_controller.go create mode 100644 lost-and-found/internal/controllers/report_controller.go create mode 100644 lost-and-found/internal/controllers/user_controller.go create mode 100644 lost-and-found/internal/middleware/cors.go create mode 100644 lost-and-found/internal/middleware/jwt_middleware.go create mode 100644 lost-and-found/internal/middleware/logger.go create mode 100644 lost-and-found/internal/middleware/rate_limiter.go create mode 100644 lost-and-found/internal/middleware/role_middleware.go create mode 100644 lost-and-found/internal/models/archive.go create mode 100644 lost-and-found/internal/models/audit_log.go create mode 100644 lost-and-found/internal/models/category.go create mode 100644 lost-and-found/internal/models/claim.go create mode 100644 lost-and-found/internal/models/claim_verification.go create mode 100644 lost-and-found/internal/models/item.go create mode 100644 lost-and-found/internal/models/lost_item.go create mode 100644 lost-and-found/internal/models/match_result.go create mode 100644 lost-and-found/internal/models/notification.go create mode 100644 lost-and-found/internal/models/revision_log.go create mode 100644 lost-and-found/internal/models/role.go create mode 100644 lost-and-found/internal/models/user.go create mode 100644 lost-and-found/internal/repositories/archive_repo.go create mode 100644 lost-and-found/internal/repositories/audit_log_repo.go create mode 100644 lost-and-found/internal/repositories/category_repo.go create mode 100644 lost-and-found/internal/repositories/claim_repo.go create mode 100644 lost-and-found/internal/repositories/claim_verification_repo.go create mode 100644 lost-and-found/internal/repositories/item_repo.go create mode 100644 lost-and-found/internal/repositories/lost_item_repo.go create mode 100644 lost-and-found/internal/repositories/match_result_repo.go create mode 100644 lost-and-found/internal/repositories/notification_repo.go create mode 100644 lost-and-found/internal/repositories/revision_log_repo.go create mode 100644 lost-and-found/internal/repositories/role_repo.go create mode 100644 lost-and-found/internal/repositories/user_repo.go create mode 100644 lost-and-found/internal/routes/routes.go create mode 100644 lost-and-found/internal/services/archive_service.go create mode 100644 lost-and-found/internal/services/audit_service.go create mode 100644 lost-and-found/internal/services/auth_service.go create mode 100644 lost-and-found/internal/services/category_service.go create mode 100644 lost-and-found/internal/services/claim_service.go create mode 100644 lost-and-found/internal/services/export_service.go create mode 100644 lost-and-found/internal/services/item_service.go create mode 100644 lost-and-found/internal/services/lost_item_service.go create mode 100644 lost-and-found/internal/services/match_service.go create mode 100644 lost-and-found/internal/services/notification_service.go create mode 100644 lost-and-found/internal/services/user_service.go create mode 100644 lost-and-found/internal/services/verification_service.go create mode 100644 lost-and-found/internal/utils/error.go create mode 100644 lost-and-found/internal/utils/excel_export.go create mode 100644 lost-and-found/internal/utils/hash.go create mode 100644 lost-and-found/internal/utils/image_handler.go create mode 100644 lost-and-found/internal/utils/matching.go create mode 100644 lost-and-found/internal/utils/pdf_export.go create mode 100644 lost-and-found/internal/utils/response.go create mode 100644 lost-and-found/internal/utils/similarity.go create mode 100644 lost-and-found/internal/utils/validator.go create mode 100644 lost-and-found/internal/workers/audit_worker.go create mode 100644 lost-and-found/internal/workers/expire_worker.go create mode 100644 lost-and-found/internal/workers/matching_worker.go create mode 100644 lost-and-found/internal/workers/notification_worker.go create mode 100644 lost-and-found/setup.go create mode 100644 lost-and-found/uploads/claims/.gitkeep create mode 100644 lost-and-found/uploads/items/.gitkeep create mode 100644 lost-and-found/uploads/lost_items/.gitkeep create mode 100644 lost-and-found/web/admin.html create mode 100644 lost-and-found/web/css/style.css create mode 100644 lost-and-found/web/index.html create mode 100644 lost-and-found/web/js/admin.js create mode 100644 lost-and-found/web/js/main.js create mode 100644 lost-and-found/web/js/manager.js create mode 100644 lost-and-found/web/js/user.js create mode 100644 lost-and-found/web/login.html create mode 100644 lost-and-found/web/manager.html create mode 100644 lost-and-found/web/register.html create mode 100644 lost-and-found/web/user.html diff --git a/lost-and-found/.env b/lost-and-found/.env new file mode 100644 index 0000000..67e0643 --- /dev/null +++ b/lost-and-found/.env @@ -0,0 +1,23 @@ +# Server Configuration +PORT=8080 +ENVIRONMENT=development + +# Database Configuration (MySQL/MariaDB) +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD= +DB_NAME=lost_and_found +DB_CHARSET=utf8mb4 +DB_PARSE_TIME=True +DB_LOC=Local + +# JWT Configuration +JWT_SECRET_KEY=L0stF0und$ecureK3y2024!@#M4h4s1sw4UAS*Pr0j3ct&BackendD3v + +# Upload Configuration +UPLOAD_PATH=./uploads +MAX_UPLOAD_SIZE=10485760 + +# CORS Configuration +ALLOWED_ORIGINS=* \ No newline at end of file diff --git a/lost-and-found/.vscode/code-counter/code-counter.db b/lost-and-found/.vscode/code-counter/code-counter.db new file mode 100644 index 0000000000000000000000000000000000000000..62024f13464bc2805514bb3ccd3981fd9fce4080 GIT binary patch literal 24576 zcmeI%O>f#T7{KwQyiL_md*kXO5o{9LX}4w6tMP=&P)+ zO-bv;O+|m2ThD)bEEvyMR`uwoQF^*Esy~jtSHD%u`jLVF0tg_000IagfB*vjt-zO3 zrPgSf_s`-FFbka38$|wzuV3C-deH8cKsBN zm+EhwM{^Y>Dhl)g_(X3juhWy_M4sAy*As2ub#>@L+IZpFy+J;#wznf}$J5FyD1Q0*~0R#|0 z009ILKmY**5ZG6N!!$9@|NFYUCode Counter Report

📊 Code Counter Report

Generated on 11/16/2025, 9:55:42 PM

Workspace: c:/Users/Bambang Herlambang/Documents/Semester 3/4_Back End Web Programming/uas 6/lost-and-found

Loading report data...
\ No newline at end of file diff --git a/lost-and-found/Makefile b/lost-and-found/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/lost-and-found/README.md b/lost-and-found/README.md new file mode 100644 index 0000000..e69de29 diff --git a/lost-and-found/cmd/server/main.go b/lost-and-found/cmd/server/main.go new file mode 100644 index 0000000..14f61ad --- /dev/null +++ b/lost-and-found/cmd/server/main.go @@ -0,0 +1,121 @@ +// main.go +package main + +import ( + "log" + "lost-and-found/internal/config" + "lost-and-found/internal/middleware" + "lost-and-found/internal/routes" + "lost-and-found/internal/workers" + "os" + "os/signal" + "syscall" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + // Load .env file + if err := godotenv.Load(); err != nil { + log.Println("⚠️ No .env file found, using environment variables") + } + + // Initialize JWT config + config.InitJWT() + + // Initialize database + if err := config.InitDB(); err != nil { + log.Fatalf("❌ Failed to initialize database: %v", err) + } + defer config.CloseDB() + + // Run migrations + db := config.GetDB() + if err := config.RunMigrations(db); err != nil { + log.Fatalf("❌ Failed to run migrations: %v", err) + } + + // Initialize Gin + if config.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.Default() + + // Apply middleware + router.Use(middleware.CORSMiddleware()) + router.Use(middleware.LoggerMiddleware()) + router.Use(middleware.RateLimiterMiddleware()) + + // Serve static files (uploads) + router.Static("/uploads", "./uploads") + router.Static("/css", "./web/css") + router.Static("/js", "./web/js") + + // Frontend routes - REFACTORED: nama file lebih simple + router.GET("/", func(c *gin.Context) { + c.File("./web/index.html") + }) + router.GET("/login", func(c *gin.Context) { + c.File("./web/login.html") + }) + router.GET("/register", func(c *gin.Context) { + c.File("./web/register.html") + }) + // ✅ REFACTORED: URL dan nama file lebih clean + router.GET("/admin", func(c *gin.Context) { + c.File("./web/admin.html") + }) + router.GET("/manager", func(c *gin.Context) { + c.File("./web/manager.html") + }) + router.GET("/user", func(c *gin.Context) { + c.File("./web/user.html") + }) + + // Setup API routes + routes.SetupRoutes(router, db) + + // Start background workers + expireWorker := workers.NewExpireWorker(db) + auditWorker := workers.NewAuditWorker(db) + matchingWorker := workers.NewMatchingWorker(db) + notificationWorker := workers.NewNotificationWorker(db) + + go expireWorker.Start() + go auditWorker.Start() + go matchingWorker.Start() + go notificationWorker.Start() + + // Setup graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // Get server config + serverConfig := config.GetServerConfig() + port := serverConfig.Port + + // Start server + go func() { + log.Printf("🚀 Server started on http://localhost:%s", port) + log.Printf("🔌 API available at http://localhost:%s/api", port) + log.Printf("🌐 Frontend available at http://localhost:%s", port) + + if err := router.Run(":" + port); err != nil { + log.Fatalf("❌ Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal + <-quit + log.Println("\n🛑 Shutting down server...") + + // Stop workers + expireWorker.Stop() + auditWorker.Stop() + matchingWorker.Stop() + notificationWorker.Stop() + + log.Println("✅ Server exited gracefully") +} \ No newline at end of file diff --git a/lost-and-found/database/schema.sql b/lost-and-found/database/schema.sql new file mode 100644 index 0000000..241f7ae --- /dev/null +++ b/lost-and-found/database/schema.sql @@ -0,0 +1,300 @@ +-- Lost & Found Database Schema +-- MySQL/MariaDB Database + +-- Set charset dan collation +SET NAMES utf8mb4; +SET CHARACTER SET utf8mb4; + +-- Drop tables if exists (untuk clean migration) +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS revision_logs; +DROP TABLE IF EXISTS match_results; +DROP TABLE IF EXISTS claim_verifications; +DROP TABLE IF EXISTS audit_logs; +DROP TABLE IF EXISTS archives; +DROP TABLE IF EXISTS claims; +DROP TABLE IF EXISTS items; +DROP TABLE IF EXISTS lost_items; +DROP TABLE IF EXISTS categories; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS roles; + +-- ============================================ +-- ROLES TABLE +-- ============================================ +CREATE TABLE roles ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_roles_name (name), + INDEX idx_roles_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- USERS TABLE +-- ============================================ +CREATE TABLE users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + nrp VARCHAR(20) UNIQUE DEFAULT NULL, + phone VARCHAR(20) DEFAULT NULL, + role_id INT UNSIGNED NOT NULL DEFAULT 3, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT, + INDEX idx_users_email (email), + INDEX idx_users_nrp (nrp), + INDEX idx_users_role_id (role_id), + INDEX idx_users_status (status), + INDEX idx_users_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- CATEGORIES TABLE +-- ============================================ +CREATE TABLE categories ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_categories_slug (slug), + INDEX idx_categories_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- ITEMS TABLE (Barang Ditemukan) +-- ============================================ +CREATE TABLE items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + category_id INT UNSIGNED NOT NULL, + photo_url VARCHAR(255) DEFAULT NULL, + location VARCHAR(200) NOT NULL, + description TEXT NOT NULL COMMENT 'RAHASIA - untuk verifikasi', + date_found TIMESTAMP NOT NULL, + status VARCHAR(50) DEFAULT 'unclaimed', + reporter_id INT UNSIGNED NOT NULL, + reporter_name VARCHAR(100) NOT NULL, + reporter_contact VARCHAR(50) NOT NULL, + expires_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, + FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE RESTRICT, + INDEX idx_items_category_id (category_id), + INDEX idx_items_status (status), + INDEX idx_items_reporter_id (reporter_id), + INDEX idx_items_date_found (date_found), + INDEX idx_items_expires_at (expires_at), + INDEX idx_items_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- LOST_ITEMS TABLE (Barang Hilang) +-- ============================================ +CREATE TABLE lost_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + name VARCHAR(100) NOT NULL, + category_id INT UNSIGNED NOT NULL, + color VARCHAR(50) DEFAULT NULL, + location VARCHAR(200) DEFAULT NULL, + description TEXT NOT NULL, + date_lost TIMESTAMP NOT NULL, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, + INDEX idx_lost_items_user_id (user_id), + INDEX idx_lost_items_category_id (category_id), + INDEX idx_lost_items_status (status), + INDEX idx_lost_items_date_lost (date_lost), + INDEX idx_lost_items_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- CLAIMS TABLE (Klaim Barang) +-- ============================================ +CREATE TABLE claims ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + item_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + description TEXT NOT NULL COMMENT 'Deskripsi dari user untuk verifikasi', + proof_url VARCHAR(255) DEFAULT NULL, + contact VARCHAR(50) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + notes TEXT DEFAULT NULL, + verified_at TIMESTAMP NULL DEFAULT NULL, + verified_by INT UNSIGNED DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_claims_item_id (item_id), + INDEX idx_claims_user_id (user_id), + INDEX idx_claims_status (status), + INDEX idx_claims_verified_by (verified_by), + INDEX idx_claims_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- CLAIM_VERIFICATIONS TABLE (Data Verifikasi Klaim) +-- ============================================ +CREATE TABLE claim_verifications ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + claim_id INT UNSIGNED UNIQUE NOT NULL, + similarity_score DECIMAL(5,2) DEFAULT 0.00, + matched_keywords TEXT DEFAULT NULL, + verification_notes TEXT DEFAULT NULL, + is_auto_matched BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE, + INDEX idx_claim_verifications_claim_id (claim_id), + INDEX idx_claim_verifications_similarity_score (similarity_score), + INDEX idx_claim_verifications_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- MATCH_RESULTS TABLE (Hasil Auto-Matching) +-- ============================================ +CREATE TABLE match_results ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + lost_item_id INT UNSIGNED NOT NULL, + item_id INT UNSIGNED NOT NULL, + similarity_score DECIMAL(5,2) NOT NULL, + matched_fields TEXT DEFAULT NULL, + matched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_notified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + INDEX idx_match_results_lost_item_id (lost_item_id), + INDEX idx_match_results_item_id (item_id), + INDEX idx_match_results_similarity_score (similarity_score), + INDEX idx_match_results_is_notified (is_notified), + INDEX idx_match_results_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- ARCHIVES TABLE (Barang yang Diarsipkan) +-- ============================================ +CREATE TABLE archives ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + item_id INT UNSIGNED UNIQUE NOT NULL COMMENT 'Original item ID', + name VARCHAR(100) NOT NULL, + category_id INT UNSIGNED NOT NULL, + photo_url VARCHAR(255) DEFAULT NULL, + location VARCHAR(200) DEFAULT NULL, + description TEXT DEFAULT NULL, + date_found TIMESTAMP NULL DEFAULT NULL, + status VARCHAR(50) DEFAULT NULL, + reporter_name VARCHAR(100) DEFAULT NULL, + reporter_contact VARCHAR(50) DEFAULT NULL, + archived_reason VARCHAR(100) DEFAULT NULL COMMENT 'expired, case_closed', + claimed_by INT UNSIGNED DEFAULT NULL, + archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT, + FOREIGN KEY (claimed_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_archives_item_id (item_id), + INDEX idx_archives_category_id (category_id), + INDEX idx_archives_archived_reason (archived_reason), + INDEX idx_archives_archived_at (archived_at), + INDEX idx_archives_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- REVISION_LOGS TABLE (Audit Trail Edit Barang) +-- ============================================ +CREATE TABLE revision_logs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + item_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + field_name VARCHAR(50) NOT NULL, + old_value TEXT DEFAULT NULL, + new_value TEXT DEFAULT NULL, + reason TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_revision_logs_item_id (item_id), + INDEX idx_revision_logs_user_id (user_id), + INDEX idx_revision_logs_created_at (created_at), + INDEX idx_revision_logs_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- AUDIT_LOGS TABLE (System Audit Trail) +-- ============================================ +CREATE TABLE audit_logs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED DEFAULT NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) DEFAULT NULL, + entity_id INT UNSIGNED DEFAULT NULL, + details TEXT DEFAULT NULL, + ip_address VARCHAR(50) DEFAULT NULL, + user_agent VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_audit_logs_user_id (user_id), + INDEX idx_audit_logs_action (action), + INDEX idx_audit_logs_entity_type (entity_type), + INDEX idx_audit_logs_entity_id (entity_id), + INDEX idx_audit_logs_created_at (created_at), + INDEX idx_audit_logs_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- NOTIFICATIONS TABLE (Notifikasi User) +-- ============================================ +CREATE TABLE notifications ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + message TEXT NOT NULL, + entity_type VARCHAR(50) DEFAULT NULL, + entity_id INT UNSIGNED DEFAULT NULL, + is_read BOOLEAN DEFAULT FALSE, + read_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_notifications_user_id (user_id), + INDEX idx_notifications_type (type), + INDEX idx_notifications_is_read (is_read), + INDEX idx_notifications_created_at (created_at), + INDEX idx_notifications_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================ +-- SUCCESS MESSAGE +-- ============================================ +SELECT '✅ Database schema created successfully!' AS Status; +SELECT '📋 Total tables: 13' AS Info; +SELECT '🔑 Indexes created on all tables' AS Info; +SELECT '📝 Next step: Run seed.sql to populate initial data' AS NextStep; \ No newline at end of file diff --git a/lost-and-found/database/seed.sql b/lost-and-found/database/seed.sql new file mode 100644 index 0000000..db4c790 --- /dev/null +++ b/lost-and-found/database/seed.sql @@ -0,0 +1,202 @@ +-- Lost & Found Database Seed Data +-- MySQL/MariaDB Database + +SET NAMES utf8mb4; +SET CHARACTER SET utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ============================================ +-- CLEAR EXISTING DATA (Optional - untuk re-seed) +-- ============================================ +-- TRUNCATE TABLE notifications; +-- TRUNCATE TABLE revision_logs; +-- TRUNCATE TABLE match_results; +-- TRUNCATE TABLE claim_verifications; +-- TRUNCATE TABLE audit_logs; +-- TRUNCATE TABLE archives; +-- TRUNCATE TABLE claims; +-- TRUNCATE TABLE items; +-- TRUNCATE TABLE lost_items; +-- TRUNCATE TABLE categories; +-- TRUNCATE TABLE users; +-- TRUNCATE TABLE roles; + +-- ============================================ +-- SEED ROLES +-- ============================================ +INSERT INTO roles (name, description) VALUES +('admin', 'Administrator with full access'), +('manager', 'Manager/Cleaning Service - can verify claims'), +('user', 'Regular user/student'); + +-- ============================================ +-- SEED CATEGORIES +-- ============================================ +INSERT INTO categories (name, slug, description) VALUES +('Pakaian', 'pakaian', 'Jaket, baju, celana, dll'), +('Alat Makan', 'alat_makan', 'Botol minum, lunchbox, dll'), +('Aksesoris', 'aksesoris', 'Jam tangan, gelang, kacamata, dll'), +('Elektronik', 'elektronik', 'Kalkulator, mouse, headset, charger, dll'), +('Alat Tulis', 'alat_tulis', 'Buku, pulpen, tipe-x, dll'), +('Lainnya', 'lainnya', 'Kategori lainnya'); + +-- ============================================ +-- SEED USERS +-- Password untuk semua user: "password123" +-- Hash bcrypt: $2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi +-- ============================================ + +-- Admin User +INSERT INTO users (name, email, password, nrp, phone, role_id, status) VALUES +('Admin System', 'admin@lostandfound.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '1234567890', '081234567890', 1, 'active'); + +-- Manager Users +INSERT INTO users (name, email, password, nrp, phone, role_id, status) VALUES +('Pak Budi', 'manager1@lostandfound.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '2234567890', '081234567891', 2, 'active'), +('Bu Siti', 'manager2@lostandfound.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '2234567891', '081234567892', 2, 'active'); + +-- Regular Users (Students) +INSERT INTO users (name, email, password, nrp, phone, role_id, status) VALUES +('Ahmad Rizki', 'ahmad@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211001', '081234567893', 3, 'active'), +('Siti Nurhaliza', 'siti@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211002', '081234567894', 3, 'active'), +('Budi Santoso', 'budi@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211003', '081234567895', 3, 'active'), +('Dewi Lestari', 'dewi@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211004', '081234567896', 3, 'active'), +('Eko Prasetyo', 'eko@student.com', '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', '5025211005', '081234567897', 3, 'active'); + +-- ============================================ +-- SEED ITEMS (Barang Ditemukan) +-- ============================================ +INSERT INTO items (name, category_id, photo_url, location, description, date_found, status, reporter_id, reporter_name, reporter_contact, expires_at) VALUES +-- Pakaian +('Jaket Hitam Nike', 1, '', 'Gedung A lantai 2', 'Jaket hitam merk Nike ukuran M, ada logo putih di dada kiri, resleting berwarna silver', '2024-01-15 09:30:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-04-15 09:30:00'), +('Sweater Abu-abu', 1, '', 'Perpustakaan', 'Sweater abu-abu polos, ada tulisan "ITS" di punggung dengan huruf biru', '2024-01-18 14:20:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-04-18 14:20:00'), + +-- Alat Makan +('Botol Minum Tupperware Biru', 2, '', 'Kantin Utama', 'Botol minum tupperware warna biru tua, ada stiker nama "RARA" di samping, tutup warna putih', '2024-01-20 12:00:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-04-20 12:00:00'), +('Lunchbox Pink', 2, '', 'Gedung B lantai 1', 'Kotak makan plastik warna pink dengan 2 sekat, ada gambar Hello Kitty di tutup', '2024-01-22 11:30:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-04-22 11:30:00'), + +-- Elektronik +('Kalkulator Casio FX-991ID Plus', 4, '', 'Ruang Kelas C101', 'Kalkulator scientific Casio FX-991ID Plus, warna hitam, ada goresan kecil di layar', '2024-01-25 08:15:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-04-25 08:15:00'), +('Mouse Wireless Logitech', 4, '', 'Lab Komputer', 'Mouse wireless Logitech M185, warna merah, tidak ada baterai dan receiver USB', '2024-01-28 15:45:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-04-28 15:45:00'), +('Charger Laptop HP', 4, '', 'Perpustakaan meja 15', 'Charger laptop HP original 65W, kabel agak kusut, ujung konektor bulat', '2024-02-01 10:20:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-05-01 10:20:00'), + +-- Aksesoris +('Jam Tangan Casio G-Shock', 3, '', 'Toilet Gedung A', 'Jam tangan Casio G-Shock warna hitam dengan strip orange, tali karet, ada goresan di layar', '2024-02-03 13:00:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-05-03 13:00:00'), +('Kacamata Minus', 3, '', 'Masjid Kampus', 'Kacamata minus frame hitam persegi, lensa agak tebal, ada case warna coklat', '2024-02-05 16:30:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-05-05 16:30:00'), + +-- Alat Tulis +('Buku Kalkulus', 5, '', 'Gedung C lantai 3', 'Buku Kalkulus karangan Purcell edisi 9, sampul biru, ada coretan nama di halaman pertama (nama dicoret)', '2024-02-08 09:00:00', 'unclaimed', 3, 'Bu Siti', '081234567892', '2024-05-08 09:00:00'), +('Pensil Mekanik Rotring', 5, '', 'Studio Gambar', 'Pensil mekanik Rotring Rapid Pro 0.5mm warna silver, agak berat, ada penyok kecil di badan', '2024-02-10 14:45:00', 'unclaimed', 2, 'Pak Budi', '081234567891', '2024-05-10 14:45:00'); + +-- ============================================ +-- SEED LOST_ITEMS (Barang Hilang) +-- ============================================ +INSERT INTO lost_items (user_id, name, category_id, color, location, description, date_lost, status) VALUES +-- User Ahmad +(4, 'Jaket Hitam', 1, 'Hitam', 'Gedung A', 'Jaket hitam merk Nike ukuran M dengan logo putih di dada', '2024-01-15 08:00:00', 'active'), +(4, 'Kalkulator Casio Scientific', 4, 'Hitam', 'Ruang Kelas', 'Kalkulator scientific Casio FX-991ID Plus warna hitam', '2024-01-25 07:00:00', 'active'), + +-- User Siti +(5, 'Botol Minum Biru', 2, 'Biru', 'Kantin', 'Botol tupperware biru dengan stiker nama RARA', '2024-01-20 11:00:00', 'active'), +(5, 'Jam Tangan Casio', 3, 'Hitam', 'Toilet', 'Jam tangan G-Shock hitam dengan strip orange', '2024-02-03 12:00:00', 'active'), + +-- User Budi +(6, 'Mouse Wireless', 4, 'Merah', 'Lab Komputer', 'Mouse wireless Logitech warna merah tanpa receiver', '2024-01-28 14:00:00', 'active'), + +-- User Dewi +(7, 'Buku Kalkulus', 5, 'Biru', 'Gedung C', 'Buku Kalkulus Purcell edisi 9 sampul biru', '2024-02-08 08:00:00', 'active'), + +-- User Eko +(8, 'Kacamata Minus', 3, 'Hitam', 'Masjid', 'Kacamata frame hitam persegi dengan case coklat', '2024-02-05 15:00:00', 'active'); + +-- ============================================ +-- SEED CLAIMS (Klaim Barang) +-- ============================================ +INSERT INTO claims (item_id, user_id, description, proof_url, contact, status, notes, verified_at, verified_by) VALUES +-- Claim pending (belum diverifikasi) +(1, 4, 'Jaket Nike hitam ukuran M, ada logo putih di dada kiri, resleting silver', '', '081234567893', 'pending', NULL, NULL, NULL), +(3, 5, 'Botol tupperware biru dengan stiker nama RARA di samping, tutup putih', '', '081234567894', 'pending', NULL, NULL, NULL), + +-- Claim yang sudah approved +(5, 4, 'Kalkulator Casio FX-991ID Plus warna hitam, ada goresan kecil di layar', '', '081234567893', 'approved', 'Deskripsi cocok, barang diserahkan', DATE_SUB(NOW(), INTERVAL 2 DAY), 2); + +-- ============================================ +-- SEED CLAIM_VERIFICATIONS +-- ============================================ +INSERT INTO claim_verifications (claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched) VALUES +(1, 85.50, '["jaket", "nike", "hitam", "logo", "putih", "resleting", "silver"]', 'High similarity detected', FALSE), +(2, 92.30, '["botol", "tupperware", "biru", "stiker", "rara", "tutup", "putih"]', 'Very high similarity - likely owner', FALSE), +(3, 88.70, '["kalkulator", "casio", "hitam", "goresan", "layar"]', 'Verified and approved', FALSE); + +-- ============================================ +-- SEED MATCH_RESULTS (Auto-Matching) +-- ============================================ +INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, matched_at, is_notified) VALUES +(1, 1, 87.50, '{"name": 85, "category": 100, "description": 90}', '2024-01-15 10:00:00', TRUE), +(2, 5, 89.20, '{"name": 88, "category": 100, "description": 91}', '2024-01-25 09:00:00', TRUE), +(3, 3, 91.80, '{"name": 90, "category": 100, "color": 100, "description": 89}', '2024-01-20 13:00:00', TRUE), +(4, 8, 86.40, '{"name": 82, "category": 100, "color": 100, "description": 85}', '2024-02-03 14:00:00', TRUE), +(5, 6, 84.60, '{"name": 80, "category": 100, "color": 100, "description": 88}', '2024-01-28 16:00:00', FALSE); + +-- ============================================ +-- SEED NOTIFICATIONS +-- ============================================ +INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, is_read, read_at) VALUES +-- Notifikasi match ditemukan +(4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Jaket Hitam Nike', 'match', 1, TRUE, DATE_SUB(NOW(), INTERVAL 1 DAY)), +(4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Kalkulator Casio FX-991ID Plus', 'match', 2, FALSE, NULL), +(5, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Botol Minum Tupperware Biru', 'match', 3, FALSE, NULL), + +-- Notifikasi klaim disetujui +(4, 'claim_approved', 'Klaim Disetujui!', 'Klaim Anda untuk barang Kalkulator Casio FX-991ID Plus telah disetujui. Silakan ambil barang di tempat yang ditentukan.', 'claim', 3, FALSE, NULL), + +-- Notifikasi untuk manager (pending claims) +(2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Jaket Hitam Nike dari Ahmad Rizki', 'claim', 1, FALSE, NULL), +(2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Botol Minum Tupperware Biru dari Siti Nurhaliza', 'claim', 2, FALSE, NULL); + +-- ============================================ +-- SEED AUDIT_LOGS +-- ============================================ +INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES +-- Login activities +(1, 'login', 'user', 1, 'Admin logged in', '127.0.0.1', 'Mozilla/5.0'), +(2, 'login', 'user', 2, 'Manager logged in', '127.0.0.1', 'Mozilla/5.0'), +(4, 'login', 'user', 4, 'User logged in', '127.0.0.1', 'Mozilla/5.0'), + +-- Item creation activities +(2, 'create', 'item', 1, 'Item created: Jaket Hitam Nike', '127.0.0.1', 'Mozilla/5.0'), +(3, 'create', 'item', 2, 'Item created: Sweater Abu-abu', '127.0.0.1', 'Mozilla/5.0'), +(2, 'create', 'item', 3, 'Item created: Botol Minum Tupperware Biru', '127.0.0.1', 'Mozilla/5.0'), + +-- Lost item creation activities +(4, 'create', 'lost_item', 1, 'Lost item report created: Jaket Hitam', '127.0.0.1', 'Mozilla/5.0'), +(5, 'create', 'lost_item', 3, 'Lost item report created: Botol Minum Biru', '127.0.0.1', 'Mozilla/5.0'), + +-- Claim activities +(4, 'create', 'claim', 1, 'Claim created for item: Jaket Hitam Nike', '127.0.0.1', 'Mozilla/5.0'), +(5, 'create', 'claim', 2, 'Claim created for item: Botol Minum Tupperware Biru', '127.0.0.1', 'Mozilla/5.0'), +(4, 'create', 'claim', 3, 'Claim created for item: Kalkulator Casio FX-991ID Plus', '127.0.0.1', 'Mozilla/5.0'), + +-- Claim verification activity +(2, 'approve', 'claim', 3, 'Claim approved: Deskripsi cocok, barang diserahkan', '127.0.0.1', 'Mozilla/5.0'); + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================ +-- SUCCESS MESSAGE +-- ============================================ +SELECT '✅ Database seeded successfully!' AS Status; +SELECT '👥 Users created: 8 (1 admin, 2 managers, 5 students)' AS Info; +SELECT '📦 Items created: 11 found items' AS Info; +SELECT '🔍 Lost items created: 7 reports' AS Info; +SELECT '📋 Claims created: 3 (1 approved, 2 pending)' AS Info; +SELECT '🔔 Notifications created: 6' AS Info; +SELECT '📝 Audit logs created: 11' AS Info; +SELECT '' AS Empty; +SELECT '🔐 Login Credentials:' AS Credentials; +SELECT 'Admin: admin@lostandfound.com / password123' AS Admin; +SELECT 'Manager: manager1@lostandfound.com / password123' AS Manager1; +SELECT 'Manager: manager2@lostandfound.com / password123' AS Manager2; +SELECT 'Student: ahmad@student.com / password123' AS Student; +SELECT '' AS Empty2; +SELECT '🚀 Ready to use! Start the server with: make run' AS NextStep; \ No newline at end of file diff --git a/go.mod b/lost-and-found/go.mod similarity index 100% rename from go.mod rename to lost-and-found/go.mod diff --git a/lost-and-found/go.sum b/lost-and-found/go.sum new file mode 100644 index 0000000..d307780 --- /dev/null +++ b/lost-and-found/go.sum @@ -0,0 +1,131 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= +github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4= +github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/lost-and-found/internal/config/config.go b/lost-and-found/internal/config/config.go new file mode 100644 index 0000000..0d14ed2 --- /dev/null +++ b/lost-and-found/internal/config/config.go @@ -0,0 +1,66 @@ +package config + +import ( + "os" +) + +// Config holds all configuration for the application +type Config struct { + Database DatabaseConfig + JWT JWTConfig + Server ServerConfig +} + +// ServerConfig holds server configuration +type ServerConfig struct { + Port string + Environment string + UploadPath string + MaxUploadSize int64 + AllowedOrigins []string +} + +// GetConfig returns the application configuration +func GetConfig() *Config { + return &Config{ + Database: GetDatabaseConfig(), + JWT: GetJWTConfig(), + Server: GetServerConfig(), + } +} + +// GetServerConfig returns server configuration from environment +func GetServerConfig() ServerConfig { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + env := os.Getenv("ENVIRONMENT") + if env == "" { + env = "development" + } + + uploadPath := os.Getenv("UPLOAD_PATH") + if uploadPath == "" { + uploadPath = "./uploads" + } + + return ServerConfig{ + Port: port, + Environment: env, + UploadPath: uploadPath, + MaxUploadSize: 10 * 1024 * 1024, // 10MB + AllowedOrigins: []string{"*"}, // In production, specify exact origins + } +} + +// IsProduction checks if running in production environment +func IsProduction() bool { + return os.Getenv("ENVIRONMENT") == "production" +} + +// IsDevelopment checks if running in development environment +func IsDevelopment() bool { + return os.Getenv("ENVIRONMENT") != "production" +} \ No newline at end of file diff --git a/lost-and-found/internal/config/database.go b/lost-and-found/internal/config/database.go new file mode 100644 index 0000000..316c958 --- /dev/null +++ b/lost-and-found/internal/config/database.go @@ -0,0 +1,145 @@ +package config + +import ( + "fmt" + "log" + "lost-and-found/internal/models" + "os" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var db *gorm.DB + +// DatabaseConfig holds database connection configuration +type DatabaseConfig struct { + Host string + Port string + User string + Password string + DBName string + Charset string + ParseTime string + Loc string +} + +// GetDatabaseConfig returns database configuration from environment +func GetDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + Host: getEnv("DB_HOST", "localhost"), + Port: getEnv("DB_PORT", "3306"), + User: getEnv("DB_USER", "root"), + Password: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", "lost_and_found"), + Charset: getEnv("DB_CHARSET", "utf8mb4"), + ParseTime: getEnv("DB_PARSE_TIME", "True"), + Loc: getEnv("DB_LOC", "Local"), + } +} + +// InitDB initializes database connection +func InitDB() error { + config := GetDatabaseConfig() + + // Build DSN (Data Source Name) for MySQL + dsn := fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s", + config.User, + config.Password, + config.Host, + config.Port, + config.DBName, + config.Charset, + config.ParseTime, + config.Loc, + ) + + // Configure GORM logger + gormLogger := logger.Default + if IsDevelopment() { + gormLogger = logger.Default.LogMode(logger.Info) + } else { + gormLogger = logger.Default.LogMode(logger.Error) + } + + // Open database connection + var err error + db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: gormLogger, + NowFunc: func() time.Time { + return time.Now().Local() + }, + }) + + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + // Get underlying SQL database + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("failed to get database instance: %w", err) + } + + // Set connection pool settings + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + // Test connection + if err := sqlDB.Ping(); err != nil { + return fmt.Errorf("failed to ping database: %w", err) + } + + log.Println("✅ Database connected successfully") + + return nil +} + +// GetDB returns the database instance +func GetDB() *gorm.DB { + return db +} + +// RunMigrations runs database migrations +func RunMigrations(db *gorm.DB) error { + log.Println("⚠️ Auto-migration is disabled. Please run schema.sql manually via HeidiSQL") + log.Println("📋 Steps:") + log.Println(" 1. Open HeidiSQL") + log.Println(" 2. Connect to your MySQL database") + log.Println(" 3. Create database 'lost_and_found' if not exists") + log.Println(" 4. Run database/schema.sql") + log.Println(" 5. Run database/seed.sql (optional)") + + // Check if tables exist + if !db.Migrator().HasTable(&models.Role{}) { + return fmt.Errorf("❌ Tables not found. Please run database/schema.sql first") + } + + log.Println("✅ Database tables detected") + return nil +} + +// CloseDB closes database connection +func CloseDB() error { + if db != nil { + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() + } + return nil +} + +// Helper function to get environment variable with default value +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} \ No newline at end of file diff --git a/lost-and-found/internal/config/jwt.go b/lost-and-found/internal/config/jwt.go new file mode 100644 index 0000000..46b66d3 --- /dev/null +++ b/lost-and-found/internal/config/jwt.go @@ -0,0 +1,131 @@ +package config + +import ( + "errors" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var jwtConfig *JWTConfig + +// JWTConfig holds JWT configuration +type JWTConfig struct { + SecretKey string + ExpirationHours int + Issuer string +} + +// JWTClaims represents the JWT claims +type JWTClaims struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// InitJWT initializes JWT configuration +func InitJWT() { + secretKey := os.Getenv("JWT_SECRET_KEY") + if secretKey == "" { + secretKey = "your-secret-key-change-this-in-production" // Default for development + } + + jwtConfig = &JWTConfig{ + SecretKey: secretKey, + ExpirationHours: 24 * 7, // 7 days + Issuer: "lost-and-found-system", + } +} + +// GetJWTConfig returns JWT configuration +func GetJWTConfig() JWTConfig { + if jwtConfig == nil { + InitJWT() + } + return *jwtConfig +} + +// GenerateToken generates a new JWT token for a user +func GenerateToken(userID uint, email, role string) (string, error) { + config := GetJWTConfig() + + // Create claims + claims := JWTClaims{ + UserID: userID, + Email: email, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(config.ExpirationHours))), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: config.Issuer, + }, + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign token with secret key + tokenString, err := token.SignedString([]byte(config.SecretKey)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +// ValidateToken validates a JWT token and returns the claims +func ValidateToken(tokenString string) (*JWTClaims, error) { + config := GetJWTConfig() + + // Parse token + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("invalid signing method") + } + return []byte(config.SecretKey), nil + }) + + if err != nil { + return nil, err + } + + // Extract claims + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +// RefreshToken generates a new token with extended expiration +func RefreshToken(oldTokenString string) (string, error) { + // Validate old token + claims, err := ValidateToken(oldTokenString) + if err != nil { + return "", err + } + + // Generate new token with same user info + return GenerateToken(claims.UserID, claims.Email, claims.Role) +} + +// ExtractUserID extracts user ID from token string +func ExtractUserID(tokenString string) (uint, error) { + claims, err := ValidateToken(tokenString) + if err != nil { + return 0, err + } + return claims.UserID, nil +} + +// ExtractRole extracts role from token string +func ExtractRole(tokenString string) (string, error) { + claims, err := ValidateToken(tokenString) + if err != nil { + return "", err + } + return claims.Role, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/admin_controller.go b/lost-and-found/internal/controllers/admin_controller.go new file mode 100644 index 0000000..bd35d82 --- /dev/null +++ b/lost-and-found/internal/controllers/admin_controller.go @@ -0,0 +1,98 @@ +package controllers + +import ( + "lost-and-found/internal/repositories" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AdminController struct { + userService *services.UserService + itemRepo *repositories.ItemRepository + claimRepo *repositories.ClaimRepository + archiveRepo *repositories.ArchiveRepository + auditService *services.AuditService +} + +func NewAdminController(db *gorm.DB) *AdminController { + return &AdminController{ + userService: services.NewUserService(db), + itemRepo: repositories.NewItemRepository(db), + claimRepo: repositories.NewClaimRepository(db), + archiveRepo: repositories.NewArchiveRepository(db), + auditService: services.NewAuditService(db), + } +} + +// GetDashboardStats gets dashboard statistics (admin/manager) +// GET /api/admin/dashboard +func (c *AdminController) GetDashboardStats(ctx *gin.Context) { + stats := make(map[string]interface{}) + + // Item statistics + totalItems, _ := c.itemRepo.CountByStatus("") + unclaimedItems, _ := c.itemRepo.CountByStatus("unclaimed") + verifiedItems, _ := c.itemRepo.CountByStatus("verified") + expiredItems, _ := c.itemRepo.CountByStatus("expired") + + stats["items"] = map[string]interface{}{ + "total": totalItems, + "unclaimed": unclaimedItems, + "verified": verifiedItems, + "expired": expiredItems, + } + + // Claim statistics + totalClaims, _ := c.claimRepo.CountByStatus("") + pendingClaims, _ := c.claimRepo.CountByStatus("pending") + approvedClaims, _ := c.claimRepo.CountByStatus("approved") + rejectedClaims, _ := c.claimRepo.CountByStatus("rejected") + + stats["claims"] = map[string]interface{}{ + "total": totalClaims, + "pending": pendingClaims, + "approved": approvedClaims, + "rejected": rejectedClaims, + } + + // Archive statistics + archivedExpired, _ := c.archiveRepo.CountByReason("expired") + archivedClosed, _ := c.archiveRepo.CountByReason("case_closed") + + stats["archives"] = map[string]interface{}{ + "total": archivedExpired + archivedClosed, + "expired": archivedExpired, + "case_closed": archivedClosed, + } + + utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats retrieved", stats) +} + +// GetAuditLogs gets audit logs (admin only) +// GET /api/admin/audit-logs +func (c *AdminController) GetAuditLogs(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + action := ctx.Query("action") + entityType := ctx.Query("entity_type") + + var userID *uint + if userIDStr := ctx.Query("user_id"); userIDStr != "" { + id, _ := strconv.ParseUint(userIDStr, 10, 32) + userID = new(uint) + *userID = uint(id) + } + + logs, total, err := c.auditService.GetAllAuditLogs(page, limit, action, entityType, userID) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get audit logs", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Audit logs retrieved", logs, total, page, limit) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/archive_controller.go b/lost-and-found/internal/controllers/archive_controller.go new file mode 100644 index 0000000..8e8dcea --- /dev/null +++ b/lost-and-found/internal/controllers/archive_controller.go @@ -0,0 +1,68 @@ +package controllers + +import ( + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ArchiveController struct { + archiveService *services.ArchiveService +} + +func NewArchiveController(db *gorm.DB) *ArchiveController { + return &ArchiveController{ + archiveService: services.NewArchiveService(db), + } +} + +// GetAllArchives gets all archived items +// GET /api/archives +func (c *ArchiveController) GetAllArchives(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + reason := ctx.Query("reason") + search := ctx.Query("search") + + archives, total, err := c.archiveService.GetAllArchives(page, limit, reason, search) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get archives", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Archives retrieved", archives, total, page, limit) +} + +// GetArchiveByID gets archive by ID +// GET /api/archives/:id +func (c *ArchiveController) GetArchiveByID(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid archive ID", err.Error()) + return + } + + archive, err := c.archiveService.GetArchiveByID(uint(id)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "Archive not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Archive retrieved", archive.ToResponse()) +} + +// GetArchiveStats gets archive statistics +// GET /api/archives/stats +func (c *ArchiveController) GetArchiveStats(ctx *gin.Context) { + stats, err := c.archiveService.GetArchiveStats() + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Archive stats retrieved", stats) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/auth_controller.go b/lost-and-found/internal/controllers/auth_controller.go new file mode 100644 index 0000000..04936ab --- /dev/null +++ b/lost-and-found/internal/controllers/auth_controller.go @@ -0,0 +1,102 @@ +package controllers + +import ( + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AuthController struct { + authService *services.AuthService +} + +func NewAuthController(db *gorm.DB) *AuthController { + return &AuthController{ + authService: services.NewAuthService(db), + } +} + +// Register handles user registration +// POST /api/register +func (c *AuthController) Register(ctx *gin.Context) { + var req services.RegisterRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + // Get IP and User-Agent + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + // Register user + result, err := c.authService.Register(req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Registration failed", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusCreated, "Registration successful", result) +} + +// Login handles user login +// POST /api/login +func (c *AuthController) Login(ctx *gin.Context) { + var req services.LoginRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + // Get IP and User-Agent + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + // Login user + result, err := c.authService.Login(req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "Login failed", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Login successful", result) +} + +// RefreshToken handles token refresh +// POST /api/refresh-token +func (c *AuthController) RefreshToken(ctx *gin.Context) { + var req struct { + Token string `json:"token" binding:"required"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + // Refresh token + newToken, err := c.authService.RefreshToken(req.Token) + if err != nil { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "Token refresh failed", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Token refreshed", gin.H{ + "token": newToken, + }) +} + +// GetMe returns current user info +// GET /api/me +func (c *AuthController) GetMe(ctx *gin.Context) { + user, exists := ctx.Get("user") + if !exists { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "") + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "User info retrieved", user) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/category_controller.go b/lost-and-found/internal/controllers/category_controller.go new file mode 100644 index 0000000..69f326f --- /dev/null +++ b/lost-and-found/internal/controllers/category_controller.go @@ -0,0 +1,129 @@ +package controllers + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type CategoryController struct { + categoryService *services.CategoryService +} + +func NewCategoryController(db *gorm.DB) *CategoryController { + return &CategoryController{ + categoryService: services.NewCategoryService(db), + } +} + +// GetAllCategories gets all categories +// GET /api/categories +func (c *CategoryController) GetAllCategories(ctx *gin.Context) { + categories, err := c.categoryService.GetAllCategories() + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get categories", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Categories retrieved", categories) +} + +// GetCategoryByID gets category by ID +// GET /api/categories/:id +func (c *CategoryController) GetCategoryByID(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error()) + return + } + + category, err := c.categoryService.GetCategoryByID(uint(id)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "Category not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Category retrieved", category.ToResponse()) +} + +// CreateCategory creates a new category (admin only) +// POST /api/categories +func (c *CategoryController) CreateCategory(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + var req services.CreateCategoryRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + category, err := c.categoryService.CreateCategory(admin.ID, req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create category", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusCreated, "Category created", category.ToResponse()) +} + +// UpdateCategory updates a category (admin only) +// PUT /api/categories/:id +func (c *CategoryController) UpdateCategory(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error()) + return + } + + var req services.UpdateCategoryRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + category, err := c.categoryService.UpdateCategory(admin.ID, uint(categoryID), req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update category", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Category updated", category.ToResponse()) +} + +// DeleteCategory deletes a category (admin only) +// DELETE /api/categories/:id +func (c *CategoryController) DeleteCategory(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.categoryService.DeleteCategory(admin.ID, uint(categoryID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete category", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Category deleted", nil) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/claim_controller.go b/lost-and-found/internal/controllers/claim_controller.go new file mode 100644 index 0000000..53fdc91 --- /dev/null +++ b/lost-and-found/internal/controllers/claim_controller.go @@ -0,0 +1,247 @@ +package controllers + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ClaimController struct { + claimService *services.ClaimService + verificationService *services.VerificationService +} + +func NewClaimController(db *gorm.DB) *ClaimController { + return &ClaimController{ + claimService: services.NewClaimService(db), + verificationService: services.NewVerificationService(db), + } +} + +// GetAllClaims gets all claims +// GET /api/claims +func (c *ClaimController) GetAllClaims(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + status := ctx.Query("status") + + var itemID, userID *uint + if itemIDStr := ctx.Query("item_id"); itemIDStr != "" { + id, _ := strconv.ParseUint(itemIDStr, 10, 32) + itemID = new(uint) + *itemID = uint(id) + } + + // If regular user, only show their claims + if userObj, exists := ctx.Get("user"); exists { + user := userObj.(*models.User) + if user.IsUser() { + userID = &user.ID + } + } + + claims, total, err := c.claimService.GetAllClaims(page, limit, status, itemID, userID) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit) +} + +// GetClaimByID gets claim by ID +// GET /api/claims/:id +func (c *ClaimController) GetClaimByID(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error()) + return + } + + isManager := false + if userObj, exists := ctx.Get("user"); exists { + user := userObj.(*models.User) + isManager = user.IsManager() || user.IsAdmin() + } + + claim, err := c.claimService.GetClaimByID(uint(id), isManager) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "Claim not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Claim retrieved", claim) +} + +// CreateClaim creates a new claim +// POST /api/claims +func (c *ClaimController) CreateClaim(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + var req services.CreateClaimRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + claim, err := c.claimService.CreateClaim(user.ID, req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create claim", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusCreated, "Claim created", claim.ToResponse()) +} + +// VerifyClaim verifies a claim (manager only) +// POST /api/claims/:id/verify +func (c *ClaimController) VerifyClaim(ctx *gin.Context) { + managerObj, _ := ctx.Get("user") + manager := managerObj.(*models.User) + + claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error()) + return + } + + var req services.VerifyClaimRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + // Auto-verify description similarity + verification, err := c.verificationService.VerifyClaimDescription(uint(claimID)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + // Verify the claim + if err := c.claimService.VerifyClaim( + manager.ID, + uint(claimID), + req, + verification.SimilarityScore, + stringSliceToString(verification.MatchedKeywords), + ipAddress, + userAgent, + ); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to verify claim", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Claim verified", gin.H{ + "verification": verification, + }) +} + +// GetClaimVerification gets verification data for a claim +// GET /api/claims/:id/verification +func (c *ClaimController) GetClaimVerification(ctx *gin.Context) { + claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error()) + return + } + + verification, err := c.verificationService.VerifyClaimDescription(uint(claimID)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Verification retrieved", verification) +} + +// CloseClaim closes a claim (manager only) +// POST /api/claims/:id/close +func (c *ClaimController) CloseClaim(ctx *gin.Context) { + managerObj, _ := ctx.Get("user") + manager := managerObj.(*models.User) + + claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.claimService.CloseClaim(manager.ID, uint(claimID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to close claim", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Claim closed and archived", nil) +} + +// DeleteClaim deletes a claim +// DELETE /api/claims/:id +func (c *ClaimController) DeleteClaim(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.claimService.DeleteClaim(user.ID, uint(claimID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete claim", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Claim deleted", nil) +} + +// GetClaimsByUser gets claims by user +// GET /api/user/claims +func (c *ClaimController) GetClaimsByUser(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + claims, total, err := c.claimService.GetClaimsByUser(user.ID, page, limit) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit) +} + +// Helper function to convert string slice to string +func stringSliceToString(slice []string) string { + if len(slice) == 0 { + return "" + } + result := "" + for i, s := range slice { + if i > 0 { + result += ", " + } + result += s + } + return result +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/item_controller.go b/lost-and-found/internal/controllers/item_controller.go new file mode 100644 index 0000000..a90bb96 --- /dev/null +++ b/lost-and-found/internal/controllers/item_controller.go @@ -0,0 +1,222 @@ +package controllers + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ItemController struct { + itemService *services.ItemService + matchService *services.MatchService +} + +func NewItemController(db *gorm.DB) *ItemController { + return &ItemController{ + itemService: services.NewItemService(db), + matchService: services.NewMatchService(db), + } +} + +// GetAllItems gets all items (public) +// GET /api/items +func (c *ItemController) GetAllItems(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + status := ctx.Query("status") + category := ctx.Query("category") + search := ctx.Query("search") + + items, total, err := c.itemService.GetAllItems(page, limit, status, category, search) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", items, total, page, limit) +} + +// GetItemByID gets item by ID +// GET /api/items/:id +func (c *ItemController) GetItemByID(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) + return + } + + // Check if user is manager or admin + isManager := false + if userObj, exists := ctx.Get("user"); exists { + user := userObj.(*models.User) + isManager = user.IsManager() || user.IsAdmin() + } + + item, err := c.itemService.GetItemByID(uint(id), isManager) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "Item not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", item) +} + +// CreateItem creates a new item +// POST /api/items +func (c *ItemController) CreateItem(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + var req services.CreateItemRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + item, err := c.itemService.CreateItem(user.ID, req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create item", err.Error()) + return + } + + // Auto-match with lost items + go c.matchService.AutoMatchNewItem(item.ID) + + utils.SuccessResponse(ctx, http.StatusCreated, "Item created", item.ToDetailResponse()) +} + +// UpdateItem updates an item +// PUT /api/items/:id +func (c *ItemController) UpdateItem(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) + return + } + + var req services.UpdateItemRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + item, err := c.itemService.UpdateItem(user.ID, uint(itemID), req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update item", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Item updated", item.ToDetailResponse()) +} + +// UpdateItemStatus updates item status +// PATCH /api/items/:id/status +func (c *ItemController) UpdateItemStatus(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) + return + } + + var req struct { + Status string `json:"status" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.itemService.UpdateItemStatus(user.ID, uint(itemID), req.Status, ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Item status updated", nil) +} + +// DeleteItem deletes an item +// DELETE /api/items/:id +func (c *ItemController) DeleteItem(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.itemService.DeleteItem(user.ID, uint(itemID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete item", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Item deleted", nil) +} + +// GetItemsByReporter gets items by reporter +// GET /api/user/items +func (c *ItemController) GetItemsByReporter(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + items, total, err := c.itemService.GetItemsByReporter(user.ID, page, limit) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error()) + return + } + + var responses []models.ItemDetailResponse + for _, item := range items { + responses = append(responses, item.ToDetailResponse()) + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", responses, total, page, limit) +} + +// GetItemRevisionHistory gets revision history for an item +// GET /api/items/:id/revisions +func (c *ItemController) GetItemRevisionHistory(ctx *gin.Context) { + itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) + return + } + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + revisions, total, err := c.itemService.GetItemRevisionHistory(uint(itemID), page, limit) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get revision history", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Revision history retrieved", revisions, total, page, limit) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/lost_item_controller.go b/lost-and-found/internal/controllers/lost_item_controller.go new file mode 100644 index 0000000..ca3a758 --- /dev/null +++ b/lost-and-found/internal/controllers/lost_item_controller.go @@ -0,0 +1,193 @@ +package controllers + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type LostItemController struct { + lostItemService *services.LostItemService +} + +func NewLostItemController(db *gorm.DB) *LostItemController { + return &LostItemController{ + lostItemService: services.NewLostItemService(db), + } +} + +// GetAllLostItems gets all lost items +// GET /api/lost-items +func (c *LostItemController) GetAllLostItems(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + status := ctx.Query("status") + category := ctx.Query("category") + search := ctx.Query("search") + + var userID *uint + // If manager/admin, can see all. If user, only see their own + if userObj, exists := ctx.Get("user"); exists { + user := userObj.(*models.User) + if user.IsUser() { + userID = &user.ID + } + } + + lostItems, total, err := c.lostItemService.GetAllLostItems(page, limit, status, category, search, userID) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit) +} + +// GetLostItemByID gets lost item by ID +// GET /api/lost-items/:id +func (c *LostItemController) GetLostItemByID(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) + return + } + + lostItem, err := c.lostItemService.GetLostItemByID(uint(id)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "Lost item not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Lost item retrieved", lostItem.ToResponse()) +} + +// CreateLostItem creates a new lost item report +// POST /api/lost-items +func (c *LostItemController) CreateLostItem(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + var req services.CreateLostItemRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + lostItem, err := c.lostItemService.CreateLostItem(user.ID, req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create lost item report", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusCreated, "Lost item report created", lostItem.ToResponse()) +} + +// UpdateLostItem updates a lost item report +// PUT /api/lost-items/:id +func (c *LostItemController) UpdateLostItem(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) + return + } + + var req services.UpdateLostItemRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + lostItem, err := c.lostItemService.UpdateLostItem(user.ID, uint(lostItemID), req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update lost item report", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Lost item report updated", lostItem.ToResponse()) +} + +// UpdateLostItemStatus updates lost item status +// PATCH /api/lost-items/:id/status +func (c *LostItemController) UpdateLostItemStatus(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) + return + } + + var req struct { + Status string `json:"status" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.lostItemService.UpdateLostItemStatus(user.ID, uint(lostItemID), req.Status, ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Lost item status updated", nil) +} + +// DeleteLostItem deletes a lost item report +// DELETE /api/lost-items/:id +func (c *LostItemController) DeleteLostItem(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.lostItemService.DeleteLostItem(user.ID, uint(lostItemID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete lost item report", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Lost item report deleted", nil) +} + +// GetLostItemsByUser gets lost items by user +// GET /api/user/lost-items +func (c *LostItemController) GetLostItemsByUser(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + lostItems, total, err := c.lostItemService.GetLostItemsByUser(user.ID, page, limit) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error()) + return + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/match_controller.go b/lost-and-found/internal/controllers/match_controller.go new file mode 100644 index 0000000..66f5987 --- /dev/null +++ b/lost-and-found/internal/controllers/match_controller.go @@ -0,0 +1,86 @@ +package controllers + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type MatchController struct { + matchService *services.MatchService +} + +func NewMatchController(db *gorm.DB) *MatchController { + return &MatchController{ + matchService: services.NewMatchService(db), + } +} + +// FindSimilarItems finds similar items for a lost item +// POST /api/lost-items/:id/find-similar +func (c *MatchController) FindSimilarItems(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) + return + } + + // Only allow managers or the owner to search + // Add ownership check here if needed + + results, err := c.matchService.FindSimilarItems(uint(lostItemID)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to find similar items", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Similar items found", gin.H{ + "total": len(results), + "matches": results, + "user_id": user.ID, + }) +} + +// GetMatchesForLostItem gets all matches for a lost item +// GET /api/lost-items/:id/matches +func (c *MatchController) GetMatchesForLostItem(ctx *gin.Context) { + lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error()) + return + } + + matches, err := c.matchService.GetMatchesForLostItem(uint(lostItemID)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches) +} + +// GetMatchesForItem gets all matches for an item +// GET /api/items/:id/matches +func (c *MatchController) GetMatchesForItem(ctx *gin.Context) { + itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error()) + return + } + + matches, err := c.matchService.GetMatchesForItem(uint(itemID)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/report_controller.go b/lost-and-found/internal/controllers/report_controller.go new file mode 100644 index 0000000..f7545ac --- /dev/null +++ b/lost-and-found/internal/controllers/report_controller.go @@ -0,0 +1,109 @@ +package controllers + +import ( + "fmt" + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ReportController struct { + exportService *services.ExportService +} + +func NewReportController(db *gorm.DB) *ReportController { + return &ReportController{ + exportService: services.NewExportService(db), + } +} + +// ExportReport exports report based on request +// POST /api/reports/export +func (c *ReportController) ExportReport(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + var req services.ExportRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + var buffer *[]byte + var filename string + var contentType string + var err error + + // Generate report based on type and format + switch req.Type { + case "items": + if req.Format == "pdf" { + buf, e := c.exportService.ExportItemsToPDF(req, user.ID, ipAddress, userAgent) + if e != nil { + err = e + } else { + data := buf.Bytes() + buffer = &data + filename = fmt.Sprintf("items_report_%s.pdf", time.Now().Format("20060102")) + contentType = "application/pdf" + } + } else { + buf, e := c.exportService.ExportItemsToExcel(req, user.ID, ipAddress, userAgent) + if e != nil { + err = e + } else { + data := buf.Bytes() + buffer = &data + filename = fmt.Sprintf("items_report_%s.xlsx", time.Now().Format("20060102")) + contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + } + + case "archives": + buf, e := c.exportService.ExportArchivesToPDF(req, user.ID, ipAddress, userAgent) + if e != nil { + err = e + } else { + data := buf.Bytes() + buffer = &data + filename = fmt.Sprintf("archives_report_%s.pdf", time.Now().Format("20060102")) + contentType = "application/pdf" + } + + case "claims": + buf, e := c.exportService.ExportClaimsToPDF(req, user.ID, ipAddress, userAgent) + if e != nil { + err = e + } else { + data := buf.Bytes() + buffer = &data + filename = fmt.Sprintf("claims_report_%s.pdf", time.Now().Format("20060102")) + contentType = "application/pdf" + } + + default: + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid report type", "") + return + } + + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to generate report", err.Error()) + return + } + + // Set headers + ctx.Header("Content-Type", contentType) + ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + ctx.Header("Content-Length", fmt.Sprintf("%d", len(*buffer))) + + // Send file + ctx.Data(http.StatusOK, contentType, *buffer) +} \ No newline at end of file diff --git a/lost-and-found/internal/controllers/user_controller.go b/lost-and-found/internal/controllers/user_controller.go new file mode 100644 index 0000000..077f414 --- /dev/null +++ b/lost-and-found/internal/controllers/user_controller.go @@ -0,0 +1,237 @@ +package controllers + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/services" + "lost-and-found/internal/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type UserController struct { + userService *services.UserService +} + +func NewUserController(db *gorm.DB) *UserController { + return &UserController{ + userService: services.NewUserService(db), + } +} + +// GetProfile gets user profile +// GET /api/user/profile +func (c *UserController) GetProfile(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + profile, err := c.userService.GetProfile(user.ID) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "Profile not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Profile retrieved", profile.ToResponse()) +} + +// UpdateProfile updates user profile +// PUT /api/user/profile +func (c *UserController) UpdateProfile(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + var req services.UpdateProfileRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + updatedUser, err := c.userService.UpdateProfile(user.ID, req, ipAddress, userAgent) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Update failed", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Profile updated", updatedUser.ToResponse()) +} + +// ChangePassword changes user password +// POST /api/user/change-password +func (c *UserController) ChangePassword(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + var req services.ChangePasswordRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.userService.ChangePassword(user.ID, req, ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Password change failed", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Password changed successfully", nil) +} + +// GetStats gets user statistics +// GET /api/user/stats +func (c *UserController) GetStats(ctx *gin.Context) { + userObj, _ := ctx.Get("user") + user := userObj.(*models.User) + + stats, err := c.userService.GetUserStats(user.ID) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "Stats retrieved", stats) +} + +// GetAllUsers gets all users (admin only) +// GET /api/admin/users +func (c *UserController) GetAllUsers(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10")) + + users, total, err := c.userService.GetAllUsers(page, limit) + if err != nil { + utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get users", err.Error()) + return + } + + var responses []models.UserResponse + for _, user := range users { + responses = append(responses, user.ToResponse()) + } + + utils.SendPaginatedResponse(ctx, http.StatusOK, "Users retrieved", responses, total, page, limit) +} + +// GetUserByID gets user by ID (admin only) +// GET /api/admin/users/:id +func (c *UserController) GetUserByID(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) + return + } + + user, err := c.userService.GetUserByID(uint(id)) + if err != nil { + utils.ErrorResponse(ctx, http.StatusNotFound, "User not found", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "User retrieved", user.ToResponse()) +} + +// UpdateUserRole updates user role (admin only) +// PATCH /api/admin/users/:id/role +func (c *UserController) UpdateUserRole(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) + return + } + + var req struct { + RoleID uint `json:"role_id" binding:"required"` + } + if err := ctx.ShouldBindJSON(&req); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.userService.UpdateUserRole(admin.ID, uint(userID), req.RoleID, ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "User role updated", nil) +} + +// BlockUser blocks a user (admin only) +// POST /api/admin/users/:id/block +func (c *UserController) BlockUser(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.userService.BlockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to block user", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "User blocked", nil) +} + +// UnblockUser unblocks a user (admin only) +// POST /api/admin/users/:id/unblock +func (c *UserController) UnblockUser(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.userService.UnblockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to unblock user", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "User unblocked", nil) +} + +// DeleteUser deletes a user (admin only) +// DELETE /api/admin/users/:id +func (c *UserController) DeleteUser(ctx *gin.Context) { + adminObj, _ := ctx.Get("user") + admin := adminObj.(*models.User) + + userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error()) + return + } + + ipAddress := ctx.ClientIP() + userAgent := ctx.Request.UserAgent() + + if err := c.userService.DeleteUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil { + utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete user", err.Error()) + return + } + + utils.SuccessResponse(ctx, http.StatusOK, "User deleted", nil) +} \ No newline at end of file diff --git a/lost-and-found/internal/middleware/cors.go b/lost-and-found/internal/middleware/cors.go new file mode 100644 index 0000000..a4ced60 --- /dev/null +++ b/lost-and-found/internal/middleware/cors.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// CORSMiddleware handles CORS +func CORSMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*") + ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + + if ctx.Request.Method == "OPTIONS" { + ctx.AbortWithStatus(204) + return + } + + ctx.Next() + } +} \ No newline at end of file diff --git a/lost-and-found/internal/middleware/jwt_middleware.go b/lost-and-found/internal/middleware/jwt_middleware.go new file mode 100644 index 0000000..f01a10c --- /dev/null +++ b/lost-and-found/internal/middleware/jwt_middleware.go @@ -0,0 +1,105 @@ +package middleware + +import ( + "lost-and-found/internal/config" + "lost-and-found/internal/repositories" + "lost-and-found/internal/utils" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// JWTMiddleware validates JWT token +func JWTMiddleware(db *gorm.DB) gin.HandlerFunc { + return func(ctx *gin.Context) { + // Get token from Authorization header + authHeader := ctx.GetHeader("Authorization") + if authHeader == "" { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authorization header required", "") + ctx.Abort() + return + } + + // Check if Bearer token + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid authorization format", "") + ctx.Abort() + return + } + + tokenString := parts[1] + + // Validate token + claims, err := config.ValidateToken(tokenString) + if err != nil { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid or expired token", err.Error()) + ctx.Abort() + return + } + + // Get user from database + userRepo := repositories.NewUserRepository(db) + user, err := userRepo.FindByID(claims.UserID) + if err != nil { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "") + ctx.Abort() + return + } + + // Check if user is blocked + if user.IsBlocked() { + utils.ErrorResponse(ctx, http.StatusForbidden, "Account is blocked", "") + ctx.Abort() + return + } + + // Set user in context + ctx.Set("user", user) + ctx.Set("user_id", user.ID) + ctx.Set("user_role", user.Role.Name) + + ctx.Next() + } +} + +// OptionalJWTMiddleware validates JWT token if present (for public routes that can benefit from auth) +func OptionalJWTMiddleware(db *gorm.DB) gin.HandlerFunc { + return func(ctx *gin.Context) { + authHeader := ctx.GetHeader("Authorization") + if authHeader == "" { + ctx.Next() + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + ctx.Next() + return + } + + tokenString := parts[1] + claims, err := config.ValidateToken(tokenString) + if err != nil { + ctx.Next() + return + } + + userRepo := repositories.NewUserRepository(db) + user, err := userRepo.FindByID(claims.UserID) + if err != nil { + ctx.Next() + return + } + + if !user.IsBlocked() { + ctx.Set("user", user) + ctx.Set("user_id", user.ID) + ctx.Set("user_role", user.Role.Name) + } + + ctx.Next() + } +} \ No newline at end of file diff --git a/lost-and-found/internal/middleware/logger.go b/lost-and-found/internal/middleware/logger.go new file mode 100644 index 0000000..6e40d2e --- /dev/null +++ b/lost-and-found/internal/middleware/logger.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "fmt" + "time" + + "github.com/gin-gonic/gin" +) + +// LoggerMiddleware logs HTTP requests +func LoggerMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + // Start timer + startTime := time.Now() + + // Process request + ctx.Next() + + // Calculate latency + latency := time.Since(startTime) + + // Get request info + method := ctx.Request.Method + path := ctx.Request.URL.Path + statusCode := ctx.Writer.Status() + clientIP := ctx.ClientIP() + + // Get user ID if authenticated + userID := "guest" + if id, exists := ctx.Get("user_id"); exists { + userID = fmt.Sprintf("%v", id) + } + + // Log format + fmt.Printf("[%s] %s | %3d | %13v | %15s | %s | User: %s\n", + time.Now().Format("2006-01-02 15:04:05"), + method, + statusCode, + latency, + clientIP, + path, + userID, + ) + } +} \ No newline at end of file diff --git a/lost-and-found/internal/middleware/rate_limiter.go b/lost-and-found/internal/middleware/rate_limiter.go new file mode 100644 index 0000000..f913c3e --- /dev/null +++ b/lost-and-found/internal/middleware/rate_limiter.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "lost-and-found/internal/utils" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// RateLimiter stores rate limit data +type RateLimiter struct { + visitors map[string]*Visitor + mu sync.RWMutex + rate int // requests per window + window time.Duration // time window +} + +// Visitor represents a visitor's rate limit data +type Visitor struct { + lastSeen time.Time + count int +} + +var limiter *RateLimiter + +// InitRateLimiter initializes the rate limiter +func InitRateLimiter(rate int, window time.Duration) { + limiter = &RateLimiter{ + visitors: make(map[string]*Visitor), + rate: rate, + window: window, + } + + // Cleanup old visitors every minute + go limiter.cleanupVisitors() +} + +// cleanupVisitors removes old visitor entries +func (rl *RateLimiter) cleanupVisitors() { + for { + time.Sleep(time.Minute) + rl.mu.Lock() + for ip, visitor := range rl.visitors { + if time.Since(visitor.lastSeen) > rl.window { + delete(rl.visitors, ip) + } + } + rl.mu.Unlock() + } +} + +// getVisitor retrieves or creates a visitor +func (rl *RateLimiter) getVisitor(ip string) *Visitor { + rl.mu.Lock() + defer rl.mu.Unlock() + + visitor, exists := rl.visitors[ip] + if !exists { + visitor = &Visitor{ + lastSeen: time.Now(), + count: 0, + } + rl.visitors[ip] = visitor + } + + return visitor +} + +// isAllowed checks if request is allowed +func (rl *RateLimiter) isAllowed(ip string) bool { + visitor := rl.getVisitor(ip) + + rl.mu.Lock() + defer rl.mu.Unlock() + + // Reset count if window has passed + if time.Since(visitor.lastSeen) > rl.window { + visitor.count = 0 + visitor.lastSeen = time.Now() + } + + // Check if limit exceeded + if visitor.count >= rl.rate { + return false + } + + visitor.count++ + visitor.lastSeen = time.Now() + return true +} + +// RateLimiterMiddleware applies rate limiting +func RateLimiterMiddleware() gin.HandlerFunc { + // Initialize rate limiter (100 requests per minute) + if limiter == nil { + InitRateLimiter(100, time.Minute) + } + + return func(ctx *gin.Context) { + ip := ctx.ClientIP() + + if !limiter.isAllowed(ip) { + utils.ErrorResponse(ctx, http.StatusTooManyRequests, "Rate limit exceeded", "Too many requests, please try again later") + ctx.Abort() + return + } + + ctx.Next() + } +} \ No newline at end of file diff --git a/lost-and-found/internal/middleware/role_middleware.go b/lost-and-found/internal/middleware/role_middleware.go new file mode 100644 index 0000000..3c12a1b --- /dev/null +++ b/lost-and-found/internal/middleware/role_middleware.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/utils" + "net/http" + + "github.com/gin-gonic/gin" +) + +// RequireRole checks if user has required role +func RequireRole(allowedRoles ...string) gin.HandlerFunc { + return func(ctx *gin.Context) { + userObj, exists := ctx.Get("user") + if !exists { + utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "") + ctx.Abort() + return + } + + user := userObj.(*models.User) + userRole := user.Role.Name + + // Check if user has allowed role + hasRole := false + for _, role := range allowedRoles { + if userRole == role { + hasRole = true + break + } + } + + if !hasRole { + utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "") + ctx.Abort() + return + } + + ctx.Next() + } +} + +// RequireAdmin middleware (admin only) +func RequireAdmin() gin.HandlerFunc { + return RequireRole(models.RoleAdmin) +} + +// RequireManager middleware (manager and admin) +func RequireManager() gin.HandlerFunc { + return RequireRole(models.RoleAdmin, models.RoleManager) +} + +// RequireUser middleware (all authenticated users) +func RequireUser() gin.HandlerFunc { + return RequireRole(models.RoleAdmin, models.RoleManager, models.RoleUser) +} \ No newline at end of file diff --git a/lost-and-found/internal/models/archive.go b/lost-and-found/internal/models/archive.go new file mode 100644 index 0000000..858139d --- /dev/null +++ b/lost-and-found/internal/models/archive.go @@ -0,0 +1,110 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Archive represents an archived item +type Archive struct { + ID uint `gorm:"primaryKey" json:"id"` + ItemID uint `gorm:"not null;uniqueIndex" json:"item_id"` // Original item ID + Name string `gorm:"type:varchar(100);not null" json:"name"` + CategoryID uint `gorm:"not null" json:"category_id"` + Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"` + Location string `gorm:"type:varchar(200)" json:"location"` + Description string `gorm:"type:text" json:"description"` + DateFound time.Time `json:"date_found"` + Status string `gorm:"type:varchar(50)" json:"status"` // case_closed, expired + ReporterName string `gorm:"type:varchar(100)" json:"reporter_name"` + ReporterContact string `gorm:"type:varchar(50)" json:"reporter_contact"` + ArchivedReason string `gorm:"type:varchar(100)" json:"archived_reason"` // expired, case_closed + ClaimedBy *uint `json:"claimed_by"` // User who claimed (if applicable) + Claimer *User `gorm:"foreignKey:ClaimedBy" json:"claimer,omitempty"` + ArchivedAt time.Time `json:"archived_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for Archive model +func (Archive) TableName() string { + return "archives" +} + +// Archive reason constants +const ( + ArchiveReasonExpired = "expired" + ArchiveReasonCaseClosed = "case_closed" +) + +// BeforeCreate hook +func (a *Archive) BeforeCreate(tx *gorm.DB) error { + if a.ArchivedAt.IsZero() { + a.ArchivedAt = time.Now() + } + return nil +} + +// ArchiveResponse represents archive data for API responses +type ArchiveResponse struct { + ID uint `json:"id"` + ItemID uint `json:"item_id"` + Name string `json:"name"` + Category string `json:"category"` + PhotoURL string `json:"photo_url"` + Location string `json:"location"` + DateFound time.Time `json:"date_found"` + Status string `json:"status"` + ArchivedReason string `json:"archived_reason"` + ClaimedBy string `json:"claimed_by,omitempty"` + ArchivedAt time.Time `json:"archived_at"` +} + +// ToResponse converts Archive to ArchiveResponse +func (a *Archive) ToResponse() ArchiveResponse { + categoryName := "" + if a.Category.ID != 0 { + categoryName = a.Category.Name + } + + claimedByName := "" + if a.Claimer != nil && a.Claimer.ID != 0 { + claimedByName = a.Claimer.Name + } + + return ArchiveResponse{ + ID: a.ID, + ItemID: a.ItemID, + Name: a.Name, + Category: categoryName, + PhotoURL: a.PhotoURL, + Location: a.Location, + DateFound: a.DateFound, + Status: a.Status, + ArchivedReason: a.ArchivedReason, + ClaimedBy: claimedByName, + ArchivedAt: a.ArchivedAt, + } +} + +// CreateFromItem creates an Archive from an Item +func CreateFromItem(item *Item, reason string, claimedBy *uint) *Archive { + return &Archive{ + ItemID: item.ID, + Name: item.Name, + CategoryID: item.CategoryID, + PhotoURL: item.PhotoURL, + Location: item.Location, + Description: item.Description, + DateFound: item.DateFound, + Status: item.Status, + ReporterName: item.ReporterName, + ReporterContact: item.ReporterContact, + ArchivedReason: reason, + ClaimedBy: claimedBy, + ArchivedAt: time.Now(), + } +} \ No newline at end of file diff --git a/lost-and-found/internal/models/audit_log.go b/lost-and-found/internal/models/audit_log.go new file mode 100644 index 0000000..8363d69 --- /dev/null +++ b/lost-and-found/internal/models/audit_log.go @@ -0,0 +1,98 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// AuditLog represents an audit log entry +type AuditLog struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID *uint `json:"user_id"` // Nullable for system actions + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Action string `gorm:"type:varchar(50);not null" json:"action"` // create, update, delete, verify, login, etc. + EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, user, etc. + EntityID *uint `json:"entity_id"` + Details string `gorm:"type:text" json:"details"` + IPAddress string `gorm:"type:varchar(50)" json:"ip_address"` + UserAgent string `gorm:"type:varchar(255)" json:"user_agent"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for AuditLog model +func (AuditLog) TableName() string { + return "audit_logs" +} + +// Action constants +const ( + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionVerify = "verify" + ActionLogin = "login" + ActionLogout = "logout" + ActionBlock = "block" + ActionUnblock = "unblock" + ActionApprove = "approve" + ActionReject = "reject" + ActionExport = "export" +) + +// Entity type constants +const ( + EntityItem = "item" + EntityLostItem = "lost_item" + EntityClaim = "claim" + EntityUser = "user" + EntityCategory = "category" + EntityArchive = "archive" +) + +// AuditLogResponse represents audit log data for API responses +type AuditLogResponse struct { + ID uint `json:"id"` + UserName string `json:"user_name,omitempty"` + Action string `json:"action"` + EntityType string `json:"entity_type"` + EntityID *uint `json:"entity_id"` + Details string `json:"details"` + IPAddress string `json:"ip_address"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts AuditLog to AuditLogResponse +func (a *AuditLog) ToResponse() AuditLogResponse { + userName := "System" + if a.User != nil && a.User.ID != 0 { + userName = a.User.Name + } + + return AuditLogResponse{ + ID: a.ID, + UserName: userName, + Action: a.Action, + EntityType: a.EntityType, + EntityID: a.EntityID, + Details: a.Details, + IPAddress: a.IPAddress, + CreatedAt: a.CreatedAt, + } +} + +// CreateAuditLog creates a new audit log entry +func CreateAuditLog(db *gorm.DB, userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error { + log := &AuditLog{ + UserID: userID, + Action: action, + EntityType: entityType, + EntityID: entityID, + Details: details, + IPAddress: ipAddress, + UserAgent: userAgent, + } + + return db.Create(log).Error +} \ No newline at end of file diff --git a/lost-and-found/internal/models/category.go b/lost-and-found/internal/models/category.go new file mode 100644 index 0000000..a2db012 --- /dev/null +++ b/lost-and-found/internal/models/category.go @@ -0,0 +1,48 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Category represents an item category +type Category struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"` + Description string `gorm:"type:text" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Items []Item `gorm:"foreignKey:CategoryID" json:"items,omitempty"` + LostItems []LostItem `gorm:"foreignKey:CategoryID" json:"lost_items,omitempty"` +} + +// TableName specifies the table name for Category model +func (Category) TableName() string { + return "categories" +} + +// CategoryResponse represents category data for API responses +type CategoryResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + ItemCount int64 `json:"item_count,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts Category to CategoryResponse +func (c *Category) ToResponse() CategoryResponse { + return CategoryResponse{ + ID: c.ID, + Name: c.Name, + Slug: c.Slug, + Description: c.Description, + CreatedAt: c.CreatedAt, + } +} \ No newline at end of file diff --git a/lost-and-found/internal/models/claim.go b/lost-and-found/internal/models/claim.go new file mode 100644 index 0000000..7dce13c --- /dev/null +++ b/lost-and-found/internal/models/claim.go @@ -0,0 +1,164 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Claim represents a claim for a found item +type Claim struct { + ID uint `gorm:"primaryKey" json:"id"` + ItemID uint `gorm:"not null" json:"item_id"` + Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Description string `gorm:"type:text;not null" json:"description"` // User's description of the item + ProofURL string `gorm:"type:varchar(255)" json:"proof_url"` // Optional proof photo + Contact string `gorm:"type:varchar(50);not null" json:"contact"` + Status string `gorm:"type:varchar(50);default:'pending'" json:"status"` // pending, approved, rejected + Notes string `gorm:"type:text" json:"notes"` // Manager's notes (approval/rejection reason) + VerifiedAt *time.Time `json:"verified_at"` + VerifiedBy *uint `json:"verified_by"` // Manager who verified + Verifier *User `gorm:"foreignKey:VerifiedBy" json:"verifier,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Verification *ClaimVerification `gorm:"foreignKey:ClaimID" json:"verification,omitempty"` +} + +// TableName specifies the table name for Claim model +func (Claim) TableName() string { + return "claims" +} + +// Claim status constants +const ( + ClaimStatusPending = "pending" + ClaimStatusApproved = "approved" + ClaimStatusRejected = "rejected" +) + +// BeforeCreate hook +func (c *Claim) BeforeCreate(tx *gorm.DB) error { + if c.Status == "" { + c.Status = ClaimStatusPending + } + return nil +} + +// IsPending checks if claim is pending +func (c *Claim) IsPending() bool { + return c.Status == ClaimStatusPending +} + +// IsApproved checks if claim is approved +func (c *Claim) IsApproved() bool { + return c.Status == ClaimStatusApproved +} + +// IsRejected checks if claim is rejected +func (c *Claim) IsRejected() bool { + return c.Status == ClaimStatusRejected +} + +// Approve approves the claim +func (c *Claim) Approve(verifierID uint, notes string) { + c.Status = ClaimStatusApproved + c.VerifiedBy = &verifierID + now := time.Now() + c.VerifiedAt = &now + c.Notes = notes +} + +// Reject rejects the claim +func (c *Claim) Reject(verifierID uint, notes string) { + c.Status = ClaimStatusRejected + c.VerifiedBy = &verifierID + now := time.Now() + c.VerifiedAt = &now + c.Notes = notes +} + +// ClaimResponse represents claim data for API responses +type ClaimResponse struct { + ID uint `json:"id"` + ItemID uint `json:"item_id"` + ItemName string `json:"item_name"` + UserID uint `json:"user_id"` + UserName string `json:"user_name"` + Description string `json:"description"` + ProofURL string `json:"proof_url"` + Contact string `json:"contact"` + Status string `json:"status"` + Notes string `json:"notes"` + MatchPercentage *float64 `json:"match_percentage,omitempty"` + VerifiedAt *time.Time `json:"verified_at"` + VerifiedBy *uint `json:"verified_by"` + VerifierName string `json:"verifier_name,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts Claim to ClaimResponse +func (c *Claim) ToResponse() ClaimResponse { + itemName := "" + if c.Item.ID != 0 { + itemName = c.Item.Name + } + + userName := "" + if c.User.ID != 0 { + userName = c.User.Name + } + + verifierName := "" + if c.Verifier != nil && c.Verifier.ID != 0 { + verifierName = c.Verifier.Name + } + + var matchPercentage *float64 + if c.Verification != nil { + matchPercentage = &c.Verification.SimilarityScore + } + + return ClaimResponse{ + ID: c.ID, + ItemID: c.ItemID, + ItemName: itemName, + UserID: c.UserID, + UserName: userName, + Description: c.Description, + ProofURL: c.ProofURL, + Contact: c.Contact, + Status: c.Status, + Notes: c.Notes, + MatchPercentage: matchPercentage, + VerifiedAt: c.VerifiedAt, + VerifiedBy: c.VerifiedBy, + VerifierName: verifierName, + CreatedAt: c.CreatedAt, + } +} + +// ClaimDetailResponse includes item description for verification +type ClaimDetailResponse struct { + ClaimResponse + ItemDescription string `json:"item_description"` // Original item description for comparison +} + +// ToDetailResponse converts Claim to ClaimDetailResponse +func (c *Claim) ToDetailResponse() ClaimDetailResponse { + baseResponse := c.ToResponse() + + itemDescription := "" + if c.Item.ID != 0 { + itemDescription = c.Item.Description + } + + return ClaimDetailResponse{ + ClaimResponse: baseResponse, + ItemDescription: itemDescription, + } +} \ No newline at end of file diff --git a/lost-and-found/internal/models/claim_verification.go b/lost-and-found/internal/models/claim_verification.go new file mode 100644 index 0000000..bcc1929 --- /dev/null +++ b/lost-and-found/internal/models/claim_verification.go @@ -0,0 +1,77 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// ClaimVerification represents verification data for a claim +type ClaimVerification struct { + ID uint `gorm:"primaryKey" json:"id"` + ClaimID uint `gorm:"not null;uniqueIndex" json:"claim_id"` + Claim Claim `gorm:"foreignKey:ClaimID" json:"claim,omitempty"` + SimilarityScore float64 `gorm:"type:decimal(5,2);default:0" json:"similarity_score"` // Percentage match (0-100) + MatchedKeywords string `gorm:"type:text" json:"matched_keywords"` // Keywords that matched + VerificationNotes string `gorm:"type:text" json:"verification_notes"` // Manager's notes + IsAutoMatched bool `gorm:"default:false" json:"is_auto_matched"` // Was it auto-matched? + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for ClaimVerification model +func (ClaimVerification) TableName() string { + return "claim_verifications" +} + +// IsHighMatch checks if similarity score is high (>= 70%) +func (cv *ClaimVerification) IsHighMatch() bool { + return cv.SimilarityScore >= 70.0 +} + +// IsMediumMatch checks if similarity score is medium (50-69%) +func (cv *ClaimVerification) IsMediumMatch() bool { + return cv.SimilarityScore >= 50.0 && cv.SimilarityScore < 70.0 +} + +// IsLowMatch checks if similarity score is low (< 50%) +func (cv *ClaimVerification) IsLowMatch() bool { + return cv.SimilarityScore < 50.0 +} + +// GetMatchLevel returns the match level as string +func (cv *ClaimVerification) GetMatchLevel() string { + if cv.IsHighMatch() { + return "high" + } else if cv.IsMediumMatch() { + return "medium" + } + return "low" +} + +// ClaimVerificationResponse represents verification data for API responses +type ClaimVerificationResponse struct { + ID uint `json:"id"` + ClaimID uint `json:"claim_id"` + SimilarityScore float64 `json:"similarity_score"` + MatchLevel string `json:"match_level"` + MatchedKeywords string `json:"matched_keywords"` + VerificationNotes string `json:"verification_notes"` + IsAutoMatched bool `json:"is_auto_matched"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts ClaimVerification to ClaimVerificationResponse +func (cv *ClaimVerification) ToResponse() ClaimVerificationResponse { + return ClaimVerificationResponse{ + ID: cv.ID, + ClaimID: cv.ClaimID, + SimilarityScore: cv.SimilarityScore, + MatchLevel: cv.GetMatchLevel(), + MatchedKeywords: cv.MatchedKeywords, + VerificationNotes: cv.VerificationNotes, + IsAutoMatched: cv.IsAutoMatched, + CreatedAt: cv.CreatedAt, + } +} \ No newline at end of file diff --git a/lost-and-found/internal/models/item.go b/lost-and-found/internal/models/item.go new file mode 100644 index 0000000..5b462b5 --- /dev/null +++ b/lost-and-found/internal/models/item.go @@ -0,0 +1,152 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Item represents a found item +type Item struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + CategoryID uint `gorm:"not null" json:"category_id"` + Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"` + Location string `gorm:"type:varchar(200);not null" json:"location"` + Description string `gorm:"type:text;not null" json:"description"` // Keunikan (rahasia) + DateFound time.Time `gorm:"not null" json:"date_found"` + Status string `gorm:"type:varchar(50);default:'unclaimed'" json:"status"` // unclaimed, pending_claim, verified, case_closed, expired + ReporterID uint `gorm:"not null" json:"reporter_id"` + Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"` + ReporterName string `gorm:"type:varchar(100);not null" json:"reporter_name"` + ReporterContact string `gorm:"type:varchar(50);not null" json:"reporter_contact"` + ExpiresAt *time.Time `json:"expires_at"` // Auto-expire after 90 days + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Claims []Claim `gorm:"foreignKey:ItemID" json:"claims,omitempty"` + MatchResults []MatchResult `gorm:"foreignKey:ItemID" json:"match_results,omitempty"` + RevisionLogs []RevisionLog `gorm:"foreignKey:ItemID" json:"revision_logs,omitempty"` +} + +// TableName specifies the table name for Item model +func (Item) TableName() string { + return "items" +} + +// Status constants +const ( + ItemStatusUnclaimed = "unclaimed" + ItemStatusPendingClaim = "pending_claim" + ItemStatusVerified = "verified" + ItemStatusCaseClosed = "case_closed" + ItemStatusExpired = "expired" +) + +// BeforeCreate hook to set expiration date +func (i *Item) BeforeCreate(tx *gorm.DB) error { + // Set default status + if i.Status == "" { + i.Status = ItemStatusUnclaimed + } + + // Set expiration date (90 days from date found) + if i.ExpiresAt == nil { + expiresAt := i.DateFound.AddDate(0, 0, 90) // Add 90 days + i.ExpiresAt = &expiresAt + } + + return nil +} + +// IsExpired checks if item has expired +func (i *Item) IsExpired() bool { + if i.ExpiresAt == nil { + return false + } + return time.Now().After(*i.ExpiresAt) +} + +// CanBeClaimed checks if item can be claimed +func (i *Item) CanBeClaimed() bool { + return i.Status == ItemStatusUnclaimed && !i.IsExpired() +} + +// CanBeEdited checks if item can be edited +func (i *Item) CanBeEdited() bool { + // Cannot edit if case is closed or expired + return i.Status != ItemStatusCaseClosed && i.Status != ItemStatusExpired +} + +// ItemPublicResponse represents item data for public view (without sensitive info) +type ItemPublicResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + PhotoURL string `json:"photo_url"` + Location string `json:"location"` + DateFound time.Time `json:"date_found"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// ToPublicResponse converts Item to ItemPublicResponse (hides description, reporter details) +func (i *Item) ToPublicResponse() ItemPublicResponse { + categoryName := "" + if i.Category.ID != 0 { + categoryName = i.Category.Name + } + + return ItemPublicResponse{ + ID: i.ID, + Name: i.Name, + Category: categoryName, + PhotoURL: i.PhotoURL, + Location: i.Location, + DateFound: i.DateFound, + Status: i.Status, + CreatedAt: i.CreatedAt, + } +} + +// ItemDetailResponse represents full item data for authorized users +type ItemDetailResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + PhotoURL string `json:"photo_url"` + Location string `json:"location"` + Description string `json:"description"` + DateFound time.Time `json:"date_found"` + Status string `json:"status"` + ReporterName string `json:"reporter_name"` + ReporterContact string `json:"reporter_contact"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// ToDetailResponse converts Item to ItemDetailResponse (includes all info) +func (i *Item) ToDetailResponse() ItemDetailResponse { + categoryName := "" + if i.Category.ID != 0 { + categoryName = i.Category.Name + } + + return ItemDetailResponse{ + ID: i.ID, + Name: i.Name, + Category: categoryName, + PhotoURL: i.PhotoURL, + Location: i.Location, + Description: i.Description, + DateFound: i.DateFound, + Status: i.Status, + ReporterName: i.ReporterName, + ReporterContact: i.ReporterContact, + ExpiresAt: i.ExpiresAt, + CreatedAt: i.CreatedAt, + } +} \ No newline at end of file diff --git a/lost-and-found/internal/models/lost_item.go b/lost-and-found/internal/models/lost_item.go new file mode 100644 index 0000000..86a0711 --- /dev/null +++ b/lost-and-found/internal/models/lost_item.go @@ -0,0 +1,93 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// LostItem represents a lost item report +type LostItem struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + CategoryID uint `gorm:"not null" json:"category_id"` + Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Color string `gorm:"type:varchar(50)" json:"color"` + Location string `gorm:"type:varchar(200)" json:"location"` // Optional + Description string `gorm:"type:text;not null" json:"description"` + DateLost time.Time `gorm:"not null" json:"date_lost"` + Status string `gorm:"type:varchar(50);default:'active'" json:"status"` // active, found, expired + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + MatchResults []MatchResult `gorm:"foreignKey:LostItemID" json:"match_results,omitempty"` +} + +// TableName specifies the table name for LostItem model +func (LostItem) TableName() string { + return "lost_items" +} + +// LostItem status constants +const ( + LostItemStatusActive = "active" + LostItemStatusFound = "found" + LostItemStatusExpired = "expired" +) + +// BeforeCreate hook +func (l *LostItem) BeforeCreate(tx *gorm.DB) error { + if l.Status == "" { + l.Status = LostItemStatusActive + } + return nil +} + +// IsActive checks if lost item is still active +func (l *LostItem) IsActive() bool { + return l.Status == LostItemStatusActive +} + +// LostItemResponse represents lost item data for API responses +type LostItemResponse struct { + ID uint `json:"id"` + UserName string `json:"user_name"` + Name string `json:"name"` + Category string `json:"category"` + Color string `json:"color"` + Location string `json:"location"` + Description string `json:"description"` + DateLost time.Time `json:"date_lost"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts LostItem to LostItemResponse +func (l *LostItem) ToResponse() LostItemResponse { + userName := "" + if l.User.ID != 0 { + userName = l.User.Name + } + + categoryName := "" + if l.Category.ID != 0 { + categoryName = l.Category.Name + } + + return LostItemResponse{ + ID: l.ID, + UserName: userName, + Name: l.Name, + Category: categoryName, + Color: l.Color, + Location: l.Location, + Description: l.Description, + DateLost: l.DateLost, + Status: l.Status, + CreatedAt: l.CreatedAt, + } +} \ No newline at end of file diff --git a/lost-and-found/internal/models/match_result.go b/lost-and-found/internal/models/match_result.go new file mode 100644 index 0000000..893d3bc --- /dev/null +++ b/lost-and-found/internal/models/match_result.go @@ -0,0 +1,128 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// MatchResult represents auto-matching result between lost item and found item +type MatchResult struct { + ID uint `gorm:"primaryKey" json:"id"` + LostItemID uint `gorm:"not null" json:"lost_item_id"` + LostItem LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"` + ItemID uint `gorm:"not null" json:"item_id"` + Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"` + SimilarityScore float64 `gorm:"type:decimal(5,2)" json:"similarity_score"` // Percentage match (0-100) + MatchedFields string `gorm:"type:text" json:"matched_fields"` // JSON of matched fields + MatchedAt time.Time `gorm:"not null" json:"matched_at"` + IsNotified bool `gorm:"default:false" json:"is_notified"` // Was user notified? + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for MatchResult model +func (MatchResult) TableName() string { + return "match_results" +} + +// BeforeCreate hook +func (mr *MatchResult) BeforeCreate(tx *gorm.DB) error { + if mr.MatchedAt.IsZero() { + mr.MatchedAt = time.Now() + } + return nil +} + +// IsHighMatch checks if similarity score is high (>= 70%) +func (mr *MatchResult) IsHighMatch() bool { + return mr.SimilarityScore >= 70.0 +} + +// IsMediumMatch checks if similarity score is medium (50-69%) +func (mr *MatchResult) IsMediumMatch() bool { + return mr.SimilarityScore >= 50.0 && mr.SimilarityScore < 70.0 +} + +// IsLowMatch checks if similarity score is low (< 50%) +func (mr *MatchResult) IsLowMatch() bool { + return mr.SimilarityScore < 50.0 +} + +// GetMatchLevel returns the match level as string +func (mr *MatchResult) GetMatchLevel() string { + if mr.IsHighMatch() { + return "high" + } else if mr.IsMediumMatch() { + return "medium" + } + return "low" +} + +// MatchResultResponse represents match result data for API responses +type MatchResultResponse struct { + ID uint `json:"id"` + LostItemID uint `json:"lost_item_id"` + LostItemName string `json:"lost_item_name"` + ItemID uint `json:"item_id"` + ItemName string `json:"item_name"` + ItemPhotoURL string `json:"item_photo_url"` + ItemLocation string `json:"item_location"` + ItemDateFound time.Time `json:"item_date_found"` + ItemStatus string `json:"item_status"` + SimilarityScore float64 `json:"similarity_score"` + MatchLevel string `json:"match_level"` + MatchedFields string `json:"matched_fields"` + MatchedAt time.Time `json:"matched_at"` + IsNotified bool `json:"is_notified"` +} + +// ToResponse converts MatchResult to MatchResultResponse +func (mr *MatchResult) ToResponse() MatchResultResponse { + lostItemName := "" + if mr.LostItem.ID != 0 { + lostItemName = mr.LostItem.Name + } + + itemName := "" + itemPhotoURL := "" + itemLocation := "" + itemDateFound := time.Time{} + itemStatus := "" + if mr.Item.ID != 0 { + itemName = mr.Item.Name + itemPhotoURL = mr.Item.PhotoURL + itemLocation = mr.Item.Location + itemDateFound = mr.Item.DateFound + itemStatus = mr.Item.Status + } + + return MatchResultResponse{ + ID: mr.ID, + LostItemID: mr.LostItemID, + LostItemName: lostItemName, + ItemID: mr.ItemID, + ItemName: itemName, + ItemPhotoURL: itemPhotoURL, + ItemLocation: itemLocation, + ItemDateFound: itemDateFound, + ItemStatus: itemStatus, + SimilarityScore: mr.SimilarityScore, + MatchLevel: mr.GetMatchLevel(), + MatchedFields: mr.MatchedFields, + MatchedAt: mr.MatchedAt, + IsNotified: mr.IsNotified, + } +} + +// ItemMatchResponse represents simplified item data for matching display +type ItemMatchResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + PhotoURL string `json:"photo_url"` + Location string `json:"location"` + DateFound time.Time `json:"date_found"` + Status string `json:"status"` + Similarity float64 `json:"similarity"` +} \ No newline at end of file diff --git a/lost-and-found/internal/models/notification.go b/lost-and-found/internal/models/notification.go new file mode 100644 index 0000000..66f39d0 --- /dev/null +++ b/lost-and-found/internal/models/notification.go @@ -0,0 +1,127 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Notification represents a notification for a user +type Notification struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Type string `gorm:"type:varchar(50);not null" json:"type"` // match_found, claim_approved, claim_rejected, item_expired, etc. + Title string `gorm:"type:varchar(200);not null" json:"title"` + Message string `gorm:"type:text;not null" json:"message"` + EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, match, etc. + EntityID *uint `json:"entity_id"` + IsRead bool `gorm:"default:false" json:"is_read"` + ReadAt *time.Time `json:"read_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for Notification model +func (Notification) TableName() string { + return "notifications" +} + +// Notification type constants +const ( + NotificationMatchFound = "match_found" + NotificationClaimApproved = "claim_approved" + NotificationClaimRejected = "claim_rejected" + NotificationItemExpired = "item_expired" + NotificationNewClaim = "new_claim" + NotificationItemReturned = "item_returned" +) + +// MarkAsRead marks the notification as read +func (n *Notification) MarkAsRead() { + n.IsRead = true + now := time.Now() + n.ReadAt = &now +} + +// NotificationResponse represents notification data for API responses +type NotificationResponse struct { + ID uint `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + EntityType string `json:"entity_type"` + EntityID *uint `json:"entity_id"` + IsRead bool `json:"is_read"` + ReadAt *time.Time `json:"read_at"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts Notification to NotificationResponse +func (n *Notification) ToResponse() NotificationResponse { + return NotificationResponse{ + ID: n.ID, + Type: n.Type, + Title: n.Title, + Message: n.Message, + EntityType: n.EntityType, + EntityID: n.EntityID, + IsRead: n.IsRead, + ReadAt: n.ReadAt, + CreatedAt: n.CreatedAt, + } +} + +// CreateNotification creates a new notification +func CreateNotification(db *gorm.DB, userID uint, notifType, title, message, entityType string, entityID *uint) error { + notification := &Notification{ + UserID: userID, + Type: notifType, + Title: title, + Message: message, + EntityType: entityType, + EntityID: entityID, + } + + return db.Create(notification).Error +} + +// CreateMatchNotification creates a notification for match found +func CreateMatchNotification(db *gorm.DB, userID uint, itemName string, matchID uint) error { + return CreateNotification( + db, + userID, + NotificationMatchFound, + "Barang yang Mirip Ditemukan!", + "Kami menemukan barang yang mirip dengan laporan kehilangan Anda: "+itemName, + "match", + &matchID, + ) +} + +// CreateClaimApprovedNotification creates a notification for approved claim +func CreateClaimApprovedNotification(db *gorm.DB, userID uint, itemName string, claimID uint) error { + return CreateNotification( + db, + userID, + NotificationClaimApproved, + "Klaim Disetujui!", + "Klaim Anda untuk barang '"+itemName+"' telah disetujui. Silakan ambil barang di tempat yang ditentukan.", + "claim", + &claimID, + ) +} + +// CreateClaimRejectedNotification creates a notification for rejected claim +func CreateClaimRejectedNotification(db *gorm.DB, userID uint, itemName, reason string, claimID uint) error { + return CreateNotification( + db, + userID, + NotificationClaimRejected, + "Klaim Ditolak", + "Klaim Anda untuk barang '"+itemName+"' ditolak. Alasan: "+reason, + "claim", + &claimID, + ) +} \ No newline at end of file diff --git a/lost-and-found/internal/models/revision_log.go b/lost-and-found/internal/models/revision_log.go new file mode 100644 index 0000000..4a809b0 --- /dev/null +++ b/lost-and-found/internal/models/revision_log.go @@ -0,0 +1,72 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// RevisionLog represents revision history for item edits +type RevisionLog struct { + ID uint `gorm:"primaryKey" json:"id"` + ItemID uint `gorm:"not null" json:"item_id"` + Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"` + UserID uint `gorm:"not null" json:"user_id"` // Who made the edit + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + FieldName string `gorm:"type:varchar(50);not null" json:"field_name"` // Which field was edited + OldValue string `gorm:"type:text" json:"old_value"` + NewValue string `gorm:"type:text" json:"new_value"` + Reason string `gorm:"type:text" json:"reason"` // Why was it edited + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName specifies the table name for RevisionLog model +func (RevisionLog) TableName() string { + return "revision_logs" +} + +// RevisionLogResponse represents revision log data for API responses +type RevisionLogResponse struct { + ID uint `json:"id"` + ItemID uint `json:"item_id"` + UserName string `json:"user_name"` + FieldName string `json:"field_name"` + OldValue string `json:"old_value"` + NewValue string `json:"new_value"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts RevisionLog to RevisionLogResponse +func (rl *RevisionLog) ToResponse() RevisionLogResponse { + userName := "" + if rl.User.ID != 0 { + userName = rl.User.Name + } + + return RevisionLogResponse{ + ID: rl.ID, + ItemID: rl.ItemID, + UserName: userName, + FieldName: rl.FieldName, + OldValue: rl.OldValue, + NewValue: rl.NewValue, + Reason: rl.Reason, + CreatedAt: rl.CreatedAt, + } +} + +// CreateRevisionLog creates a new revision log entry +func CreateRevisionLog(db *gorm.DB, itemID, userID uint, fieldName, oldValue, newValue, reason string) error { + log := &RevisionLog{ + ItemID: itemID, + UserID: userID, + FieldName: fieldName, + OldValue: oldValue, + NewValue: newValue, + Reason: reason, + } + + return db.Create(log).Error +} \ No newline at end of file diff --git a/lost-and-found/internal/models/role.go b/lost-and-found/internal/models/role.go new file mode 100644 index 0000000..9b47242 --- /dev/null +++ b/lost-and-found/internal/models/role.go @@ -0,0 +1,52 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Role represents a user role in the system +type Role struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Users []User `gorm:"foreignKey:RoleID" json:"users,omitempty"` +} + +// TableName specifies the table name for Role model +func (Role) TableName() string { + return "roles" +} + +// Role constants +const ( + RoleAdmin = "admin" + RoleManager = "manager" + RoleUser = "user" +) + +// GetRoleID returns the ID for a given role name +func GetRoleID(db *gorm.DB, roleName string) (uint, error) { + var role Role + if err := db.Where("name = ?", roleName).First(&role).Error; err != nil { + return 0, err + } + return role.ID, nil +} + +// IsValidRole checks if a role name is valid +func IsValidRole(roleName string) bool { + validRoles := []string{RoleAdmin, RoleManager, RoleUser} + for _, r := range validRoles { + if r == roleName { + return true + } + } + return false +} \ No newline at end of file diff --git a/lost-and-found/internal/models/user.go b/lost-and-found/internal/models/user.go new file mode 100644 index 0000000..d64f592 --- /dev/null +++ b/lost-and-found/internal/models/user.go @@ -0,0 +1,104 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// User represents a user in the system +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Email string `gorm:"type:varchar(100);uniqueIndex;not null" json:"email"` + Password string `gorm:"type:varchar(255);not null" json:"-"` // Hide password in JSON + NRP string `gorm:"type:varchar(20);uniqueIndex" json:"nrp"` + Phone string `gorm:"type:varchar(20)" json:"phone"` + RoleID uint `gorm:"not null;default:3" json:"role_id"` // Default to user role + Role Role `gorm:"foreignKey:RoleID" json:"role,omitempty"` + Status string `gorm:"type:varchar(20);default:'active'" json:"status"` // active, blocked + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Items []Item `gorm:"foreignKey:ReporterID" json:"items,omitempty"` + LostItems []LostItem `gorm:"foreignKey:UserID" json:"lost_items,omitempty"` + Claims []Claim `gorm:"foreignKey:UserID" json:"claims,omitempty"` +} + +// TableName specifies the table name for User model +func (User) TableName() string { + return "users" +} + +// BeforeCreate hook to validate user data +func (u *User) BeforeCreate(tx *gorm.DB) error { + // Set default role if not specified + if u.RoleID == 0 { + u.RoleID = 3 // Default to user role + } + + // Set default status + if u.Status == "" { + u.Status = "active" + } + + return nil +} + +// IsAdmin checks if user is admin +func (u *User) IsAdmin() bool { + return u.Role.Name == "admin" +} + +// IsManager checks if user is manager +func (u *User) IsManager() bool { + return u.Role.Name == "manager" +} + +// IsUser checks if user is regular user +func (u *User) IsUser() bool { + return u.Role.Name == "user" +} + +// IsActive checks if user is active +func (u *User) IsActive() bool { + return u.Status == "active" +} + +// IsBlocked checks if user is blocked +func (u *User) IsBlocked() bool { + return u.Status == "blocked" +} + +// UserResponse represents user data for API responses (without sensitive info) +type UserResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + NRP string `json:"nrp"` + Phone string `json:"phone"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// ToResponse converts User to UserResponse +func (u *User) ToResponse() UserResponse { + roleName := "" + if u.Role.ID != 0 { + roleName = u.Role.Name + } + + return UserResponse{ + ID: u.ID, + Name: u.Name, + Email: u.Email, + NRP: u.NRP, + Phone: u.Phone, + Role: roleName, + Status: u.Status, + CreatedAt: u.CreatedAt, + } +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/archive_repo.go b/lost-and-found/internal/repositories/archive_repo.go new file mode 100644 index 0000000..82b2008 --- /dev/null +++ b/lost-and-found/internal/repositories/archive_repo.go @@ -0,0 +1,91 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type ArchiveRepository struct { + db *gorm.DB +} + +func NewArchiveRepository(db *gorm.DB) *ArchiveRepository { + return &ArchiveRepository{db: db} +} + +// Create creates a new archive record +func (r *ArchiveRepository) Create(archive *models.Archive) error { + return r.db.Create(archive).Error +} + +// FindByID finds archive by ID +func (r *ArchiveRepository) FindByID(id uint) (*models.Archive, error) { + var archive models.Archive + err := r.db.Preload("Category").Preload("Claimer").Preload("Claimer.Role").First(&archive, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("archive not found") + } + return nil, err + } + return &archive, nil +} + +// FindAll returns all archived items with filters +func (r *ArchiveRepository) FindAll(page, limit int, reason, search string) ([]models.Archive, int64, error) { + var archives []models.Archive + var total int64 + + query := r.db.Model(&models.Archive{}) + + // Apply filters + if reason != "" { + query = query.Where("archived_reason = ?", reason) + } + if search != "" { + query = query.Where("name ILIKE ? OR location ILIKE ?", "%"+search+"%", "%"+search+"%") + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Category").Preload("Claimer").Preload("Claimer.Role"). + Order("archived_at DESC"). + Offset(offset).Limit(limit).Find(&archives).Error + if err != nil { + return nil, 0, err + } + + return archives, total, nil +} + +// FindByItemID finds archive by original item ID +func (r *ArchiveRepository) FindByItemID(itemID uint) (*models.Archive, error) { + var archive models.Archive + err := r.db.Where("item_id = ?", itemID).Preload("Category").Preload("Claimer").First(&archive).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("archive not found") + } + return nil, err + } + return &archive, nil +} + +// Delete permanently deletes an archive +func (r *ArchiveRepository) Delete(id uint) error { + return r.db.Unscoped().Delete(&models.Archive{}, id).Error +} + +// CountByReason counts archives by reason +func (r *ArchiveRepository) CountByReason(reason string) (int64, error) { + var count int64 + err := r.db.Model(&models.Archive{}).Where("archived_reason = ?", reason).Count(&count).Error + return count, err +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/audit_log_repo.go b/lost-and-found/internal/repositories/audit_log_repo.go new file mode 100644 index 0000000..7e3e857 --- /dev/null +++ b/lost-and-found/internal/repositories/audit_log_repo.go @@ -0,0 +1,104 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type AuditLogRepository struct { + db *gorm.DB +} + +func NewAuditLogRepository(db *gorm.DB) *AuditLogRepository { + return &AuditLogRepository{db: db} +} + +// Create creates a new audit log +func (r *AuditLogRepository) Create(log *models.AuditLog) error { + return r.db.Create(log).Error +} + +// FindByID finds audit log by ID +func (r *AuditLogRepository) FindByID(id uint) (*models.AuditLog, error) { + var log models.AuditLog + err := r.db.Preload("User").Preload("User.Role").First(&log, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("audit log not found") + } + return nil, err + } + return &log, nil +} + +// FindAll returns all audit logs with filters +func (r *AuditLogRepository) FindAll(page, limit int, action, entityType string, userID *uint) ([]models.AuditLog, int64, error) { + var logs []models.AuditLog + var total int64 + + query := r.db.Model(&models.AuditLog{}) + + // Apply filters + if action != "" { + query = query.Where("action = ?", action) + } + if entityType != "" { + query = query.Where("entity_type = ?", entityType) + } + if userID != nil { + query = query.Where("user_id = ?", *userID) + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("User").Preload("User.Role"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// FindByUser finds audit logs by user +func (r *AuditLogRepository) FindByUser(userID uint, page, limit int) ([]models.AuditLog, int64, error) { + return r.FindAll(page, limit, "", "", &userID) +} + +// FindByEntity finds audit logs by entity +func (r *AuditLogRepository) FindByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLog, int64, error) { + var logs []models.AuditLog + var total int64 + + query := r.db.Model(&models.AuditLog{}). + Where("entity_type = ? AND entity_id = ?", entityType, entityID) + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("User").Preload("User.Role"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// Log creates a new audit log entry (helper method) +func (r *AuditLogRepository) Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error { + return models.CreateAuditLog(r.db, userID, action, entityType, entityID, details, ipAddress, userAgent) +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/category_repo.go b/lost-and-found/internal/repositories/category_repo.go new file mode 100644 index 0000000..6c2ed3f --- /dev/null +++ b/lost-and-found/internal/repositories/category_repo.go @@ -0,0 +1,101 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type CategoryRepository struct { + db *gorm.DB +} + +func NewCategoryRepository(db *gorm.DB) *CategoryRepository { + return &CategoryRepository{db: db} +} + +// Create creates a new category +func (r *CategoryRepository) Create(category *models.Category) error { + return r.db.Create(category).Error +} + +// FindByID finds category by ID +func (r *CategoryRepository) FindByID(id uint) (*models.Category, error) { + var category models.Category + err := r.db.First(&category, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("category not found") + } + return nil, err + } + return &category, nil +} + +// FindBySlug finds category by slug +func (r *CategoryRepository) FindBySlug(slug string) (*models.Category, error) { + var category models.Category + err := r.db.Where("slug = ?", slug).First(&category).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("category not found") + } + return nil, err + } + return &category, nil +} + +// FindAll returns all categories +func (r *CategoryRepository) FindAll() ([]models.Category, error) { + var categories []models.Category + err := r.db.Order("name ASC").Find(&categories).Error + return categories, err +} + +// Update updates category data +func (r *CategoryRepository) Update(category *models.Category) error { + return r.db.Save(category).Error +} + +// Delete soft deletes a category +func (r *CategoryRepository) Delete(id uint) error { + return r.db.Delete(&models.Category{}, id).Error +} + +// GetCategoryWithItemCount gets category with item count +func (r *CategoryRepository) GetCategoryWithItemCount(id uint) (*models.Category, int64, error) { + category, err := r.FindByID(id) + if err != nil { + return nil, 0, err + } + + var count int64 + if err := r.db.Model(&models.Item{}).Where("category_id = ?", id).Count(&count).Error; err != nil { + return nil, 0, err + } + + return category, count, nil +} + +// GetAllWithItemCount gets all categories with item counts +func (r *CategoryRepository) GetAllWithItemCount() ([]models.CategoryResponse, error) { + var categories []models.Category + if err := r.db.Order("name ASC").Find(&categories).Error; err != nil { + return nil, err + } + + var responses []models.CategoryResponse + for _, cat := range categories { + var count int64 + if err := r.db.Model(&models.Item{}).Where("category_id = ?", cat.ID).Count(&count).Error; err != nil { + return nil, err + } + + response := cat.ToResponse() + response.ItemCount = count + responses = append(responses, response) + } + + return responses, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/claim_repo.go b/lost-and-found/internal/repositories/claim_repo.go new file mode 100644 index 0000000..abd8613 --- /dev/null +++ b/lost-and-found/internal/repositories/claim_repo.go @@ -0,0 +1,145 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type ClaimRepository struct { + db *gorm.DB +} + +func NewClaimRepository(db *gorm.DB) *ClaimRepository { + return &ClaimRepository{db: db} +} + +// Create creates a new claim +func (r *ClaimRepository) Create(claim *models.Claim) error { + return r.db.Create(claim).Error +} + +// FindByID finds claim by ID +func (r *ClaimRepository) FindByID(id uint) (*models.Claim, error) { + var claim models.Claim + err := r.db.Preload("Item").Preload("Item.Category"). + Preload("User").Preload("User.Role"). + Preload("Verifier").Preload("Verifier.Role"). + Preload("Verification"). + First(&claim, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("claim not found") + } + return nil, err + } + return &claim, nil +} + +// FindAll returns all claims with filters +func (r *ClaimRepository) FindAll(page, limit int, status string, itemID, userID *uint) ([]models.Claim, int64, error) { + var claims []models.Claim + var total int64 + + query := r.db.Model(&models.Claim{}) + + // Apply filters + if status != "" { + query = query.Where("status = ?", status) + } + if itemID != nil { + query = query.Where("item_id = ?", *itemID) + } + if userID != nil { + query = query.Where("user_id = ?", *userID) + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Item").Preload("Item.Category"). + Preload("User").Preload("User.Role"). + Preload("Verifier").Preload("Verifier.Role"). + Preload("Verification"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&claims).Error + if err != nil { + return nil, 0, err + } + + return claims, total, nil +} + +// Update updates claim data +func (r *ClaimRepository) Update(claim *models.Claim) error { + return r.db.Save(claim).Error +} + +// Delete soft deletes a claim +func (r *ClaimRepository) Delete(id uint) error { + return r.db.Delete(&models.Claim{}, id).Error +} + +// CheckExistingClaim checks if user already claimed an item +func (r *ClaimRepository) CheckExistingClaim(userID, itemID uint) (bool, error) { + var count int64 + err := r.db.Model(&models.Claim{}). + Where("user_id = ? AND item_id = ? AND status != ?", userID, itemID, models.ClaimStatusRejected). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +// FindByItem finds claims for an item +func (r *ClaimRepository) FindByItem(itemID uint) ([]models.Claim, error) { + var claims []models.Claim + err := r.db.Where("item_id = ?", itemID). + Preload("User").Preload("User.Role"). + Preload("Verification"). + Order("created_at DESC").Find(&claims).Error + return claims, err +} + +// FindByUser finds claims by user +func (r *ClaimRepository) FindByUser(userID uint, page, limit int) ([]models.Claim, int64, error) { + var claims []models.Claim + var total int64 + + query := r.db.Model(&models.Claim{}).Where("user_id = ?", userID) + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Item").Preload("Item.Category"). + Preload("Verification"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&claims).Error + if err != nil { + return nil, 0, err + } + + return claims, total, nil +} + +// CountByStatus counts claims by status +func (r *ClaimRepository) CountByStatus(status string) (int64, error) { + var count int64 + err := r.db.Model(&models.Claim{}).Where("status = ?", status).Count(&count).Error + return count, err +} + +// FindPendingClaims finds all pending claims +func (r *ClaimRepository) FindPendingClaims(page, limit int) ([]models.Claim, int64, error) { + return r.FindAll(page, limit, models.ClaimStatusPending, nil, nil) +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/claim_verification_repo.go b/lost-and-found/internal/repositories/claim_verification_repo.go new file mode 100644 index 0000000..5cbac09 --- /dev/null +++ b/lost-and-found/internal/repositories/claim_verification_repo.go @@ -0,0 +1,66 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type ClaimVerificationRepository struct { + db *gorm.DB +} + +func NewClaimVerificationRepository(db *gorm.DB) *ClaimVerificationRepository { + return &ClaimVerificationRepository{db: db} +} + +// Create creates a new claim verification +func (r *ClaimVerificationRepository) Create(verification *models.ClaimVerification) error { + return r.db.Create(verification).Error +} + +// FindByID finds claim verification by ID +func (r *ClaimVerificationRepository) FindByID(id uint) (*models.ClaimVerification, error) { + var verification models.ClaimVerification + err := r.db.Preload("Claim").First(&verification, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("claim verification not found") + } + return nil, err + } + return &verification, nil +} + +// FindByClaimID finds claim verification by claim ID +func (r *ClaimVerificationRepository) FindByClaimID(claimID uint) (*models.ClaimVerification, error) { + var verification models.ClaimVerification + err := r.db.Where("claim_id = ?", claimID).Preload("Claim").First(&verification).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // Return nil if not found (not an error) + } + return nil, err + } + return &verification, nil +} + +// Update updates claim verification +func (r *ClaimVerificationRepository) Update(verification *models.ClaimVerification) error { + return r.db.Save(verification).Error +} + +// Delete deletes a claim verification +func (r *ClaimVerificationRepository) Delete(id uint) error { + return r.db.Delete(&models.ClaimVerification{}, id).Error +} + +// FindHighMatches finds high match verifications (>= 70%) +func (r *ClaimVerificationRepository) FindHighMatches() ([]models.ClaimVerification, error) { + var verifications []models.ClaimVerification + err := r.db.Where("similarity_score >= ?", 70.0). + Preload("Claim").Preload("Claim.Item").Preload("Claim.User"). + Order("similarity_score DESC").Find(&verifications).Error + return verifications, err +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/item_repo.go b/lost-and-found/internal/repositories/item_repo.go new file mode 100644 index 0000000..d974529 --- /dev/null +++ b/lost-and-found/internal/repositories/item_repo.go @@ -0,0 +1,158 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + "time" + + "gorm.io/gorm" +) + +type ItemRepository struct { + db *gorm.DB +} + +func NewItemRepository(db *gorm.DB) *ItemRepository { + return &ItemRepository{db: db} +} + +// Create creates a new item +func (r *ItemRepository) Create(item *models.Item) error { + return r.db.Create(item).Error +} + +// FindByID finds item by ID +func (r *ItemRepository) FindByID(id uint) (*models.Item, error) { + var item models.Item + err := r.db.Preload("Category").Preload("Reporter").Preload("Reporter.Role").First(&item, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("item not found") + } + return nil, err + } + return &item, nil +} + +// FindAll returns all items with filters +func (r *ItemRepository) FindAll(page, limit int, status, category, search string) ([]models.Item, int64, error) { + var items []models.Item + var total int64 + + query := r.db.Model(&models.Item{}) + + // Apply filters + if status != "" { + query = query.Where("status = ?", status) + } + if category != "" { + query = query.Joins("JOIN categories ON categories.id = items.category_id").Where("categories.slug = ?", category) + } + if search != "" { + query = query.Where("name ILIKE ? OR location ILIKE ?", "%"+search+"%", "%"+search+"%") + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Category").Preload("Reporter").Preload("Reporter.Role"). + Order("date_found DESC"). + Offset(offset).Limit(limit).Find(&items).Error + if err != nil { + return nil, 0, err + } + + return items, total, nil +} + +// Update updates item data +func (r *ItemRepository) Update(item *models.Item) error { + return r.db.Save(item).Error +} + +// UpdateStatus updates item status +func (r *ItemRepository) UpdateStatus(id uint, status string) error { + return r.db.Model(&models.Item{}).Where("id = ?", id).Update("status", status).Error +} + +// Delete soft deletes an item +func (r *ItemRepository) Delete(id uint) error { + return r.db.Delete(&models.Item{}, id).Error +} + +// FindExpired finds expired items +func (r *ItemRepository) FindExpired() ([]models.Item, error) { + var items []models.Item + now := time.Now() + err := r.db.Where("expires_at <= ? AND status = ?", now, models.ItemStatusUnclaimed). + Preload("Category").Find(&items).Error + return items, err +} + +// ArchiveItem moves item to archive +func (r *ItemRepository) ArchiveItem(item *models.Item, reason string, claimedBy *uint) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Create archive record + archive := models.CreateFromItem(item, reason, claimedBy) + if err := tx.Create(archive).Error; err != nil { + return err + } + + // Update item status + if err := tx.Model(item).Updates(map[string]interface{}{ + "status": models.ItemStatusExpired, + }).Error; err != nil { + return err + } + + return nil + }) +} + +// CountByStatus counts items by status +func (r *ItemRepository) CountByStatus(status string) (int64, error) { + var count int64 + err := r.db.Model(&models.Item{}).Where("status = ?", status).Count(&count).Error + return count, err +} + +// FindByReporter finds items by reporter ID +func (r *ItemRepository) FindByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) { + var items []models.Item + var total int64 + + query := r.db.Model(&models.Item{}).Where("reporter_id = ?", reporterID) + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Category").Order("date_found DESC"). + Offset(offset).Limit(limit).Find(&items).Error + if err != nil { + return nil, 0, err + } + + return items, total, nil +} + +// SearchForMatching searches items for matching with lost items +func (r *ItemRepository) SearchForMatching(categoryID uint, name, color string) ([]models.Item, error) { + var items []models.Item + + query := r.db.Where("status = ? AND category_id = ?", models.ItemStatusUnclaimed, categoryID) + + if name != "" { + query = query.Where("name ILIKE ?", "%"+name+"%") + } + + err := query.Preload("Category").Order("date_found DESC").Limit(10).Find(&items).Error + return items, err +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/lost_item_repo.go b/lost-and-found/internal/repositories/lost_item_repo.go new file mode 100644 index 0000000..1bee969 --- /dev/null +++ b/lost-and-found/internal/repositories/lost_item_repo.go @@ -0,0 +1,127 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type LostItemRepository struct { + db *gorm.DB +} + +func NewLostItemRepository(db *gorm.DB) *LostItemRepository { + return &LostItemRepository{db: db} +} + +// Create creates a new lost item report +func (r *LostItemRepository) Create(lostItem *models.LostItem) error { + return r.db.Create(lostItem).Error +} + +// FindByID finds lost item by ID +func (r *LostItemRepository) FindByID(id uint) (*models.LostItem, error) { + var lostItem models.LostItem + err := r.db.Preload("Category").Preload("User").Preload("User.Role").First(&lostItem, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("lost item not found") + } + return nil, err + } + return &lostItem, nil +} + +// FindAll returns all lost items with filters +func (r *LostItemRepository) FindAll(page, limit int, status, category, search string, userID *uint) ([]models.LostItem, int64, error) { + var lostItems []models.LostItem + var total int64 + + query := r.db.Model(&models.LostItem{}) + + // Filter by user if specified + if userID != nil { + query = query.Where("user_id = ?", *userID) + } + + // Apply filters + if status != "" { + query = query.Where("status = ?", status) + } + if category != "" { + query = query.Joins("JOIN categories ON categories.id = lost_items.category_id").Where("categories.slug = ?", category) + } + if search != "" { + query = query.Where("name ILIKE ? OR description ILIKE ?", "%"+search+"%", "%"+search+"%") + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Category").Preload("User").Preload("User.Role"). + Order("date_lost DESC"). + Offset(offset).Limit(limit).Find(&lostItems).Error + if err != nil { + return nil, 0, err + } + + return lostItems, total, nil +} + +// Update updates lost item data +func (r *LostItemRepository) Update(lostItem *models.LostItem) error { + return r.db.Save(lostItem).Error +} + +// UpdateStatus updates lost item status +func (r *LostItemRepository) UpdateStatus(id uint, status string) error { + return r.db.Model(&models.LostItem{}).Where("id = ?", id).Update("status", status).Error +} + +// Delete soft deletes a lost item +func (r *LostItemRepository) Delete(id uint) error { + return r.db.Delete(&models.LostItem{}, id).Error +} + +// FindByUser finds lost items by user ID +func (r *LostItemRepository) FindByUser(userID uint, page, limit int) ([]models.LostItem, int64, error) { + var lostItems []models.LostItem + var total int64 + + query := r.db.Model(&models.LostItem{}).Where("user_id = ?", userID) + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Category").Order("date_lost DESC"). + Offset(offset).Limit(limit).Find(&lostItems).Error + if err != nil { + return nil, 0, err + } + + return lostItems, total, nil +} + +// CountByStatus counts lost items by status +func (r *LostItemRepository) CountByStatus(status string) (int64, error) { + var count int64 + err := r.db.Model(&models.LostItem{}).Where("status = ?", status).Count(&count).Error + return count, err +} + +// FindActiveForMatching finds active lost items for matching +func (r *LostItemRepository) FindActiveForMatching(categoryID uint) ([]models.LostItem, error) { + var lostItems []models.LostItem + err := r.db.Where("status = ? AND category_id = ?", models.LostItemStatusActive, categoryID). + Preload("User").Find(&lostItems).Error + return lostItems, err +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/match_result_repo.go b/lost-and-found/internal/repositories/match_result_repo.go new file mode 100644 index 0000000..f46d617 --- /dev/null +++ b/lost-and-found/internal/repositories/match_result_repo.go @@ -0,0 +1,124 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type MatchResultRepository struct { + db *gorm.DB +} + +func NewMatchResultRepository(db *gorm.DB) *MatchResultRepository { + return &MatchResultRepository{db: db} +} + +// Create creates a new match result +func (r *MatchResultRepository) Create(match *models.MatchResult) error { + return r.db.Create(match).Error +} + +// FindByID finds match result by ID +func (r *MatchResultRepository) FindByID(id uint) (*models.MatchResult, error) { + var match models.MatchResult + err := r.db.Preload("LostItem").Preload("LostItem.User"). + Preload("Item").Preload("Item.Category"). + First(&match, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("match result not found") + } + return nil, err + } + return &match, nil +} + +// FindAll returns all match results with filters +func (r *MatchResultRepository) FindAll(page, limit int, lostItemID, itemID *uint) ([]models.MatchResult, int64, error) { + var matches []models.MatchResult + var total int64 + + query := r.db.Model(&models.MatchResult{}) + + // Apply filters + if lostItemID != nil { + query = query.Where("lost_item_id = ?", *lostItemID) + } + if itemID != nil { + query = query.Where("item_id = ?", *itemID) + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("LostItem").Preload("LostItem.User"). + Preload("Item").Preload("Item.Category"). + Order("similarity_score DESC"). + Offset(offset).Limit(limit).Find(&matches).Error + if err != nil { + return nil, 0, err + } + + return matches, total, nil +} + +// FindByLostItem finds match results for a lost item +func (r *MatchResultRepository) FindByLostItem(lostItemID uint) ([]models.MatchResult, error) { + var matches []models.MatchResult + err := r.db.Where("lost_item_id = ?", lostItemID). + Preload("Item").Preload("Item.Category"). + Order("similarity_score DESC").Find(&matches).Error + return matches, err +} + +// FindByItem finds match results for an item +func (r *MatchResultRepository) FindByItem(itemID uint) ([]models.MatchResult, error) { + var matches []models.MatchResult + err := r.db.Where("item_id = ?", itemID). + Preload("LostItem").Preload("LostItem.User"). + Order("similarity_score DESC").Find(&matches).Error + return matches, err +} + +// Update updates match result +func (r *MatchResultRepository) Update(match *models.MatchResult) error { + return r.db.Save(match).Error +} + +// MarkAsNotified marks match result as notified +func (r *MatchResultRepository) MarkAsNotified(id uint) error { + return r.db.Model(&models.MatchResult{}).Where("id = ?", id).Update("is_notified", true).Error +} + +// FindUnnotifiedMatches finds match results that haven't been notified +func (r *MatchResultRepository) FindUnnotifiedMatches() ([]models.MatchResult, error) { + var matches []models.MatchResult + err := r.db.Where("is_notified = ?", false). + Preload("LostItem").Preload("LostItem.User"). + Preload("Item").Preload("Item.Category"). + Order("matched_at ASC").Find(&matches).Error + return matches, err +} + +// Delete deletes a match result +func (r *MatchResultRepository) Delete(id uint) error { + return r.db.Delete(&models.MatchResult{}, id).Error +} + +// CheckExistingMatch checks if a match already exists +func (r *MatchResultRepository) CheckExistingMatch(lostItemID, itemID uint) (bool, error) { + var count int64 + err := r.db.Model(&models.MatchResult{}). + Where("lost_item_id = ? AND item_id = ?", lostItemID, itemID). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/notification_repo.go b/lost-and-found/internal/repositories/notification_repo.go new file mode 100644 index 0000000..978c6d4 --- /dev/null +++ b/lost-and-found/internal/repositories/notification_repo.go @@ -0,0 +1,103 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type NotificationRepository struct { + db *gorm.DB +} + +func NewNotificationRepository(db *gorm.DB) *NotificationRepository { + return &NotificationRepository{db: db} +} + +// Create creates a new notification +func (r *NotificationRepository) Create(notification *models.Notification) error { + return r.db.Create(notification).Error +} + +// FindByID finds notification by ID +func (r *NotificationRepository) FindByID(id uint) (*models.Notification, error) { + var notification models.Notification + err := r.db.Preload("User").First(¬ification, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("notification not found") + } + return nil, err + } + return ¬ification, nil +} + +// FindByUser finds notifications for a user +func (r *NotificationRepository) FindByUser(userID uint, page, limit int, onlyUnread bool) ([]models.Notification, int64, error) { + var notifications []models.Notification + var total int64 + + query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID) + + // Filter unread if specified + if onlyUnread { + query = query.Where("is_read = ?", false) + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Order("created_at DESC"). + Offset(offset).Limit(limit).Find(¬ifications).Error + if err != nil { + return nil, 0, err + } + + return notifications, total, nil +} + +// MarkAsRead marks a notification as read +func (r *NotificationRepository) MarkAsRead(id uint) error { + notification, err := r.FindByID(id) + if err != nil { + return err + } + notification.MarkAsRead() + return r.db.Save(notification).Error +} + +// MarkAllAsRead marks all notifications for a user as read +func (r *NotificationRepository) MarkAllAsRead(userID uint) error { + return r.db.Model(&models.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Update("is_read", true).Error +} + +// Delete deletes a notification +func (r *NotificationRepository) Delete(id uint) error { + return r.db.Delete(&models.Notification{}, id).Error +} + +// DeleteAllForUser deletes all notifications for a user +func (r *NotificationRepository) DeleteAllForUser(userID uint) error { + return r.db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error +} + +// CountUnread counts unread notifications for a user +func (r *NotificationRepository) CountUnread(userID uint) (int64, error) { + var count int64 + err := r.db.Model(&models.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Count(&count).Error + return count, err +} + +// Notify creates a notification (helper method) +func (r *NotificationRepository) Notify(userID uint, notifType, title, message, entityType string, entityID *uint) error { + return models.CreateNotification(r.db, userID, notifType, title, message, entityType, entityID) +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/revision_log_repo.go b/lost-and-found/internal/repositories/revision_log_repo.go new file mode 100644 index 0000000..c87de82 --- /dev/null +++ b/lost-and-found/internal/repositories/revision_log_repo.go @@ -0,0 +1,92 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type RevisionLogRepository struct { + db *gorm.DB +} + +func NewRevisionLogRepository(db *gorm.DB) *RevisionLogRepository { + return &RevisionLogRepository{db: db} +} + +// Create creates a new revision log +func (r *RevisionLogRepository) Create(log *models.RevisionLog) error { + return r.db.Create(log).Error +} + +// FindByID finds revision log by ID +func (r *RevisionLogRepository) FindByID(id uint) (*models.RevisionLog, error) { + var log models.RevisionLog + err := r.db.Preload("Item").Preload("User").Preload("User.Role").First(&log, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("revision log not found") + } + return nil, err + } + return &log, nil +} + +// FindByItem finds revision logs for an item +func (r *RevisionLogRepository) FindByItem(itemID uint, page, limit int) ([]models.RevisionLog, int64, error) { + var logs []models.RevisionLog + var total int64 + + query := r.db.Model(&models.RevisionLog{}).Where("item_id = ?", itemID) + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("User").Preload("User.Role"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// FindAll returns all revision logs with filters +func (r *RevisionLogRepository) FindAll(page, limit int, userID *uint) ([]models.RevisionLog, int64, error) { + var logs []models.RevisionLog + var total int64 + + query := r.db.Model(&models.RevisionLog{}) + + // Apply filters + if userID != nil { + query = query.Where("user_id = ?", *userID) + } + + // Count total + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := query.Preload("Item").Preload("User").Preload("User.Role"). + Order("created_at DESC"). + Offset(offset).Limit(limit).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// Log creates a new revision log entry (helper method) +func (r *RevisionLogRepository) Log(itemID, userID uint, fieldName, oldValue, newValue, reason string) error { + return models.CreateRevisionLog(r.db, itemID, userID, fieldName, oldValue, newValue, reason) +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/role_repo.go b/lost-and-found/internal/repositories/role_repo.go new file mode 100644 index 0000000..f7e1fac --- /dev/null +++ b/lost-and-found/internal/repositories/role_repo.go @@ -0,0 +1,64 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type RoleRepository struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) *RoleRepository { + return &RoleRepository{db: db} +} + +// FindAll returns all roles +func (r *RoleRepository) FindAll() ([]models.Role, error) { + var roles []models.Role + err := r.db.Find(&roles).Error + return roles, err +} + +// FindByID finds role by ID +func (r *RoleRepository) FindByID(id uint) (*models.Role, error) { + var role models.Role + err := r.db.First(&role, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("role not found") + } + return nil, err + } + return &role, nil +} + +// FindByName finds role by name +func (r *RoleRepository) FindByName(name string) (*models.Role, error) { + var role models.Role + err := r.db.Where("name = ?", name).First(&role).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("role not found") + } + return nil, err + } + return &role, nil +} + +// Create creates a new role +func (r *RoleRepository) Create(role *models.Role) error { + return r.db.Create(role).Error +} + +// Update updates role data +func (r *RoleRepository) Update(role *models.Role) error { + return r.db.Save(role).Error +} + +// Delete deletes a role +func (r *RoleRepository) Delete(id uint) error { + return r.db.Delete(&models.Role{}, id).Error +} \ No newline at end of file diff --git a/lost-and-found/internal/repositories/user_repo.go b/lost-and-found/internal/repositories/user_repo.go new file mode 100644 index 0000000..d10d886 --- /dev/null +++ b/lost-and-found/internal/repositories/user_repo.go @@ -0,0 +1,152 @@ +package repositories + +import ( + "errors" + "lost-and-found/internal/models" + + "gorm.io/gorm" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +// Create creates a new user +func (r *UserRepository) Create(user *models.User) error { + return r.db.Create(user).Error +} + +// FindByID finds user by ID +func (r *UserRepository) FindByID(id uint) (*models.User, error) { + var user models.User + err := r.db.Preload("Role").First(&user, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// FindByEmail finds user by email +func (r *UserRepository) FindByEmail(email string) (*models.User, error) { + var user models.User + err := r.db.Preload("Role").Where("email = ?", email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// FindByNRP finds user by NRP +func (r *UserRepository) FindByNRP(nrp string) (*models.User, error) { + var user models.User + err := r.db.Preload("Role").Where("nrp = ?", nrp).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &user, nil +} + +// FindAll returns all users +func (r *UserRepository) FindAll(page, limit int) ([]models.User, int64, error) { + var users []models.User + var total int64 + + // Count total + if err := r.db.Model(&models.User{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + offset := (page - 1) * limit + err := r.db.Preload("Role").Offset(offset).Limit(limit).Find(&users).Error + if err != nil { + return nil, 0, err + } + + return users, total, nil +} + +// Update updates user data +func (r *UserRepository) Update(user *models.User) error { + return r.db.Save(user).Error +} + +// UpdateRole updates user role +func (r *UserRepository) UpdateRole(userID, roleID uint) error { + return r.db.Model(&models.User{}).Where("id = ?", userID).Update("role_id", roleID).Error +} + +// UpdateStatus updates user status +func (r *UserRepository) UpdateStatus(userID uint, status string) error { + return r.db.Model(&models.User{}).Where("id = ?", userID).Update("status", status).Error +} + +// Delete soft deletes a user +func (r *UserRepository) Delete(id uint) error { + return r.db.Delete(&models.User{}, id).Error +} + +// BlockUser blocks a user +func (r *UserRepository) BlockUser(id uint) error { + return r.UpdateStatus(id, "blocked") +} + +// UnblockUser unblocks a user +func (r *UserRepository) UnblockUser(id uint) error { + return r.UpdateStatus(id, "active") +} + +// CountByRole counts users by role +func (r *UserRepository) CountByRole(roleID uint) (int64, error) { + var count int64 + err := r.db.Model(&models.User{}).Where("role_id = ?", roleID).Count(&count).Error + return count, err +} + +// GetUserStats gets user statistics +func (r *UserRepository) GetUserStats(userID uint) (map[string]interface{}, error) { + var stats map[string]interface{} = make(map[string]interface{}) + + // Count items reported + var itemCount int64 + if err := r.db.Model(&models.Item{}).Where("reporter_id = ?", userID).Count(&itemCount).Error; err != nil { + return nil, err + } + stats["items_reported"] = itemCount + + // Count lost items reported + var lostItemCount int64 + if err := r.db.Model(&models.LostItem{}).Where("user_id = ?", userID).Count(&lostItemCount).Error; err != nil { + return nil, err + } + stats["lost_items_reported"] = lostItemCount + + // Count claims made + var claimCount int64 + if err := r.db.Model(&models.Claim{}).Where("user_id = ?", userID).Count(&claimCount).Error; err != nil { + return nil, err + } + stats["claims_made"] = claimCount + + // Count approved claims + var approvedClaimCount int64 + if err := r.db.Model(&models.Claim{}).Where("user_id = ? AND status = ?", userID, models.ClaimStatusApproved).Count(&approvedClaimCount).Error; err != nil { + return nil, err + } + stats["claims_approved"] = approvedClaimCount + + return stats, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/routes/routes.go b/lost-and-found/internal/routes/routes.go new file mode 100644 index 0000000..24d99af --- /dev/null +++ b/lost-and-found/internal/routes/routes.go @@ -0,0 +1,128 @@ +package routes + +import ( + "lost-and-found/internal/controllers" + "lost-and-found/internal/middleware" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// SetupRoutes configures all application routes +func SetupRoutes(router *gin.Engine, db *gorm.DB) { + // Initialize controllers + authController := controllers.NewAuthController(db) + userController := controllers.NewUserController(db) + itemController := controllers.NewItemController(db) + lostItemController := controllers.NewLostItemController(db) + claimController := controllers.NewClaimController(db) + matchController := controllers.NewMatchController(db) + categoryController := controllers.NewCategoryController(db) + archiveController := controllers.NewArchiveController(db) + adminController := controllers.NewAdminController(db) + reportController := controllers.NewReportController(db) + + // API group + api := router.Group("/api") + { + // Public routes (no authentication required) + api.POST("/register", authController.Register) + api.POST("/login", authController.Login) + api.POST("/refresh-token", authController.RefreshToken) + + // Public categories + api.GET("/categories", categoryController.GetAllCategories) + api.GET("/categories/:id", categoryController.GetCategoryByID) + + // Public items (read-only, limited info) + api.GET("/items", itemController.GetAllItems) + api.GET("/items/:id", itemController.GetItemByID) + + // Authenticated routes (all users) + authenticated := api.Group("") + authenticated.Use(middleware.JWTMiddleware(db)) + authenticated.Use(middleware.RequireUser()) + { + // User profile + authenticated.GET("/me", authController.GetMe) + authenticated.GET("/user/profile", userController.GetProfile) + authenticated.PUT("/user/profile", userController.UpdateProfile) + authenticated.POST("/user/change-password", userController.ChangePassword) + authenticated.GET("/user/stats", userController.GetStats) + + // User items + authenticated.GET("/user/items", itemController.GetItemsByReporter) + authenticated.POST("/items", itemController.CreateItem) + + // User lost items + authenticated.GET("/user/lost-items", lostItemController.GetLostItemsByUser) + authenticated.GET("/lost-items", lostItemController.GetAllLostItems) + authenticated.GET("/lost-items/:id", lostItemController.GetLostItemByID) + authenticated.POST("/lost-items", lostItemController.CreateLostItem) + authenticated.PUT("/lost-items/:id", lostItemController.UpdateLostItem) + authenticated.PATCH("/lost-items/:id/status", lostItemController.UpdateLostItemStatus) + authenticated.DELETE("/lost-items/:id", lostItemController.DeleteLostItem) + + // User claims + authenticated.GET("/user/claims", claimController.GetClaimsByUser) + authenticated.GET("/claims", claimController.GetAllClaims) + authenticated.GET("/claims/:id", claimController.GetClaimByID) + authenticated.POST("/claims", claimController.CreateClaim) + authenticated.DELETE("/claims/:id", claimController.DeleteClaim) + + // Matches (for lost items) + authenticated.GET("/lost-items/:id/matches", matchController.GetMatchesForLostItem) + authenticated.POST("/lost-items/:id/find-similar", matchController.FindSimilarItems) + } + + // Manager routes (manager and admin) + manager := api.Group("") + manager.Use(middleware.JWTMiddleware(db)) + manager.Use(middleware.RequireManager()) + { + // Item management + manager.PUT("/items/:id", itemController.UpdateItem) + manager.PATCH("/items/:id/status", itemController.UpdateItemStatus) + manager.DELETE("/items/:id", itemController.DeleteItem) + manager.GET("/items/:id/revisions", itemController.GetItemRevisionHistory) + manager.GET("/items/:id/matches", matchController.GetMatchesForItem) + + // Claim verification + manager.POST("/claims/:id/verify", claimController.VerifyClaim) + manager.GET("/claims/:id/verification", claimController.GetClaimVerification) + manager.POST("/claims/:id/close", claimController.CloseClaim) + + // Archives + manager.GET("/archives", archiveController.GetAllArchives) + manager.GET("/archives/:id", archiveController.GetArchiveByID) + manager.GET("/archives/stats", archiveController.GetArchiveStats) + + // Dashboard + manager.GET("/manager/dashboard", adminController.GetDashboardStats) + } + + // Admin routes (admin only) + admin := api.Group("/admin") + admin.Use(middleware.JWTMiddleware(db)) + admin.Use(middleware.RequireAdmin()) + { + // User management + admin.GET("/users", userController.GetAllUsers) + admin.GET("/users/:id", userController.GetUserByID) + admin.PATCH("/users/:id/role", userController.UpdateUserRole) + admin.POST("/users/:id/block", userController.BlockUser) + admin.POST("/users/:id/unblock", userController.UnblockUser) + admin.DELETE("/users/:id", userController.DeleteUser) + + // Category management + admin.POST("/categories", categoryController.CreateCategory) + admin.PUT("/categories/:id", categoryController.UpdateCategory) + admin.DELETE("/categories/:id", categoryController.DeleteCategory) + + // Dashboard & Reports + admin.GET("/dashboard", adminController.GetDashboardStats) + admin.GET("/audit-logs", adminController.GetAuditLogs) + admin.POST("/reports/export", reportController.ExportReport) + } + } +} \ No newline at end of file diff --git a/lost-and-found/internal/services/archive_service.go b/lost-and-found/internal/services/archive_service.go new file mode 100644 index 0000000..bdc7044 --- /dev/null +++ b/lost-and-found/internal/services/archive_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + + "gorm.io/gorm" +) + +type ArchiveService struct { + archiveRepo *repositories.ArchiveRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewArchiveService(db *gorm.DB) *ArchiveService { + return &ArchiveService{ + archiveRepo: repositories.NewArchiveRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// GetAllArchives gets all archived items +func (s *ArchiveService) GetAllArchives(page, limit int, reason, search string) ([]models.ArchiveResponse, int64, error) { + archives, total, err := s.archiveRepo.FindAll(page, limit, reason, search) + if err != nil { + return nil, 0, err + } + + var responses []models.ArchiveResponse + for _, archive := range archives { + responses = append(responses, archive.ToResponse()) + } + + return responses, total, nil +} + +// GetArchiveByID gets archive by ID +func (s *ArchiveService) GetArchiveByID(id uint) (*models.Archive, error) { + return s.archiveRepo.FindByID(id) +} + +// GetArchiveByItemID gets archive by original item ID +func (s *ArchiveService) GetArchiveByItemID(itemID uint) (*models.Archive, error) { + return s.archiveRepo.FindByItemID(itemID) +} + +// GetArchiveStats gets archive statistics +func (s *ArchiveService) GetArchiveStats() (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // Count by reason + expiredCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonExpired) + if err != nil { + return nil, err + } + stats["expired"] = expiredCount + + caseClosedCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonCaseClosed) + if err != nil { + return nil, err + } + stats["case_closed"] = caseClosedCount + + stats["total"] = expiredCount + caseClosedCount + + return stats, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/services/audit_service.go b/lost-and-found/internal/services/audit_service.go new file mode 100644 index 0000000..c75d0b0 --- /dev/null +++ b/lost-and-found/internal/services/audit_service.go @@ -0,0 +1,68 @@ +package services + +import ( + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + + "gorm.io/gorm" +) + +type AuditService struct { + auditLogRepo *repositories.AuditLogRepository +} + +func NewAuditService(db *gorm.DB) *AuditService { + return &AuditService{ + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// GetAllAuditLogs gets all audit logs +func (s *AuditService) GetAllAuditLogs(page, limit int, action, entityType string, userID *uint) ([]models.AuditLogResponse, int64, error) { + logs, total, err := s.auditLogRepo.FindAll(page, limit, action, entityType, userID) + if err != nil { + return nil, 0, err + } + + var responses []models.AuditLogResponse + for _, log := range logs { + responses = append(responses, log.ToResponse()) + } + + return responses, total, nil +} + +// GetAuditLogsByUser gets audit logs by user +func (s *AuditService) GetAuditLogsByUser(userID uint, page, limit int) ([]models.AuditLogResponse, int64, error) { + logs, total, err := s.auditLogRepo.FindByUser(userID, page, limit) + if err != nil { + return nil, 0, err + } + + var responses []models.AuditLogResponse + for _, log := range logs { + responses = append(responses, log.ToResponse()) + } + + return responses, total, nil +} + +// GetAuditLogsByEntity gets audit logs by entity +func (s *AuditService) GetAuditLogsByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLogResponse, int64, error) { + logs, total, err := s.auditLogRepo.FindByEntity(entityType, entityID, page, limit) + if err != nil { + return nil, 0, err + } + + var responses []models.AuditLogResponse + for _, log := range logs { + responses = append(responses, log.ToResponse()) + } + + return responses, total, nil +} + +// LogAction creates a new audit log entry +func (s *AuditService) LogAction(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error { + return s.auditLogRepo.Log(userID, action, entityType, entityID, details, ipAddress, userAgent) +} \ No newline at end of file diff --git a/lost-and-found/internal/services/auth_service.go b/lost-and-found/internal/services/auth_service.go new file mode 100644 index 0000000..74c2df3 --- /dev/null +++ b/lost-and-found/internal/services/auth_service.go @@ -0,0 +1,172 @@ +package services + +import ( + "errors" + "lost-and-found/internal/config" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "lost-and-found/internal/utils" + + "gorm.io/gorm" +) + +type AuthService struct { + userRepo *repositories.UserRepository + roleRepo *repositories.RoleRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewAuthService(db *gorm.DB) *AuthService { + return &AuthService{ + userRepo: repositories.NewUserRepository(db), + roleRepo: repositories.NewRoleRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// RegisterRequest represents registration data +type RegisterRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + NRP string `json:"nrp"` + Phone string `json:"phone"` +} + +// LoginRequest represents login data +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +// AuthResponse represents authentication response +type AuthResponse struct { + Token string `json:"token"` + User models.UserResponse `json:"user"` +} + +// Register registers a new user +func (s *AuthService) Register(req RegisterRequest, ipAddress, userAgent string) (*AuthResponse, error) { + // Check if email already exists + existingUser, _ := s.userRepo.FindByEmail(req.Email) + if existingUser != nil { + return nil, errors.New("email already registered") + } + + // Check if NRP already exists + if req.NRP != "" { + existingNRP, _ := s.userRepo.FindByNRP(req.NRP) + if existingNRP != nil { + return nil, errors.New("NRP already registered") + } + } + + // Hash password + hashedPassword, err := utils.HashPassword(req.Password) + if err != nil { + return nil, errors.New("failed to hash password") + } + + // Get user role ID + userRole, err := s.roleRepo.FindByName(models.RoleUser) + if err != nil { + return nil, errors.New("failed to get user role") + } + + // Create user + user := &models.User{ + Name: req.Name, + Email: req.Email, + Password: hashedPassword, + NRP: req.NRP, + Phone: req.Phone, + RoleID: userRole.ID, + Status: "active", + } + + if err := s.userRepo.Create(user); err != nil { + return nil, errors.New("failed to create user") + } + + // Load user with role + user, err = s.userRepo.FindByID(user.ID) + if err != nil { + return nil, err + } + + // Generate JWT token + token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name) + if err != nil { + return nil, errors.New("failed to generate token") + } + + // Log audit + s.auditLogRepo.Log(&user.ID, models.ActionCreate, models.EntityUser, &user.ID, + "User registered", ipAddress, userAgent) + + return &AuthResponse{ + Token: token, + User: user.ToResponse(), + }, nil +} + +// Login authenticates a user +func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) { + // Find user by email + user, err := s.userRepo.FindByEmail(req.Email) + if err != nil { + return nil, errors.New("invalid email or password") + } + + // Check if user is blocked + if user.IsBlocked() { + return nil, errors.New("account is blocked") + } + + // Verify password + if !utils.CheckPasswordHash(req.Password, user.Password) { + return nil, errors.New("invalid email or password") + } + + // Generate JWT token + token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name) + if err != nil { + return nil, errors.New("failed to generate token") + } + + // Log audit + s.auditLogRepo.Log(&user.ID, models.ActionLogin, models.EntityUser, &user.ID, + "User logged in", ipAddress, userAgent) + + return &AuthResponse{ + Token: token, + User: user.ToResponse(), + }, nil +} + +// ValidateToken validates JWT token and returns user +func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) { + // Validate token + claims, err := config.ValidateToken(tokenString) + if err != nil { + return nil, errors.New("invalid token") + } + + // Get user + user, err := s.userRepo.FindByID(claims.UserID) + if err != nil { + return nil, errors.New("user not found") + } + + // Check if user is blocked + if user.IsBlocked() { + return nil, errors.New("account is blocked") + } + + return user, nil +} + +// RefreshToken refreshes JWT token +func (s *AuthService) RefreshToken(oldToken string) (string, error) { + return config.RefreshToken(oldToken) +} \ No newline at end of file diff --git a/lost-and-found/internal/services/category_service.go b/lost-and-found/internal/services/category_service.go new file mode 100644 index 0000000..153fca7 --- /dev/null +++ b/lost-and-found/internal/services/category_service.go @@ -0,0 +1,147 @@ +package services + +import ( + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "strings" + + "gorm.io/gorm" +) + +type CategoryService struct { + categoryRepo *repositories.CategoryRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewCategoryService(db *gorm.DB) *CategoryService { + return &CategoryService{ + categoryRepo: repositories.NewCategoryRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// CreateCategoryRequest represents category creation data +type CreateCategoryRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` +} + +// UpdateCategoryRequest represents category update data +type UpdateCategoryRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +// GetAllCategories gets all categories +func (s *CategoryService) GetAllCategories() ([]models.CategoryResponse, error) { + return s.categoryRepo.GetAllWithItemCount() +} + +// GetCategoryByID gets category by ID +func (s *CategoryService) GetCategoryByID(id uint) (*models.Category, error) { + return s.categoryRepo.FindByID(id) +} + +// GetCategoryBySlug gets category by slug +func (s *CategoryService) GetCategoryBySlug(slug string) (*models.Category, error) { + return s.categoryRepo.FindBySlug(slug) +} + +// CreateCategory creates a new category (admin only) +func (s *CategoryService) CreateCategory(adminID uint, req CreateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) { + // Generate slug from name + slug := s.generateSlug(req.Name) + + // Check if slug already exists + existing, _ := s.categoryRepo.FindBySlug(slug) + if existing != nil { + return nil, errors.New("category with similar name already exists") + } + + category := &models.Category{ + Name: req.Name, + Slug: slug, + Description: req.Description, + } + + if err := s.categoryRepo.Create(category); err != nil { + return nil, errors.New("failed to create category") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionCreate, models.EntityCategory, &category.ID, + "Category created: "+category.Name, ipAddress, userAgent) + + return category, nil +} + +// UpdateCategory updates a category (admin only) +func (s *CategoryService) UpdateCategory(adminID, categoryID uint, req UpdateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) { + category, err := s.categoryRepo.FindByID(categoryID) + if err != nil { + return nil, err + } + + // Update fields + if req.Name != "" { + category.Name = req.Name + category.Slug = s.generateSlug(req.Name) + } + if req.Description != "" { + category.Description = req.Description + } + + if err := s.categoryRepo.Update(category); err != nil { + return nil, errors.New("failed to update category") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityCategory, &categoryID, + "Category updated: "+category.Name, ipAddress, userAgent) + + return category, nil +} + +// DeleteCategory deletes a category (admin only) +func (s *CategoryService) DeleteCategory(adminID, categoryID uint, ipAddress, userAgent string) error { + category, err := s.categoryRepo.FindByID(categoryID) + if err != nil { + return err + } + + // Check if category has items + _, count, err := s.categoryRepo.GetCategoryWithItemCount(categoryID) + if err != nil { + return err + } + if count > 0 { + return errors.New("cannot delete category with existing items") + } + + if err := s.categoryRepo.Delete(categoryID); err != nil { + return errors.New("failed to delete category") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityCategory, &categoryID, + "Category deleted: "+category.Name, ipAddress, userAgent) + + return nil +} + +// generateSlug generates URL-friendly slug from name +func (s *CategoryService) generateSlug(name string) string { + slug := strings.ToLower(name) + slug = strings.ReplaceAll(slug, " ", "_") + slug = strings.ReplaceAll(slug, "/", "_") + // Remove special characters + validChars := "abcdefghijklmnopqrstuvwxyz0123456789_" + result := "" + for _, char := range slug { + if strings.ContainsRune(validChars, char) { + result += string(char) + } + } + return result +} \ No newline at end of file diff --git a/lost-and-found/internal/services/claim_service.go b/lost-and-found/internal/services/claim_service.go new file mode 100644 index 0000000..7caa90e --- /dev/null +++ b/lost-and-found/internal/services/claim_service.go @@ -0,0 +1,268 @@ +package services + +import ( + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + + "gorm.io/gorm" +) + +type ClaimService struct { + db *gorm.DB // Tambahkan ini + claimRepo *repositories.ClaimRepository + itemRepo *repositories.ItemRepository + verificationRepo *repositories.ClaimVerificationRepository + notificationRepo *repositories.NotificationRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewClaimService(db *gorm.DB) *ClaimService { + return &ClaimService{ + db: db, // Tambahkan ini + claimRepo: repositories.NewClaimRepository(db), + itemRepo: repositories.NewItemRepository(db), + verificationRepo: repositories.NewClaimVerificationRepository(db), + notificationRepo: repositories.NewNotificationRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// CreateClaimRequest represents claim creation data +type CreateClaimRequest struct { + ItemID uint `json:"item_id" binding:"required"` + Description string `json:"description" binding:"required"` + ProofURL string `json:"proof_url"` + Contact string `json:"contact" binding:"required"` +} + +// VerifyClaimRequest represents claim verification data +type VerifyClaimRequest struct { + Status string `json:"status" binding:"required"` // approved or rejected + Notes string `json:"notes"` +} + +// CreateClaim creates a new claim +func (s *ClaimService) CreateClaim(userID uint, req CreateClaimRequest, ipAddress, userAgent string) (*models.Claim, error) { + // Check if item exists + item, err := s.itemRepo.FindByID(req.ItemID) + if err != nil { + return nil, err + } + + // Check if item can be claimed + if !item.CanBeClaimed() { + return nil, errors.New("item cannot be claimed") + } + + // Check if user already claimed this item + exists, err := s.claimRepo.CheckExistingClaim(userID, req.ItemID) + if err != nil { + return nil, err + } + if exists { + return nil, errors.New("you already claimed this item") + } + + // Create claim + claim := &models.Claim{ + ItemID: req.ItemID, + UserID: userID, + Description: req.Description, + ProofURL: req.ProofURL, + Contact: req.Contact, + Status: models.ClaimStatusPending, + } + + if err := s.claimRepo.Create(claim); err != nil { + return nil, errors.New("failed to create claim") + } + + // Update item status to pending claim + s.itemRepo.UpdateStatus(req.ItemID, models.ItemStatusPendingClaim) + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityClaim, &claim.ID, + "Claim created for item: "+item.Name, ipAddress, userAgent) + + // Load claim with relations + return s.claimRepo.FindByID(claim.ID) +} + +// GetAllClaims gets all claims +func (s *ClaimService) GetAllClaims(page, limit int, status string, itemID, userID *uint) ([]models.ClaimResponse, int64, error) { + claims, total, err := s.claimRepo.FindAll(page, limit, status, itemID, userID) + if err != nil { + return nil, 0, err + } + + var responses []models.ClaimResponse + for _, claim := range claims { + responses = append(responses, claim.ToResponse()) + } + + return responses, total, nil +} + +// GetClaimByID gets claim by ID +func (s *ClaimService) GetClaimByID(id uint, isManager bool) (interface{}, error) { + claim, err := s.claimRepo.FindByID(id) + if err != nil { + return nil, err + } + + // Manager can see full details including item description + if isManager { + return claim.ToDetailResponse(), nil + } + + return claim.ToResponse(), nil +} + +// GetClaimsByUser gets claims by user +func (s *ClaimService) GetClaimsByUser(userID uint, page, limit int) ([]models.ClaimResponse, int64, error) { + claims, total, err := s.claimRepo.FindByUser(userID, page, limit) + if err != nil { + return nil, 0, err + } + + var responses []models.ClaimResponse + for _, claim := range claims { + responses = append(responses, claim.ToResponse()) + } + + return responses, total, nil +} + +// VerifyClaim verifies a claim (manager only) +func (s *ClaimService) VerifyClaim(managerID, claimID uint, req VerifyClaimRequest, similarityScore float64, matchedKeywords string, ipAddress, userAgent string) error { + claim, err := s.claimRepo.FindByID(claimID) + if err != nil { + return err + } + + if !claim.IsPending() { + return errors.New("claim is not pending") + } + + // Create or update verification record + verification, _ := s.verificationRepo.FindByClaimID(claimID) + if verification == nil { + verification = &models.ClaimVerification{ + ClaimID: claimID, + SimilarityScore: similarityScore, + MatchedKeywords: matchedKeywords, + VerificationNotes: req.Notes, + IsAutoMatched: false, + } + s.verificationRepo.Create(verification) + } else { + verification.VerificationNotes = req.Notes + s.verificationRepo.Update(verification) + } + + // Update claim status + if req.Status == models.ClaimStatusApproved { + claim.Approve(managerID, req.Notes) + + // Update item status to verified + s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusVerified) + + // Send approval notification - PERBAIKAN DI SINI + item, _ := s.itemRepo.FindByID(claim.ItemID) + models.CreateClaimApprovedNotification(s.db, claim.UserID, item.Name, claimID) + + // Log audit + s.auditLogRepo.Log(&managerID, models.ActionApprove, models.EntityClaim, &claimID, + "Claim approved", ipAddress, userAgent) + } else if req.Status == models.ClaimStatusRejected { + claim.Reject(managerID, req.Notes) + + // Check if there are other pending claims for this item + otherClaims, _ := s.claimRepo.FindByItem(claim.ItemID) + hasPendingClaims := false + for _, c := range otherClaims { + if c.ID != claimID && c.IsPending() { + hasPendingClaims = true + break + } + } + + // If no other pending claims, set item back to unclaimed + if !hasPendingClaims { + s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusUnclaimed) + } + + // Send rejection notification - PERBAIKAN DI SINI + item, _ := s.itemRepo.FindByID(claim.ItemID) + models.CreateClaimRejectedNotification(s.db, claim.UserID, item.Name, req.Notes, claimID) + + // Log audit + s.auditLogRepo.Log(&managerID, models.ActionReject, models.EntityClaim, &claimID, + "Claim rejected: "+req.Notes, ipAddress, userAgent) + } else { + return errors.New("invalid status") + } + + if err := s.claimRepo.Update(claim); err != nil { + return errors.New("failed to verify claim") + } + + return nil +} + +// CloseClaim closes a claim and moves item to archive (manager only) +func (s *ClaimService) CloseClaim(managerID, claimID uint, ipAddress, userAgent string) error { + claim, err := s.claimRepo.FindByID(claimID) + if err != nil { + return err + } + + if !claim.IsApproved() { + return errors.New("only approved claims can be closed") + } + + // Update item status to case_closed + if err := s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusCaseClosed); err != nil { + return errors.New("failed to close case") + } + + // Archive the item + item, _ := s.itemRepo.FindByID(claim.ItemID) + s.itemRepo.ArchiveItem(item, models.ArchiveReasonCaseClosed, &claim.UserID) + + // Log audit + s.auditLogRepo.Log(&managerID, models.ActionUpdate, models.EntityItem, &item.ID, + "Case closed and archived", ipAddress, userAgent) + + return nil +} + +// DeleteClaim deletes a claim +func (s *ClaimService) DeleteClaim(userID, claimID uint, ipAddress, userAgent string) error { + claim, err := s.claimRepo.FindByID(claimID) + if err != nil { + return err + } + + // Only pending claims can be deleted by users + if !claim.IsPending() && claim.UserID == userID { + return errors.New("cannot delete non-pending claim") + } + + if err := s.claimRepo.Delete(claimID); err != nil { + return errors.New("failed to delete claim") + } + + // Check if item should go back to unclaimed + otherClaims, _ := s.claimRepo.FindByItem(claim.ItemID) + if len(otherClaims) == 0 { + s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusUnclaimed) + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityClaim, &claimID, + "Claim deleted", ipAddress, userAgent) + + return nil +} \ No newline at end of file diff --git a/lost-and-found/internal/services/export_service.go b/lost-and-found/internal/services/export_service.go new file mode 100644 index 0000000..4bef52a --- /dev/null +++ b/lost-and-found/internal/services/export_service.go @@ -0,0 +1,254 @@ +package services + +import ( + "bytes" + "fmt" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "lost-and-found/internal/utils" + "time" + + "gorm.io/gorm" +) + +type ExportService struct { + itemRepo *repositories.ItemRepository + archiveRepo *repositories.ArchiveRepository + claimRepo *repositories.ClaimRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewExportService(db *gorm.DB) *ExportService { + return &ExportService{ + itemRepo: repositories.NewItemRepository(db), + archiveRepo: repositories.NewArchiveRepository(db), + claimRepo: repositories.NewClaimRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// ExportRequest represents export request data +type ExportRequest struct { + Type string `json:"type"` // items, archives, claims, audit_logs + Format string `json:"format"` // pdf, excel + StartDate *time.Time `json:"start_date"` + EndDate *time.Time `json:"end_date"` + Status string `json:"status"` +} + +// ExportItemsToPDF exports items to PDF +func (s *ExportService) ExportItemsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) { + // Get items + items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "") + if err != nil { + return nil, err + } + + // Filter by date range if provided + var filteredItems []models.Item + for _, item := range items { + if req.StartDate != nil && item.DateFound.Before(*req.StartDate) { + continue + } + if req.EndDate != nil && item.DateFound.After(*req.EndDate) { + continue + } + filteredItems = append(filteredItems, item) + } + + // Generate PDF + pdf := utils.NewPDFExporter() + pdf.AddTitle("Laporan Barang Ditemukan") + pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s", + formatDate(req.StartDate), + formatDate(req.EndDate))) + pdf.AddNewLine() + + // Add table + headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Tanggal Ditemukan", "Status"} + var data [][]string + for i, item := range filteredItems { + data = append(data, []string{ + fmt.Sprintf("%d", i+1), + item.Name, + item.Category.Name, + item.Location, + item.DateFound.Format("02 Jan 2006"), + item.Status, + }) + } + pdf.AddTable(headers, data) + + // Add footer + pdf.AddNewLine() + pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredItems))) + pdf.AddText(fmt.Sprintf("Dicetak pada: %s", time.Now().Format("02 January 2006 15:04"))) + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil, + fmt.Sprintf("Exported items report (PDF, %d items)", len(filteredItems)), + ipAddress, userAgent) + + return pdf.Output(), nil +} + +// ExportItemsToExcel exports items to Excel +func (s *ExportService) ExportItemsToExcel(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) { + // Get items + items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "") + if err != nil { + return nil, err + } + + // Filter by date range if provided + var filteredItems []models.Item + for _, item := range items { + if req.StartDate != nil && item.DateFound.Before(*req.StartDate) { + continue + } + if req.EndDate != nil && item.DateFound.After(*req.EndDate) { + continue + } + filteredItems = append(filteredItems, item) + } + + // Generate Excel + excel := utils.NewExcelExporter() + excel.SetSheetName("Barang Ditemukan") + + // Add headers + headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Deskripsi", + "Tanggal Ditemukan", "Status", "Pelapor", "Kontak"} + excel.AddRow(headers) + + // Add data + for i, item := range filteredItems { + excel.AddRow([]string{ + fmt.Sprintf("%d", i+1), + item.Name, + item.Category.Name, + item.Location, + item.Description, + item.DateFound.Format("02 Jan 2006"), + item.Status, + item.ReporterName, + item.ReporterContact, + }) + } + + // Auto-size columns + excel.AutoSizeColumns(len(headers)) + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil, + fmt.Sprintf("Exported items report (Excel, %d items)", len(filteredItems)), + ipAddress, userAgent) + + return excel.Output() +} + +// ExportArchivesToPDF exports archives to PDF +func (s *ExportService) ExportArchivesToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) { + archives, _, err := s.archiveRepo.FindAll(1, 10000, "", "") + if err != nil { + return nil, err + } + + // Filter by date range + var filteredArchives []models.Archive + for _, archive := range archives { + if req.StartDate != nil && archive.ArchivedAt.Before(*req.StartDate) { + continue + } + if req.EndDate != nil && archive.ArchivedAt.After(*req.EndDate) { + continue + } + filteredArchives = append(filteredArchives, archive) + } + + pdf := utils.NewPDFExporter() + pdf.AddTitle("Laporan Barang yang Diarsipkan") + pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s", + formatDate(req.StartDate), + formatDate(req.EndDate))) + pdf.AddNewLine() + + headers := []string{"No", "Nama Barang", "Kategori", "Alasan Arsip", "Tanggal Arsip"} + var data [][]string + for i, archive := range filteredArchives { + data = append(data, []string{ + fmt.Sprintf("%d", i+1), + archive.Name, + archive.Category.Name, + archive.ArchivedReason, + archive.ArchivedAt.Format("02 Jan 2006"), + }) + } + pdf.AddTable(headers, data) + + pdf.AddNewLine() + pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredArchives))) + + s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil, + fmt.Sprintf("Exported archives report (PDF, %d items)", len(filteredArchives)), + ipAddress, userAgent) + + return pdf.Output(), nil +} + +// ExportClaimsToPDF exports claims to PDF +func (s *ExportService) ExportClaimsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) { + claims, _, err := s.claimRepo.FindAll(1, 10000, req.Status, nil, nil) + if err != nil { + return nil, err + } + + // Filter by date range + var filteredClaims []models.Claim + for _, claim := range claims { + if req.StartDate != nil && claim.CreatedAt.Before(*req.StartDate) { + continue + } + if req.EndDate != nil && claim.CreatedAt.After(*req.EndDate) { + continue + } + filteredClaims = append(filteredClaims, claim) + } + + pdf := utils.NewPDFExporter() + pdf.AddTitle("Laporan Klaim Barang") + pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s", + formatDate(req.StartDate), + formatDate(req.EndDate))) + pdf.AddNewLine() + + headers := []string{"No", "Barang", "Pengklaim", "Status", "Tanggal Klaim"} + var data [][]string + for i, claim := range filteredClaims { + data = append(data, []string{ + fmt.Sprintf("%d", i+1), + claim.Item.Name, + claim.User.Name, + claim.Status, + claim.CreatedAt.Format("02 Jan 2006"), + }) + } + pdf.AddTable(headers, data) + + pdf.AddNewLine() + pdf.AddText(fmt.Sprintf("Total: %d klaim", len(filteredClaims))) + + s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil, + fmt.Sprintf("Exported claims report (PDF, %d claims)", len(filteredClaims)), + ipAddress, userAgent) + + return pdf.Output(), nil +} + +// Helper function to format date +func formatDate(date *time.Time) string { + if date == nil { + return "N/A" + } + return date.Format("02 Jan 2006") +} \ No newline at end of file diff --git a/lost-and-found/internal/services/item_service.go b/lost-and-found/internal/services/item_service.go new file mode 100644 index 0000000..e749717 --- /dev/null +++ b/lost-and-found/internal/services/item_service.go @@ -0,0 +1,211 @@ +package services + +import ( + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "time" + + "gorm.io/gorm" +) + +type ItemService struct { + itemRepo *repositories.ItemRepository + categoryRepo *repositories.CategoryRepository + auditLogRepo *repositories.AuditLogRepository + revisionRepo *repositories.RevisionLogRepository +} + +func NewItemService(db *gorm.DB) *ItemService { + return &ItemService{ + itemRepo: repositories.NewItemRepository(db), + categoryRepo: repositories.NewCategoryRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + revisionRepo: repositories.NewRevisionLogRepository(db), + } +} + +// CreateItemRequest represents create item data +type CreateItemRequest struct { + Name string `json:"name" binding:"required"` + CategoryID uint `json:"category_id" binding:"required"` + PhotoURL string `json:"photo_url"` + Location string `json:"location" binding:"required"` + Description string `json:"description" binding:"required"` + DateFound time.Time `json:"date_found" binding:"required"` + ReporterName string `json:"reporter_name" binding:"required"` + ReporterContact string `json:"reporter_contact" binding:"required"` +} + +// UpdateItemRequest represents update item data +type UpdateItemRequest struct { + Name string `json:"name"` + CategoryID uint `json:"category_id"` + Location string `json:"location"` + Description string `json:"description"` + DateFound time.Time `json:"date_found"` + ReporterName string `json:"reporter_name"` + ReporterContact string `json:"reporter_contact"` + Reason string `json:"reason"` // Reason for edit +} + +// GetAllItems gets all items (public view) +func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) { + items, total, err := s.itemRepo.FindAll(page, limit, status, category, search) + if err != nil { + return nil, 0, err + } + + var responses []models.ItemPublicResponse + for _, item := range items { + responses = append(responses, item.ToPublicResponse()) + } + + return responses, total, nil +} + +// GetItemByID gets item by ID +func (s *ItemService) GetItemByID(id uint, isManager bool) (interface{}, error) { + item, err := s.itemRepo.FindByID(id) + if err != nil { + return nil, err + } + + // Manager can see full details + if isManager { + return item.ToDetailResponse(), nil + } + + // Public can only see limited info + return item.ToPublicResponse(), nil +} + +// CreateItem creates a new item +func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) { + // Verify category exists + if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil { + return nil, errors.New("invalid category") + } + + item := &models.Item{ + Name: req.Name, + CategoryID: req.CategoryID, + PhotoURL: req.PhotoURL, + Location: req.Location, + Description: req.Description, + DateFound: req.DateFound, + Status: models.ItemStatusUnclaimed, + ReporterID: reporterID, + ReporterName: req.ReporterName, + ReporterContact: req.ReporterContact, + } + + if err := s.itemRepo.Create(item); err != nil { + return nil, errors.New("failed to create item") + } + + // Log audit + s.auditLogRepo.Log(&reporterID, models.ActionCreate, models.EntityItem, &item.ID, + "Item created: "+item.Name, ipAddress, userAgent) + + return item, nil +} + +// UpdateItem updates an item +func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) { + item, err := s.itemRepo.FindByID(itemID) + if err != nil { + return nil, err + } + + // Check if item can be edited + if !item.CanBeEdited() { + return nil, errors.New("cannot edit item with status: " + item.Status) + } + + // Track changes for revision log + if req.Name != "" && req.Name != item.Name { + s.revisionRepo.Log(itemID, userID, "name", item.Name, req.Name, req.Reason) + item.Name = req.Name + } + if req.CategoryID != 0 && req.CategoryID != item.CategoryID { + oldCat, _ := s.categoryRepo.FindByID(item.CategoryID) + newCat, _ := s.categoryRepo.FindByID(req.CategoryID) + s.revisionRepo.Log(itemID, userID, "category", oldCat.Name, newCat.Name, req.Reason) + item.CategoryID = req.CategoryID + } + if req.Location != "" && req.Location != item.Location { + s.revisionRepo.Log(itemID, userID, "location", item.Location, req.Location, req.Reason) + item.Location = req.Location + } + if req.Description != "" && req.Description != item.Description { + s.revisionRepo.Log(itemID, userID, "description", item.Description, req.Description, req.Reason) + item.Description = req.Description + } + + if err := s.itemRepo.Update(item); err != nil { + return nil, errors.New("failed to update item") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityItem, &itemID, + "Item updated: "+item.Name, ipAddress, userAgent) + + return item, nil +} + +// UpdateItemStatus updates item status +func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error { + if err := s.itemRepo.UpdateStatus(itemID, status); err != nil { + return errors.New("failed to update item status") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityItem, &itemID, + "Item status updated to: "+status, ipAddress, userAgent) + + return nil +} + +// DeleteItem deletes an item +func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error { + item, err := s.itemRepo.FindByID(itemID) + if err != nil { + return err + } + + // Cannot delete verified or case closed items + if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed { + return errors.New("cannot delete item with status: " + item.Status) + } + + if err := s.itemRepo.Delete(itemID); err != nil { + return errors.New("failed to delete item") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityItem, &itemID, + "Item deleted: "+item.Name, ipAddress, userAgent) + + return nil +} + +// GetItemsByReporter gets items by reporter +func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) { + return s.itemRepo.FindByReporter(reporterID, page, limit) +} + +// GetItemRevisionHistory gets revision history for an item +func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) { + logs, total, err := s.revisionRepo.FindByItem(itemID, page, limit) + if err != nil { + return nil, 0, err + } + + var responses []models.RevisionLogResponse + for _, log := range logs { + responses = append(responses, log.ToResponse()) + } + + return responses, total, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/services/lost_item_service.go b/lost-and-found/internal/services/lost_item_service.go new file mode 100644 index 0000000..fdf32f8 --- /dev/null +++ b/lost-and-found/internal/services/lost_item_service.go @@ -0,0 +1,207 @@ +package services + +import ( + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "time" + + "gorm.io/gorm" +) + +type LostItemService struct { + lostItemRepo *repositories.LostItemRepository + categoryRepo *repositories.CategoryRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewLostItemService(db *gorm.DB) *LostItemService { + return &LostItemService{ + lostItemRepo: repositories.NewLostItemRepository(db), + categoryRepo: repositories.NewCategoryRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// CreateLostItemRequest represents create lost item data +type CreateLostItemRequest struct { + Name string `json:"name" binding:"required"` + CategoryID uint `json:"category_id" binding:"required"` + Color string `json:"color"` + Location string `json:"location"` + Description string `json:"description" binding:"required"` + DateLost time.Time `json:"date_lost" binding:"required"` +} + +// UpdateLostItemRequest represents update lost item data +type UpdateLostItemRequest struct { + Name string `json:"name"` + CategoryID uint `json:"category_id"` + Color string `json:"color"` + Location string `json:"location"` + Description string `json:"description"` + DateLost time.Time `json:"date_lost"` +} + +// GetAllLostItems gets all lost items +func (s *LostItemService) GetAllLostItems(page, limit int, status, category, search string, userID *uint) ([]models.LostItemResponse, int64, error) { + lostItems, total, err := s.lostItemRepo.FindAll(page, limit, status, category, search, userID) + if err != nil { + return nil, 0, err + } + + var responses []models.LostItemResponse + for _, lostItem := range lostItems { + responses = append(responses, lostItem.ToResponse()) + } + + return responses, total, nil +} + +// GetLostItemByID gets lost item by ID +func (s *LostItemService) GetLostItemByID(id uint) (*models.LostItem, error) { + return s.lostItemRepo.FindByID(id) +} + +// CreateLostItem creates a new lost item report +func (s *LostItemService) CreateLostItem(userID uint, req CreateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) { + // Verify category exists + if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil { + return nil, errors.New("invalid category") + } + + lostItem := &models.LostItem{ + UserID: userID, + Name: req.Name, + CategoryID: req.CategoryID, + Color: req.Color, + Location: req.Location, + Description: req.Description, + DateLost: req.DateLost, + Status: models.LostItemStatusActive, + } + + if err := s.lostItemRepo.Create(lostItem); err != nil { + return nil, errors.New("failed to create lost item report") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityLostItem, &lostItem.ID, + "Lost item report created: "+lostItem.Name, ipAddress, userAgent) + + // Load with relations + return s.lostItemRepo.FindByID(lostItem.ID) +} + +// UpdateLostItem updates a lost item report +func (s *LostItemService) UpdateLostItem(userID, lostItemID uint, req UpdateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) { + lostItem, err := s.lostItemRepo.FindByID(lostItemID) + if err != nil { + return nil, err + } + + // Only owner can update + if lostItem.UserID != userID { + return nil, errors.New("unauthorized to update this lost item report") + } + + // Only active reports can be updated + if !lostItem.IsActive() { + return nil, errors.New("cannot update non-active lost item report") + } + + // Update fields + if req.Name != "" { + lostItem.Name = req.Name + } + if req.CategoryID != 0 { + // Verify category exists + if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil { + return nil, errors.New("invalid category") + } + lostItem.CategoryID = req.CategoryID + } + if req.Color != "" { + lostItem.Color = req.Color + } + if req.Location != "" { + lostItem.Location = req.Location + } + if req.Description != "" { + lostItem.Description = req.Description + } + if !req.DateLost.IsZero() { + lostItem.DateLost = req.DateLost + } + + if err := s.lostItemRepo.Update(lostItem); err != nil { + return nil, errors.New("failed to update lost item report") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID, + "Lost item report updated: "+lostItem.Name, ipAddress, userAgent) + + return lostItem, nil +} + +// UpdateLostItemStatus updates lost item status +func (s *LostItemService) UpdateLostItemStatus(userID, lostItemID uint, status string, ipAddress, userAgent string) error { + lostItem, err := s.lostItemRepo.FindByID(lostItemID) + if err != nil { + return err + } + + // Only owner can update + if lostItem.UserID != userID { + return errors.New("unauthorized to update this lost item report") + } + + if err := s.lostItemRepo.UpdateStatus(lostItemID, status); err != nil { + return errors.New("failed to update lost item status") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID, + "Lost item status updated to: "+status, ipAddress, userAgent) + + return nil +} + +// DeleteLostItem deletes a lost item report +func (s *LostItemService) DeleteLostItem(userID, lostItemID uint, ipAddress, userAgent string) error { + lostItem, err := s.lostItemRepo.FindByID(lostItemID) + if err != nil { + return err + } + + // Only owner can delete + if lostItem.UserID != userID { + return errors.New("unauthorized to delete this lost item report") + } + + if err := s.lostItemRepo.Delete(lostItemID); err != nil { + return errors.New("failed to delete lost item report") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityLostItem, &lostItemID, + "Lost item report deleted: "+lostItem.Name, ipAddress, userAgent) + + return nil +} + +// GetLostItemsByUser gets lost items by user +func (s *LostItemService) GetLostItemsByUser(userID uint, page, limit int) ([]models.LostItemResponse, int64, error) { + lostItems, total, err := s.lostItemRepo.FindByUser(userID, page, limit) + if err != nil { + return nil, 0, err + } + + var responses []models.LostItemResponse + for _, lostItem := range lostItems { + responses = append(responses, lostItem.ToResponse()) + } + + return responses, total, nil +} \ No newline at end of file diff --git a/lost-and-found/internal/services/match_service.go b/lost-and-found/internal/services/match_service.go new file mode 100644 index 0000000..fff50f9 --- /dev/null +++ b/lost-and-found/internal/services/match_service.go @@ -0,0 +1,239 @@ +package services + +import ( + "encoding/json" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "lost-and-found/internal/utils" + + "gorm.io/gorm" +) + +type MatchService struct { + db *gorm.DB // Tambahkan ini + matchRepo *repositories.MatchResultRepository + itemRepo *repositories.ItemRepository + lostItemRepo *repositories.LostItemRepository + notificationRepo *repositories.NotificationRepository +} + +func NewMatchService(db *gorm.DB) *MatchService { + return &MatchService{ + db: db, // Tambahkan ini + matchRepo: repositories.NewMatchResultRepository(db), + itemRepo: repositories.NewItemRepository(db), + lostItemRepo: repositories.NewLostItemRepository(db), + notificationRepo: repositories.NewNotificationRepository(db), + } +} + +// MatchedField represents a matched field between items +type MatchedField struct { + Field string `json:"field"` + LostValue string `json:"lost_value"` + FoundValue string `json:"found_value"` + Score float64 `json:"score"` +} + +// FindSimilarItems finds similar items for a lost item report +func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) { + lostItem, err := s.lostItemRepo.FindByID(lostItemID) + if err != nil { + return nil, err + } + + // Search for items in same category + items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color) + if err != nil { + return nil, err + } + + var results []models.MatchResultResponse + for _, item := range items { + // Calculate similarity + score, matchedFields := s.calculateSimilarity(lostItem, &item) + + // Only include if score is reasonable (>= 30%) + if score >= 30.0 { + // Check if match already exists + exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID) + if !exists { + // Create match result + matchedFieldsJSON, _ := json.Marshal(matchedFields) + match := &models.MatchResult{ + LostItemID: lostItemID, + ItemID: item.ID, + SimilarityScore: score, + MatchedFields: string(matchedFieldsJSON), + IsNotified: false, + } + s.matchRepo.Create(match) + + // Reload with relations + match, _ = s.matchRepo.FindByID(match.ID) + results = append(results, match.ToResponse()) + } + } + } + + return results, nil +} + +// GetMatchesForLostItem gets all matches for a lost item +func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) { + matches, err := s.matchRepo.FindByLostItem(lostItemID) + if err != nil { + return nil, err + } + + var responses []models.MatchResultResponse + for _, match := range matches { + responses = append(responses, match.ToResponse()) + } + + return responses, nil +} + +// GetMatchesForItem gets all matches for an item +func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) { + matches, err := s.matchRepo.FindByItem(itemID) + if err != nil { + return nil, err + } + + var responses []models.MatchResultResponse + for _, match := range matches { + responses = append(responses, match.ToResponse()) + } + + return responses, nil +} + +// AutoMatchNewItem automatically matches a new item with lost items +func (s *MatchService) AutoMatchNewItem(itemID uint) error { + item, err := s.itemRepo.FindByID(itemID) + if err != nil { + return err + } + + // Find active lost items in same category + lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID) + if err != nil { + return err + } + + for _, lostItem := range lostItems { + // Calculate similarity + score, matchedFields := s.calculateSimilarity(&lostItem, item) + + // Create match if score is high enough (>= 50% for auto-match) + if score >= 50.0 { + // Check if match already exists + exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID) + if !exists { + matchedFieldsJSON, _ := json.Marshal(matchedFields) + match := &models.MatchResult{ + LostItemID: lostItem.ID, + ItemID: itemID, + SimilarityScore: score, + MatchedFields: string(matchedFieldsJSON), + IsNotified: false, + } + s.matchRepo.Create(match) + + // Send notification to lost item owner - PERBAIKAN DI SINI + models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID) + s.matchRepo.MarkAsNotified(match.ID) + } + } + } + + return nil +} + +// calculateSimilarity calculates similarity between lost item and found item +func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) { + var matchedFields []MatchedField + totalScore := 0.0 + maxScore := 0.0 + + // Category match (20 points) + maxScore += 20 + if lostItem.CategoryID == item.CategoryID { + totalScore += 20 + matchedFields = append(matchedFields, MatchedField{ + Field: "category", + LostValue: lostItem.Category.Name, + FoundValue: item.Category.Name, + Score: 20, + }) + } + + // Name similarity (30 points) + maxScore += 30 + nameSimilarity := utils.CalculateStringSimilarity(lostItem.Name, item.Name) + nameScore := nameSimilarity * 30 + totalScore += nameScore + if nameScore > 10 { + matchedFields = append(matchedFields, MatchedField{ + Field: "name", + LostValue: lostItem.Name, + FoundValue: item.Name, + Score: nameScore, + }) + } + + // Color match (15 points) + if lostItem.Color != "" { + maxScore += 15 + colorSimilarity := utils.CalculateStringSimilarity(lostItem.Color, item.Name+" "+item.Description) + colorScore := colorSimilarity * 15 + totalScore += colorScore + if colorScore > 5 { + matchedFields = append(matchedFields, MatchedField{ + Field: "color", + LostValue: lostItem.Color, + FoundValue: "matched in description", + Score: colorScore, + }) + } + } + + // Location match (20 points) + if lostItem.Location != "" { + maxScore += 20 + locationSimilarity := utils.CalculateStringSimilarity(lostItem.Location, item.Location) + locationScore := locationSimilarity * 20 + totalScore += locationScore + if locationScore > 10 { + matchedFields = append(matchedFields, MatchedField{ + Field: "location", + LostValue: lostItem.Location, + FoundValue: item.Location, + Score: locationScore, + }) + } + } + + // Description keywords match (15 points) + maxScore += 15 + descSimilarity := utils.CalculateStringSimilarity(lostItem.Description, item.Description) + descScore := descSimilarity * 15 + totalScore += descScore + if descScore > 5 { + matchedFields = append(matchedFields, MatchedField{ + Field: "description", + LostValue: "keywords matched", + FoundValue: "keywords matched", + Score: descScore, + }) + } + + // Calculate percentage + percentage := (totalScore / maxScore) * 100 + if percentage > 100 { + percentage = 100 + } + + return percentage, matchedFields +} \ No newline at end of file diff --git a/lost-and-found/internal/services/notification_service.go b/lost-and-found/internal/services/notification_service.go new file mode 100644 index 0000000..a75d0fa --- /dev/null +++ b/lost-and-found/internal/services/notification_service.go @@ -0,0 +1,115 @@ +package services + +import ( + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + + "gorm.io/gorm" +) + +type NotificationService struct { + db *gorm.DB // Tambahkan ini + notificationRepo *repositories.NotificationRepository +} + +func NewNotificationService(db *gorm.DB) *NotificationService { + return &NotificationService{ + db: db, // Tambahkan ini + notificationRepo: repositories.NewNotificationRepository(db), + } +} +// GetUserNotifications gets notifications for a user +func (s *NotificationService) GetUserNotifications(userID uint, page, limit int, onlyUnread bool) ([]models.NotificationResponse, int64, error) { + notifications, total, err := s.notificationRepo.FindByUser(userID, page, limit, onlyUnread) + if err != nil { + return nil, 0, err + } + + var responses []models.NotificationResponse + for _, notification := range notifications { + responses = append(responses, notification.ToResponse()) + } + + return responses, total, nil +} + +// GetNotificationByID gets notification by ID +func (s *NotificationService) GetNotificationByID(userID, notificationID uint) (*models.Notification, error) { + notification, err := s.notificationRepo.FindByID(notificationID) + if err != nil { + return nil, err + } + + // Check ownership + if notification.UserID != userID { + return nil, errors.New("unauthorized") + } + + return notification, nil +} + +// MarkAsRead marks a notification as read +func (s *NotificationService) MarkAsRead(userID, notificationID uint) error { + notification, err := s.notificationRepo.FindByID(notificationID) + if err != nil { + return err + } + + // Check ownership + if notification.UserID != userID { + return errors.New("unauthorized") + } + + return s.notificationRepo.MarkAsRead(notificationID) +} + +// MarkAllAsRead marks all notifications as read for a user +func (s *NotificationService) MarkAllAsRead(userID uint) error { + return s.notificationRepo.MarkAllAsRead(userID) +} + +// DeleteNotification deletes a notification +func (s *NotificationService) DeleteNotification(userID, notificationID uint) error { + notification, err := s.notificationRepo.FindByID(notificationID) + if err != nil { + return err + } + + // Check ownership + if notification.UserID != userID { + return errors.New("unauthorized") + } + + return s.notificationRepo.Delete(notificationID) +} + +// DeleteAllNotifications deletes all notifications for a user +func (s *NotificationService) DeleteAllNotifications(userID uint) error { + return s.notificationRepo.DeleteAllForUser(userID) +} + +// CountUnread counts unread notifications for a user +func (s *NotificationService) CountUnread(userID uint) (int64, error) { + return s.notificationRepo.CountUnread(userID) +} + +// CreateNotification creates a new notification +func (s *NotificationService) CreateNotification(userID uint, notifType, title, message, entityType string, entityID *uint) error { + return s.notificationRepo.Notify(userID, notifType, title, message, entityType, entityID) +} + +// SendMatchNotification sends notification when a match is found +func (s *NotificationService) SendMatchNotification(userID uint, itemName string, matchID uint) error { + return models.CreateMatchNotification(s.db, userID, itemName, matchID) +} + +// SendClaimApprovedNotification sends notification when claim is approved +func (s *NotificationService) SendClaimApprovedNotification(userID uint, itemName string, claimID uint) error { + return models.CreateClaimApprovedNotification(s.db, userID, itemName, claimID) +} + +// SendClaimRejectedNotification sends notification when claim is rejected +func (s *NotificationService) SendClaimRejectedNotification(userID uint, itemName, reason string, claimID uint) error { + return models.CreateClaimRejectedNotification(s.db, userID, itemName, reason, claimID) +} \ No newline at end of file diff --git a/lost-and-found/internal/services/user_service.go b/lost-and-found/internal/services/user_service.go new file mode 100644 index 0000000..8d94f93 --- /dev/null +++ b/lost-and-found/internal/services/user_service.go @@ -0,0 +1,190 @@ +package services + +import ( + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "lost-and-found/internal/utils" + + "gorm.io/gorm" +) + +type UserService struct { + userRepo *repositories.UserRepository + roleRepo *repositories.RoleRepository + auditLogRepo *repositories.AuditLogRepository +} + +func NewUserService(db *gorm.DB) *UserService { + return &UserService{ + userRepo: repositories.NewUserRepository(db), + roleRepo: repositories.NewRoleRepository(db), + auditLogRepo: repositories.NewAuditLogRepository(db), + } +} + +// UpdateProfileRequest represents profile update data +type UpdateProfileRequest struct { + Name string `json:"name"` + Phone string `json:"phone"` + NRP string `json:"nrp"` +} + +// ChangePasswordRequest represents password change data +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// GetProfile gets user profile +func (s *UserService) GetProfile(userID uint) (*models.User, error) { + return s.userRepo.FindByID(userID) +} + +// UpdateProfile updates user profile +func (s *UserService) UpdateProfile(userID uint, req UpdateProfileRequest, ipAddress, userAgent string) (*models.User, error) { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return nil, err + } + + // Update fields + if req.Name != "" { + user.Name = req.Name + } + if req.Phone != "" { + user.Phone = req.Phone + } + if req.NRP != "" { + // Check if NRP already exists for another user + existingNRP, _ := s.userRepo.FindByNRP(req.NRP) + if existingNRP != nil && existingNRP.ID != userID { + return nil, errors.New("NRP already used by another user") + } + user.NRP = req.NRP + } + + if err := s.userRepo.Update(user); err != nil { + return nil, errors.New("failed to update profile") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID, + "Profile updated", ipAddress, userAgent) + + return user, nil +} + +// ChangePassword changes user password +func (s *UserService) ChangePassword(userID uint, req ChangePasswordRequest, ipAddress, userAgent string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return err + } + + // Verify old password + if !utils.CheckPasswordHash(req.OldPassword, user.Password) { + return errors.New("invalid old password") + } + + // Hash new password + hashedPassword, err := utils.HashPassword(req.NewPassword) + if err != nil { + return errors.New("failed to hash password") + } + + user.Password = hashedPassword + if err := s.userRepo.Update(user); err != nil { + return errors.New("failed to change password") + } + + // Log audit + s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID, + "Password changed", ipAddress, userAgent) + + return nil +} + +// GetUserStats gets user statistics +func (s *UserService) GetUserStats(userID uint) (map[string]interface{}, error) { + return s.userRepo.GetUserStats(userID) +} + +// GetAllUsers gets all users (admin only) +func (s *UserService) GetAllUsers(page, limit int) ([]models.User, int64, error) { + return s.userRepo.FindAll(page, limit) +} + +// GetUserByID gets user by ID (admin only) +func (s *UserService) GetUserByID(id uint) (*models.User, error) { + return s.userRepo.FindByID(id) +} + +// UpdateUserRole updates user role (admin only) +func (s *UserService) UpdateUserRole(adminID, userID, roleID uint, ipAddress, userAgent string) error { + // Verify role exists + role, err := s.roleRepo.FindByID(roleID) + if err != nil { + return errors.New("invalid role") + } + + // Update role + if err := s.userRepo.UpdateRole(userID, roleID); err != nil { + return errors.New("failed to update user role") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityUser, &userID, + "Role updated to: "+role.Name, ipAddress, userAgent) + + return nil +} + +// BlockUser blocks a user (admin only) +func (s *UserService) BlockUser(adminID, userID uint, ipAddress, userAgent string) error { + // Cannot block self + if adminID == userID { + return errors.New("cannot block yourself") + } + + if err := s.userRepo.BlockUser(userID); err != nil { + return errors.New("failed to block user") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionBlock, models.EntityUser, &userID, + "User blocked", ipAddress, userAgent) + + return nil +} + +// UnblockUser unblocks a user (admin only) +func (s *UserService) UnblockUser(adminID, userID uint, ipAddress, userAgent string) error { + if err := s.userRepo.UnblockUser(userID); err != nil { + return errors.New("failed to unblock user") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionUnblock, models.EntityUser, &userID, + "User unblocked", ipAddress, userAgent) + + return nil +} + +// DeleteUser deletes a user (admin only) +func (s *UserService) DeleteUser(adminID, userID uint, ipAddress, userAgent string) error { + // Cannot delete self + if adminID == userID { + return errors.New("cannot delete yourself") + } + + if err := s.userRepo.Delete(userID); err != nil { + return errors.New("failed to delete user") + } + + // Log audit + s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityUser, &userID, + "User deleted", ipAddress, userAgent) + + return nil +} \ No newline at end of file diff --git a/lost-and-found/internal/services/verification_service.go b/lost-and-found/internal/services/verification_service.go new file mode 100644 index 0000000..35e9c09 --- /dev/null +++ b/lost-and-found/internal/services/verification_service.go @@ -0,0 +1,153 @@ +package services + +import ( + "encoding/json" + "errors" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "lost-and-found/internal/utils" + + "gorm.io/gorm" +) + +type VerificationService struct { + verificationRepo *repositories.ClaimVerificationRepository + claimRepo *repositories.ClaimRepository + itemRepo *repositories.ItemRepository +} + +func NewVerificationService(db *gorm.DB) *VerificationService { + return &VerificationService{ + verificationRepo: repositories.NewClaimVerificationRepository(db), + claimRepo: repositories.NewClaimRepository(db), + itemRepo: repositories.NewItemRepository(db), + } +} + +// VerificationResult represents the verification result +type VerificationResult struct { + SimilarityScore float64 `json:"similarity_score"` + MatchLevel string `json:"match_level"` + MatchedKeywords []string `json:"matched_keywords"` + Details map[string]string `json:"details"` + Recommendation string `json:"recommendation"` +} + +// VerifyClaimDescription verifies claim description against item description +func (s *VerificationService) VerifyClaimDescription(claimID uint) (*VerificationResult, error) { + claim, err := s.claimRepo.FindByID(claimID) + if err != nil { + return nil, err + } + + item, err := s.itemRepo.FindByID(claim.ItemID) + if err != nil { + return nil, err + } + + // Calculate similarity between claim description and item description + similarity := utils.CalculateStringSimilarity(claim.Description, item.Description) + similarityPercent := similarity * 100 + + // Extract matched keywords + claimKeywords := utils.ExtractKeywords(claim.Description) + itemKeywords := utils.ExtractKeywords(item.Description) + matchedKeywords := utils.FindMatchedKeywords(claimKeywords, itemKeywords) + + // Determine match level + matchLevel := "low" + recommendation := "REJECT - Deskripsi tidak cocok" + + if similarityPercent >= 70.0 { + matchLevel = "high" + recommendation = "APPROVE - Deskripsi sangat cocok" + } else if similarityPercent >= 50.0 { + matchLevel = "medium" + recommendation = "REVIEW - Perlu verifikasi lebih lanjut" + } + + // Create or update verification record + verification, _ := s.verificationRepo.FindByClaimID(claimID) + if verification == nil { + verification = &models.ClaimVerification{ + ClaimID: claimID, + SimilarityScore: similarityPercent, + MatchedKeywords: stringSliceToJSON(matchedKeywords), + IsAutoMatched: false, + } + s.verificationRepo.Create(verification) + } else { + verification.SimilarityScore = similarityPercent + verification.MatchedKeywords = stringSliceToJSON(matchedKeywords) + s.verificationRepo.Update(verification) + } + + return &VerificationResult{ + SimilarityScore: similarityPercent, + MatchLevel: matchLevel, + MatchedKeywords: matchedKeywords, + Details: map[string]string{ + "claim_description": claim.Description, + "item_description": item.Description, + "matched_count": string(len(matchedKeywords)), + }, + Recommendation: recommendation, + }, nil +} + +// GetVerificationByClaimID gets verification data for a claim +func (s *VerificationService) GetVerificationByClaimID(claimID uint) (*models.ClaimVerification, error) { + verification, err := s.verificationRepo.FindByClaimID(claimID) + if err != nil { + return nil, err + } + if verification == nil { + return nil, errors.New("verification not found") + } + return verification, nil +} + +// GetHighMatchVerifications gets all high match verifications +func (s *VerificationService) GetHighMatchVerifications() ([]models.ClaimVerificationResponse, error) { + verifications, err := s.verificationRepo.FindHighMatches() + if err != nil { + return nil, err + } + + var responses []models.ClaimVerificationResponse + for _, v := range verifications { + responses = append(responses, v.ToResponse()) + } + + return responses, nil +} + +// CompareDescriptions provides detailed comparison between two descriptions +func (s *VerificationService) CompareDescriptions(desc1, desc2 string) map[string]interface{} { + similarity := utils.CalculateStringSimilarity(desc1, desc2) + + keywords1 := utils.ExtractKeywords(desc1) + keywords2 := utils.ExtractKeywords(desc2) + matchedKeywords := utils.FindMatchedKeywords(keywords1, keywords2) + + return map[string]interface{}{ + "similarity_score": similarity * 100, + "description_1": desc1, + "description_2": desc2, + "keywords_1": keywords1, + "keywords_2": keywords2, + "matched_keywords": matchedKeywords, + "total_keywords_1": len(keywords1), + "total_keywords_2": len(keywords2), + "matched_count": len(matchedKeywords), + } +} + +// Helper function to convert string slice to JSON +func stringSliceToJSON(slice []string) string { + if len(slice) == 0 { + return "[]" + } + data, _ := json.Marshal(slice) + return string(data) +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/error.go b/lost-and-found/internal/utils/error.go new file mode 100644 index 0000000..448c296 --- /dev/null +++ b/lost-and-found/internal/utils/error.go @@ -0,0 +1,66 @@ +package utils + +import "fmt" + +// AppError represents a custom application error +type AppError struct { + Code string + Message string + Err error +} + +func (e *AppError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// NewAppError creates a new application error +func NewAppError(code, message string, err error) *AppError { + return &AppError{ + Code: code, + Message: message, + Err: err, + } +} + +// Common error codes +const ( + ErrCodeValidation = "VALIDATION_ERROR" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeUnauthorized = "UNAUTHORIZED" + ErrCodeForbidden = "FORBIDDEN" + ErrCodeInternal = "INTERNAL_ERROR" + ErrCodeDuplicate = "DUPLICATE_ERROR" + ErrCodeBadRequest = "BAD_REQUEST" +) + +// Error constructors +func ValidationError(message string) *AppError { + return NewAppError(ErrCodeValidation, message, nil) +} + +func NotFoundError(message string) *AppError { + return NewAppError(ErrCodeNotFound, message, nil) +} + +func UnauthorizedError(message string) *AppError { + return NewAppError(ErrCodeUnauthorized, message, nil) +} + +func ForbiddenError(message string) *AppError { + return NewAppError(ErrCodeForbidden, message, nil) +} + +func InternalError(message string, err error) *AppError { + return NewAppError(ErrCodeInternal, message, err) +} + +func DuplicateError(message string) *AppError { + return NewAppError(ErrCodeDuplicate, message, nil) +} + +func BadRequestError(message string) *AppError { + return NewAppError(ErrCodeBadRequest, message, nil) +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/excel_export.go b/lost-and-found/internal/utils/excel_export.go new file mode 100644 index 0000000..bb3d3c4 --- /dev/null +++ b/lost-and-found/internal/utils/excel_export.go @@ -0,0 +1,99 @@ +package utils + +import ( + "bytes" + "fmt" + + "github.com/xuri/excelize/v2" +) + +// ExcelExporter handles Excel file generation +type ExcelExporter struct { + file *excelize.File + sheetName string + rowIndex int +} + +// NewExcelExporter creates a new Excel exporter +func NewExcelExporter() *ExcelExporter { + f := excelize.NewFile() + return &ExcelExporter{ + file: f, + sheetName: "Sheet1", + rowIndex: 1, + } +} + +// SetSheetName sets the active sheet name +func (e *ExcelExporter) SetSheetName(name string) { + e.file.SetSheetName("Sheet1", name) + e.sheetName = name +} + +// AddRow adds a row of data +func (e *ExcelExporter) AddRow(data []string) error { + for colIndex, value := range data { + cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex) + if err := e.file.SetCellValue(e.sheetName, cell, value); err != nil { + return err + } + } + e.rowIndex++ + return nil +} + +// AddHeader adds a header row with bold style +func (e *ExcelExporter) AddHeader(headers []string) error { + style, err := e.file.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + }, + Fill: excelize.Fill{ + Type: "pattern", + Color: []string{"#D3D3D3"}, + Pattern: 1, + }, + }) + if err != nil { + return err + } + + for colIndex, header := range headers { + cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex) + if err := e.file.SetCellValue(e.sheetName, cell, header); err != nil { + return err + } + if err := e.file.SetCellStyle(e.sheetName, cell, cell, style); err != nil { + return err + } + } + e.rowIndex++ + return nil +} + +// AutoSizeColumns auto-sizes columns +func (e *ExcelExporter) AutoSizeColumns(columnCount int) { + for i := 0; i < columnCount; i++ { + col := getColumnName(i) + e.file.SetColWidth(e.sheetName, col, col, 15) + } +} + +// Output returns the Excel file as bytes +func (e *ExcelExporter) Output() (*bytes.Buffer, error) { + buf := new(bytes.Buffer) + if err := e.file.Write(buf); err != nil { + return nil, err + } + return buf, nil +} + +// getColumnName converts column index to Excel column name (A, B, C, ..., AA, AB, ...) +func getColumnName(index int) string { + name := "" + for index >= 0 { + name = string(rune('A'+(index%26))) + name + index = index/26 - 1 + } + return name +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/hash.go b/lost-and-found/internal/utils/hash.go new file mode 100644 index 0000000..0ef2a3d --- /dev/null +++ b/lost-and-found/internal/utils/hash.go @@ -0,0 +1,17 @@ +package utils + +import ( + "golang.org/x/crypto/bcrypt" +) + +// HashPassword hashes a password using bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPasswordHash compares a password with a hash +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/image_handler.go b/lost-and-found/internal/utils/image_handler.go new file mode 100644 index 0000000..a4b7e43 --- /dev/null +++ b/lost-and-found/internal/utils/image_handler.go @@ -0,0 +1,187 @@ +package utils + +import ( + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "github.com/nfnt/resize" +) + +// ImageHandler handles image upload and processing +type ImageHandler struct { + uploadPath string + maxSize int64 + allowedTypes []string + maxWidth uint + maxHeight uint +} + +// NewImageHandler creates a new image handler +func NewImageHandler(uploadPath string) *ImageHandler { + return &ImageHandler{ + uploadPath: uploadPath, + maxSize: 10 * 1024 * 1024, // 10MB + allowedTypes: []string{"image/jpeg", "image/jpg", "image/png"}, + maxWidth: 1920, + maxHeight: 1080, + } +} + +// UploadImage uploads and processes an image +func (h *ImageHandler) UploadImage(file *multipart.FileHeader, subfolder string) (string, error) { + // Check file size + if file.Size > h.maxSize { + return "", errors.New("file size exceeds maximum allowed size") + } + + // Check file type + if !h.isAllowedType(file.Header.Get("Content-Type")) { + return "", errors.New("file type not allowed") + } + + // Open uploaded file + src, err := file.Open() + if err != nil { + return "", err + } + defer src.Close() + + // Generate unique filename + ext := filepath.Ext(file.Filename) + filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext) + + // Create upload directory if not exists + uploadDir := filepath.Join(h.uploadPath, subfolder) + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + return "", err + } + + // Full file path + filePath := filepath.Join(uploadDir, filename) + + // Decode image + img, format, err := image.Decode(src) + if err != nil { + return "", errors.New("invalid image file") + } + + // Resize if necessary + if uint(img.Bounds().Dx()) > h.maxWidth || uint(img.Bounds().Dy()) > h.maxHeight { + img = resize.Thumbnail(h.maxWidth, h.maxHeight, img, resize.Lanczos3) + } + + // Create destination file + dst, err := os.Create(filePath) + if err != nil { + return "", err + } + defer dst.Close() + + // Encode and save image + switch format { + case "jpeg", "jpg": + if err := jpeg.Encode(dst, img, &jpeg.Options{Quality: 90}); err != nil { + return "", err + } + case "png": + if err := png.Encode(dst, img); err != nil { + return "", err + } + default: + return "", errors.New("unsupported image format") + } + + // Return relative path + return filepath.Join(subfolder, filename), nil +} + +// UploadImageSimple uploads image without processing +func (h *ImageHandler) UploadImageSimple(file *multipart.FileHeader, subfolder string) (string, error) { + // Check file size + if file.Size > h.maxSize { + return "", errors.New("file size exceeds maximum allowed size") + } + + // Check file type + if !h.isAllowedType(file.Header.Get("Content-Type")) { + return "", errors.New("file type not allowed") + } + + // Open uploaded file + src, err := file.Open() + if err != nil { + return "", err + } + defer src.Close() + + // Generate unique filename + ext := filepath.Ext(file.Filename) + filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext) + + // Create upload directory if not exists + uploadDir := filepath.Join(h.uploadPath, subfolder) + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + return "", err + } + + // Full file path + filePath := filepath.Join(uploadDir, filename) + + // Create destination file + dst, err := os.Create(filePath) + if err != nil { + return "", err + } + defer dst.Close() + + // Copy file + if _, err := io.Copy(dst, src); err != nil { + return "", err + } + + // Return relative path + return filepath.Join(subfolder, filename), nil +} + +// DeleteImage deletes an image file +func (h *ImageHandler) DeleteImage(relativePath string) error { + if relativePath == "" { + return nil + } + + filePath := filepath.Join(h.uploadPath, relativePath) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil // File doesn't exist, no error + } + + return os.Remove(filePath) +} + +// isAllowedType checks if file type is allowed +func (h *ImageHandler) isAllowedType(contentType string) bool { + for _, allowed := range h.allowedTypes { + if strings.EqualFold(contentType, allowed) { + return true + } + } + return false +} + +// generateRandomString generates a random string +func generateRandomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[time.Now().UnixNano()%int64(len(charset))] + } + return string(b) +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/matching.go b/lost-and-found/internal/utils/matching.go new file mode 100644 index 0000000..9203179 --- /dev/null +++ b/lost-and-found/internal/utils/matching.go @@ -0,0 +1,102 @@ +package utils + +import ( + "strings" +) + +// CalculateMatchScore calculates match score between two items +func CalculateMatchScore(item1, item2 map[string]interface{}) float64 { + totalScore := 0.0 + maxScore := 0.0 + + // Name matching (30%) + maxScore += 30 + if name1, ok1 := item1["name"].(string); ok1 { + if name2, ok2 := item2["name"].(string); ok2 { + similarity := CalculateStringSimilarity(name1, name2) + totalScore += similarity * 30 + } + } + + // Category matching (20%) + maxScore += 20 + if cat1, ok1 := item1["category"].(string); ok1 { + if cat2, ok2 := item2["category"].(string); ok2 { + if strings.EqualFold(cat1, cat2) { + totalScore += 20 + } + } + } + + // Color matching (15%) + if color1, ok1 := item1["color"].(string); ok1 { + if color1 != "" { + maxScore += 15 + if color2, ok2 := item2["color"].(string); ok2 { + if strings.Contains(strings.ToLower(color2), strings.ToLower(color1)) { + totalScore += 15 + } + } + } + } + + // Location matching (20%) + if loc1, ok1 := item1["location"].(string); ok1 { + if loc1 != "" { + maxScore += 20 + if loc2, ok2 := item2["location"].(string); ok2 { + similarity := CalculateStringSimilarity(loc1, loc2) + totalScore += similarity * 20 + } + } + } + + // Description matching (15%) + maxScore += 15 + if desc1, ok1 := item1["description"].(string); ok1 { + if desc2, ok2 := item2["description"].(string); ok2 { + similarity := CalculateStringSimilarity(desc1, desc2) + totalScore += similarity * 15 + } + } + + if maxScore == 0 { + return 0 + } + + return (totalScore / maxScore) * 100 +} + +// MatchItems matches items based on criteria +func MatchItems(lostItem, foundItems []map[string]interface{}, threshold float64) []map[string]interface{} { + var matches []map[string]interface{} + + if len(lostItem) == 0 || len(foundItems) == 0 { + return matches + } + + lost := lostItem[0] + + for _, found := range foundItems { + score := CalculateMatchScore(lost, found) + if score >= threshold { + match := make(map[string]interface{}) + match["item"] = found + match["score"] = score + match["level"] = getMatchLevel(score) + matches = append(matches, match) + } + } + + return matches +} + +// getMatchLevel returns match level based on score +func getMatchLevel(score float64) string { + if score >= 70 { + return "high" + } else if score >= 50 { + return "medium" + } + return "low" +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/pdf_export.go b/lost-and-found/internal/utils/pdf_export.go new file mode 100644 index 0000000..3d98ed4 --- /dev/null +++ b/lost-and-found/internal/utils/pdf_export.go @@ -0,0 +1,105 @@ +package utils + +import ( + "bytes" + "fmt" + + "github.com/jung-kurt/gofpdf" +) + +// PDFExporter handles PDF generation +type PDFExporter struct { + pdf *gofpdf.Fpdf +} + +// NewPDFExporter creates a new PDF exporter +func NewPDFExporter() *PDFExporter { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + pdf.SetFont("Arial", "", 12) + + return &PDFExporter{ + pdf: pdf, + } +} + +// AddTitle adds a title to the PDF +func (e *PDFExporter) AddTitle(title string) { + e.pdf.SetFont("Arial", "B", 16) + e.pdf.Cell(0, 10, title) + e.pdf.Ln(12) + e.pdf.SetFont("Arial", "", 12) +} + +// AddSubtitle adds a subtitle to the PDF +func (e *PDFExporter) AddSubtitle(subtitle string) { + e.pdf.SetFont("Arial", "I", 11) + e.pdf.Cell(0, 8, subtitle) + e.pdf.Ln(10) + e.pdf.SetFont("Arial", "", 12) +} + +// AddText adds regular text +func (e *PDFExporter) AddText(text string) { + e.pdf.SetFont("Arial", "", 10) + e.pdf.MultiCell(0, 6, text, "", "", false) + e.pdf.Ln(4) +} + +// AddNewLine adds a new line +func (e *PDFExporter) AddNewLine() { + e.pdf.Ln(6) +} + +// AddTable adds a table to the PDF +func (e *PDFExporter) AddTable(headers []string, data [][]string) { + // Calculate column widths + pageWidth, _ := e.pdf.GetPageSize() + margins := 20.0 // Left + Right margins + tableWidth := pageWidth - margins + colWidth := tableWidth / float64(len(headers)) + + // Add headers + e.pdf.SetFont("Arial", "B", 10) + e.pdf.SetFillColor(200, 200, 200) + for _, header := range headers { + e.pdf.CellFormat(colWidth, 8, header, "1", 0, "C", true, 0, "") + } + e.pdf.Ln(-1) + + // Add data rows + e.pdf.SetFont("Arial", "", 9) + e.pdf.SetFillColor(255, 255, 255) + + fill := false + for _, row := range data { + for _, cell := range row { + if fill { + e.pdf.SetFillColor(245, 245, 245) + } else { + e.pdf.SetFillColor(255, 255, 255) + } + e.pdf.CellFormat(colWidth, 7, cell, "1", 0, "L", true, 0, "") + } + e.pdf.Ln(-1) + fill = !fill + } +} + +// AddPageNumber adds page numbers +func (e *PDFExporter) AddPageNumber() { + e.pdf.AliasNbPages("") + e.pdf.SetY(-15) + e.pdf.SetFont("Arial", "I", 8) + e.pdf.CellFormat(0, 10, fmt.Sprintf("Halaman %d/{nb}", e.pdf.PageNo()), "", 0, "C", false, 0, "") +} + +// Output returns the PDF as bytes +func (e *PDFExporter) Output() *bytes.Buffer { + var buf bytes.Buffer + err := e.pdf.Output(&buf) + if err != nil { + return nil + } + return &buf +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/response.go b/lost-and-found/internal/utils/response.go new file mode 100644 index 0000000..febc059 --- /dev/null +++ b/lost-and-found/internal/utils/response.go @@ -0,0 +1,67 @@ +package utils + +import ( + "github.com/gin-gonic/gin" +) + +// Response represents a standard API response +type Response struct { + Success bool `json:"success"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// PaginatedResponse represents a paginated API response +type PaginatedResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data interface{} `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// Pagination represents pagination metadata +type Pagination struct { + CurrentPage int `json:"current_page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` + TotalRecords int64 `json:"total_records"` +} + +// SuccessResponse sends a success response +func SuccessResponse(ctx *gin.Context, statusCode int, message string, data interface{}) { + ctx.JSON(statusCode, Response{ + Success: true, + Message: message, + Data: data, + }) +} + +// ErrorResponse sends an error response +func ErrorResponse(ctx *gin.Context, statusCode int, message string, error string) { + ctx.JSON(statusCode, Response{ + Success: false, + Message: message, + Error: error, + }) +} + +// SendPaginatedResponse sends a paginated response (nama fungsi diubah untuk menghindari konflik) +func SendPaginatedResponse(ctx *gin.Context, statusCode int, message string, data interface{}, total int64, page, limit int) { + totalPages := int(total) / limit + if int(total)%limit != 0 { + totalPages++ + } + + ctx.JSON(statusCode, PaginatedResponse{ + Success: true, + Message: message, + Data: data, + Pagination: Pagination{ + CurrentPage: page, + PerPage: limit, + TotalPages: totalPages, + TotalRecords: total, + }, + }) +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/similarity.go b/lost-and-found/internal/utils/similarity.go new file mode 100644 index 0000000..727462e --- /dev/null +++ b/lost-and-found/internal/utils/similarity.go @@ -0,0 +1,159 @@ +package utils + +import ( + "math" + "strings" + "unicode" +) + +// CalculateStringSimilarity calculates similarity between two strings using Levenshtein distance +func CalculateStringSimilarity(s1, s2 string) float64 { + // Normalize strings + s1 = normalizeString(s1) + s2 = normalizeString(s2) + + if s1 == s2 { + return 1.0 + } + + if len(s1) == 0 || len(s2) == 0 { + return 0.0 + } + + // Calculate Levenshtein distance + distance := levenshteinDistance(s1, s2) + maxLen := math.Max(float64(len(s1)), float64(len(s2))) + + similarity := 1.0 - (float64(distance) / maxLen) + return math.Max(0, similarity) +} + +// levenshteinDistance calculates the Levenshtein distance between two strings +func levenshteinDistance(s1, s2 string) int { + len1 := len(s1) + len2 := len(s2) + + // Create a 2D slice for dynamic programming + dp := make([][]int, len1+1) + for i := range dp { + dp[i] = make([]int, len2+1) + } + + // Initialize first row and column + for i := 0; i <= len1; i++ { + dp[i][0] = i + } + for j := 0; j <= len2; j++ { + dp[0][j] = j + } + + // Fill the dp table + for i := 1; i <= len1; i++ { + for j := 1; j <= len2; j++ { + cost := 0 + if s1[i-1] != s2[j-1] { + cost = 1 + } + + dp[i][j] = min3( + dp[i-1][j]+1, // deletion + dp[i][j-1]+1, // insertion + dp[i-1][j-1]+cost, // substitution + ) + } + } + + return dp[len1][len2] +} + +// ExtractKeywords extracts keywords from a string +func ExtractKeywords(text string) []string { + // Normalize and split text + text = normalizeString(text) + words := strings.Fields(text) + + // Filter stopwords and short words + var keywords []string + stopwords := getStopwords() + + for _, word := range words { + if len(word) > 2 && !contains(stopwords, word) { + keywords = append(keywords, word) + } + } + + return keywords +} + +// FindMatchedKeywords finds common keywords between two lists +func FindMatchedKeywords(keywords1, keywords2 []string) []string { + var matched []string + + for _, k1 := range keywords1 { + for _, k2 := range keywords2 { + if strings.EqualFold(k1, k2) || CalculateStringSimilarity(k1, k2) > 0.8 { + if !contains(matched, k1) { + matched = append(matched, k1) + } + break + } + } + } + + return matched +} + +// normalizeString normalizes a string (lowercase, remove extra spaces) +func normalizeString(s string) string { + // Convert to lowercase + s = strings.ToLower(s) + + // Remove punctuation and extra spaces + var result strings.Builder + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) { + result.WriteRune(r) + } else { + result.WriteRune(' ') + } + } + + // Remove multiple spaces + s = strings.Join(strings.Fields(result.String()), " ") + + return strings.TrimSpace(s) +} + +// getStopwords returns common Indonesian stopwords +func getStopwords() []string { + return []string{ + "dan", "atau", "dengan", "untuk", "dari", "ke", "di", "yang", "ini", "itu", + "ada", "adalah", "akan", "telah", "sudah", "pada", "oleh", "sebagai", "dalam", + "juga", "saya", "kamu", "dia", "kita", "mereka", "kami", "the", "a", "an", + "of", "to", "in", "for", "on", "at", "by", "with", "from", + } +} + +// contains checks if a slice contains a string +func contains(slice []string, str string) bool { + for _, s := range slice { + if strings.EqualFold(s, str) { + return true + } + } + return false +} + +// min3 returns the minimum of three integers +func min3(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} \ No newline at end of file diff --git a/lost-and-found/internal/utils/validator.go b/lost-and-found/internal/utils/validator.go new file mode 100644 index 0000000..e4a64d7 --- /dev/null +++ b/lost-and-found/internal/utils/validator.go @@ -0,0 +1,84 @@ +package utils + +import ( + "regexp" + "strings" + + "github.com/go-playground/validator/v10" +) + +var validate *validator.Validate + +// InitValidator initializes the validator +func InitValidator() { + validate = validator.New() + + // Register custom validators + validate.RegisterValidation("phone", validatePhone) + validate.RegisterValidation("nrp", validateNRP) +} + +// ValidateStruct validates a struct +func ValidateStruct(s interface{}) error { + if validate == nil { + InitValidator() + } + return validate.Struct(s) +} + +// validatePhone validates Indonesian phone numbers +func validatePhone(fl validator.FieldLevel) bool { + phone := fl.Field().String() + if phone == "" { + return true // Allow empty (use required tag separately) + } + + // Remove spaces and dashes + phone = strings.ReplaceAll(phone, " ", "") + phone = strings.ReplaceAll(phone, "-", "") + + // Check if starts with 0 or +62 or 62 + pattern := `^(0|\+?62)[0-9]{8,12}$` + matched, _ := regexp.MatchString(pattern, phone) + return matched +} + +// validateNRP validates NRP (Nomor Registrasi Pokok) +func validateNRP(fl validator.FieldLevel) bool { + nrp := fl.Field().String() + if nrp == "" { + return true // Allow empty (use required tag separately) + } + + // NRP format: 10 digits + pattern := `^[0-9]{10}$` + matched, _ := regexp.MatchString(pattern, nrp) + return matched +} + +// IsValidEmail checks if email is valid +func IsValidEmail(email string) bool { + pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + matched, _ := regexp.MatchString(pattern, email) + return matched +} + +// IsValidURL checks if URL is valid +func IsValidURL(url string) bool { + pattern := `^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$` + matched, _ := regexp.MatchString(pattern, url) + return matched +} + +// SanitizeString removes potentially dangerous characters +func SanitizeString(s string) string { + // Remove control characters + s = strings.Map(func(r rune) rune { + if r < 32 && r != '\n' && r != '\r' && r != '\t' { + return -1 + } + return r + }, s) + + return strings.TrimSpace(s) +} \ No newline at end of file diff --git a/lost-and-found/internal/workers/audit_worker.go b/lost-and-found/internal/workers/audit_worker.go new file mode 100644 index 0000000..01762ea --- /dev/null +++ b/lost-and-found/internal/workers/audit_worker.go @@ -0,0 +1,72 @@ +package workers + +import ( + "log" + "lost-and-found/internal/repositories" + "time" + + "gorm.io/gorm" +) + +// AuditWorker handles audit log background tasks +type AuditWorker struct { + db *gorm.DB + auditLogRepo *repositories.AuditLogRepository + stopChan chan bool +} + +// NewAuditWorker creates a new audit worker +func NewAuditWorker(db *gorm.DB) *AuditWorker { + return &AuditWorker{ + db: db, + auditLogRepo: repositories.NewAuditLogRepository(db), + stopChan: make(chan bool), + } +} + +// Start starts the audit worker +func (w *AuditWorker) Start() { + log.Println("🔍 Audit Worker started") + + ticker := time.NewTicker(24 * time.Hour) // Run daily + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.cleanupOldLogs() + case <-w.stopChan: + log.Println("🔍 Audit Worker stopped") + return + } + } +} + +// Stop stops the audit worker +func (w *AuditWorker) Stop() { + w.stopChan <- true +} + +// cleanupOldLogs removes audit logs older than 1 year +func (w *AuditWorker) cleanupOldLogs() { + log.Println("🧹 Cleaning up old audit logs...") + + cutoffDate := time.Now().AddDate(-1, 0, 0) // 1 year ago + + result := w.db.Unscoped().Where("created_at < ?", cutoffDate).Delete(&struct { + tableName struct{} `gorm:"audit_logs"` + }{}) + + if result.Error != nil { + log.Printf("❌ Failed to cleanup audit logs: %v", result.Error) + return + } + + log.Printf("✅ Cleaned up %d old audit log entries", result.RowsAffected) +} + +// RunNow runs cleanup immediately (for testing) +func (w *AuditWorker) RunNow() { + log.Println("▶️ Running audit cleanup manually...") + w.cleanupOldLogs() +} \ No newline at end of file diff --git a/lost-and-found/internal/workers/expire_worker.go b/lost-and-found/internal/workers/expire_worker.go new file mode 100644 index 0000000..79654ed --- /dev/null +++ b/lost-and-found/internal/workers/expire_worker.go @@ -0,0 +1,103 @@ +package workers + +import ( + "log" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "time" + + "gorm.io/gorm" +) + +// ExpireWorker handles item expiration background tasks +type ExpireWorker struct { + db *gorm.DB + itemRepo *repositories.ItemRepository + archiveRepo *repositories.ArchiveRepository + stopChan chan bool +} + +// NewExpireWorker creates a new expire worker +func NewExpireWorker(db *gorm.DB) *ExpireWorker { + return &ExpireWorker{ + db: db, + itemRepo: repositories.NewItemRepository(db), + archiveRepo: repositories.NewArchiveRepository(db), + stopChan: make(chan bool), + } +} + +// Start starts the expire worker +func (w *ExpireWorker) Start() { + log.Println("⏰ Expire Worker started") + + // Run immediately on start + w.expireItems() + + // Then run every hour + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.expireItems() + case <-w.stopChan: + log.Println("⏰ Expire Worker stopped") + return + } + } +} + +// Stop stops the expire worker +func (w *ExpireWorker) Stop() { + w.stopChan <- true +} + +// expireItems finds and expires items that have passed their expiration date +func (w *ExpireWorker) expireItems() { + log.Println("🔍 Checking for expired items...") + + // Find expired items + expiredItems, err := w.itemRepo.FindExpired() + if err != nil { + log.Printf("❌ Error finding expired items: %v", err) + return + } + + if len(expiredItems) == 0 { + log.Println("✅ No expired items found") + return + } + + log.Printf("📦 Found %d expired items", len(expiredItems)) + + // Process each expired item + expiredCount := 0 + for _, item := range expiredItems { + if err := w.archiveExpiredItem(&item); err != nil { + log.Printf("❌ Failed to archive item ID %d: %v", item.ID, err) + continue + } + expiredCount++ + } + + log.Printf("✅ Successfully archived %d expired items", expiredCount) +} + +// archiveExpiredItem archives an expired item +func (w *ExpireWorker) archiveExpiredItem(item *models.Item) error { + // Archive the item + if err := w.itemRepo.ArchiveItem(item, models.ArchiveReasonExpired, nil); err != nil { + return err + } + + log.Printf("📦 Archived expired item: %s (ID: %d)", item.Name, item.ID) + return nil +} + +// RunNow runs expiration check immediately (for testing) +func (w *ExpireWorker) RunNow() { + log.Println("▶️ Running expiration check manually...") + w.expireItems() +} \ No newline at end of file diff --git a/lost-and-found/internal/workers/matching_worker.go b/lost-and-found/internal/workers/matching_worker.go new file mode 100644 index 0000000..0052496 --- /dev/null +++ b/lost-and-found/internal/workers/matching_worker.go @@ -0,0 +1,89 @@ +package workers + +import ( + "log" + "lost-and-found/internal/repositories" + "lost-and-found/internal/services" + "time" + + "gorm.io/gorm" +) + +// MatchingWorker handles automatic matching of lost and found items +type MatchingWorker struct { + db *gorm.DB + matchService *services.MatchService + itemRepo *repositories.ItemRepository + lostItemRepo *repositories.LostItemRepository + stopChan chan bool +} + +// NewMatchingWorker creates a new matching worker +func NewMatchingWorker(db *gorm.DB) *MatchingWorker { + return &MatchingWorker{ + db: db, + matchService: services.NewMatchService(db), + itemRepo: repositories.NewItemRepository(db), + lostItemRepo: repositories.NewLostItemRepository(db), + stopChan: make(chan bool), + } +} + +// Start starts the matching worker +func (w *MatchingWorker) Start() { + log.Println("🔗 Matching Worker started") + + // Run every 30 minutes + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.performMatching() + case <-w.stopChan: + log.Println("🔗 Matching Worker stopped") + return + } + } +} + +// Stop stops the matching worker +func (w *MatchingWorker) Stop() { + w.stopChan <- true +} + +// performMatching performs automatic matching between lost and found items +func (w *MatchingWorker) performMatching() { + log.Println("🔍 Performing automatic matching...") + + // Get all unclaimed items + items, _, err := w.itemRepo.FindAll(1, 1000, "unclaimed", "", "") + if err != nil { + log.Printf("❌ Error fetching items: %v", err) + return + } + + if len(items) == 0 { + log.Println("✅ No unclaimed items to match") + return + } + + matchCount := 0 + for _, item := range items { + // Auto-match with lost items + if err := w.matchService.AutoMatchNewItem(item.ID); err != nil { + log.Printf("❌ Failed to match item ID %d: %v", item.ID, err) + continue + } + matchCount++ + } + + log.Printf("✅ Completed matching for %d items", matchCount) +} + +// RunNow runs matching immediately (for testing) +func (w *MatchingWorker) RunNow() { + log.Println("▶️ Running matching manually...") + w.performMatching() +} \ No newline at end of file diff --git a/lost-and-found/internal/workers/notification_worker.go b/lost-and-found/internal/workers/notification_worker.go new file mode 100644 index 0000000..b250972 --- /dev/null +++ b/lost-and-found/internal/workers/notification_worker.go @@ -0,0 +1,116 @@ +package workers + +import ( + "log" + "lost-and-found/internal/models" + "lost-and-found/internal/repositories" + "time" + + "gorm.io/gorm" +) + +// NotificationWorker handles sending notifications asynchronously +type NotificationWorker struct { + db *gorm.DB + notificationRepo *repositories.NotificationRepository + matchRepo *repositories.MatchResultRepository + stopChan chan bool +} + +// NewNotificationWorker creates a new notification worker +func NewNotificationWorker(db *gorm.DB) *NotificationWorker { + return &NotificationWorker{ + db: db, + notificationRepo: repositories.NewNotificationRepository(db), + matchRepo: repositories.NewMatchResultRepository(db), + stopChan: make(chan bool), + } +} + +// Start starts the notification worker +func (w *NotificationWorker) Start() { + log.Println("📬 Notification Worker started") + + // Run every 5 minutes + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + w.sendPendingNotifications() + case <-w.stopChan: + log.Println("📬 Notification Worker stopped") + return + } + } +} + +// Stop stops the notification worker +func (w *NotificationWorker) Stop() { + w.stopChan <- true +} + +// sendPendingNotifications sends notifications for unnotified matches +func (w *NotificationWorker) sendPendingNotifications() { + log.Println("🔍 Checking for pending notifications...") + + // Get unnotified matches + matches, err := w.matchRepo.FindUnnotifiedMatches() + if err != nil { + log.Printf("❌ Error fetching unnotified matches: %v", err) + return + } + + if len(matches) == 0 { + log.Println("✅ No pending notifications") + return + } + + log.Printf("📧 Found %d pending notifications", len(matches)) + + sentCount := 0 + for _, match := range matches { + // Send notification to lost item owner + if err := w.sendMatchNotification(&match); err != nil { + log.Printf("❌ Failed to send notification for match ID %d: %v", match.ID, err) + continue + } + + // Mark as notified + if err := w.matchRepo.MarkAsNotified(match.ID); err != nil { + log.Printf("❌ Failed to mark match ID %d as notified: %v", match.ID, err) + continue + } + + sentCount++ + } + + log.Printf("✅ Sent %d notifications", sentCount) +} + +// sendMatchNotification sends a match notification +func (w *NotificationWorker) sendMatchNotification(match *models.MatchResult) error { + // Create notification + err := models.CreateMatchNotification( + w.db, + match.LostItem.UserID, + match.Item.Name, + match.ID, + ) + + if err != nil { + return err + } + + log.Printf("📧 Sent match notification to user ID %d for item: %s", + match.LostItem.UserID, match.Item.Name) + + return nil +} + +// RunNow runs notification sending immediately (for testing) +func (w *NotificationWorker) RunNow() { + log.Println("▶️ Running notification sending manually...") + w.sendPendingNotifications() +} \ No newline at end of file diff --git a/lost-and-found/setup.go b/lost-and-found/setup.go new file mode 100644 index 0000000..c6bda00 --- /dev/null +++ b/lost-and-found/setup.go @@ -0,0 +1,254 @@ +package main + +import ( + "fmt" + "os" +) + +var structure = []string{ + // Main Entry Point + "cmd/server/main.go", + + // Configuration + "internal/config/config.go", + "internal/config/database.go", + "internal/config/jwt.go", + + // Models + "internal/models/user.go", + "internal/models/role.go", + "internal/models/item.go", + "internal/models/lost_item.go", + "internal/models/claim.go", + "internal/models/category.go", + "internal/models/archive.go", + "internal/models/audit_log.go", + "internal/models/claim_verification.go", // BARU: tracking verifikasi klaim + "internal/models/match_result.go", // BARU: hasil matching barang + "internal/models/revision_log.go", // BARU: audit trail edit barang + "internal/models/notification.go", // BARU: notifikasi (opsional) + + // Repositories + "internal/repositories/user_repo.go", + "internal/repositories/role_repo.go", + "internal/repositories/item_repo.go", + "internal/repositories/lost_item_repo.go", + "internal/repositories/claim_repo.go", + "internal/repositories/category_repo.go", + "internal/repositories/archive_repo.go", + "internal/repositories/audit_log_repo.go", + "internal/repositories/claim_verification_repo.go", // BARU + "internal/repositories/match_result_repo.go", // BARU + "internal/repositories/revision_log_repo.go", // BARU + "internal/repositories/notification_repo.go", // BARU (opsional) + + // Services + "internal/services/auth_service.go", + "internal/services/user_service.go", + "internal/services/item_service.go", + "internal/services/lost_item_service.go", + "internal/services/claim_service.go", + "internal/services/match_service.go", + "internal/services/category_service.go", + "internal/services/archive_service.go", + "internal/services/audit_service.go", + "internal/services/export_service.go", + "internal/services/verification_service.go", // BARU: logic verifikasi terpisah + "internal/services/notification_service.go", // BARU: handle notifikasi + + // Controllers + "internal/controllers/auth_controller.go", + "internal/controllers/user_controller.go", + "internal/controllers/item_controller.go", + "internal/controllers/lost_item_controller.go", + "internal/controllers/claim_controller.go", + "internal/controllers/match_controller.go", + "internal/controllers/category_controller.go", + "internal/controllers/archive_controller.go", + "internal/controllers/admin_controller.go", + "internal/controllers/report_controller.go", // BARU: export laporan terpisah + + // Middleware + "internal/middleware/jwt_middleware.go", + "internal/middleware/role_middleware.go", + "internal/middleware/logger.go", + "internal/middleware/cors.go", + "internal/middleware/rate_limiter.go", // BARU: rate limiting (recommended) + + // Workers (Concurrency) + "internal/workers/expire_worker.go", + "internal/workers/audit_worker.go", + "internal/workers/matching_worker.go", // BARU: auto-matching background + "internal/workers/notification_worker.go", // BARU: kirim notifikasi + + // Utils + "internal/utils/hash.go", + "internal/utils/response.go", + "internal/utils/error.go", + "internal/utils/validator.go", + "internal/utils/matching.go", + "internal/utils/similarity.go", + "internal/utils/pdf_export.go", + "internal/utils/excel_export.go", + "internal/utils/image_handler.go", // BARU: handle upload/resize foto + + // Routes + "internal/routes/routes.go", + + // Upload directories + "uploads/items/.gitkeep", + "uploads/lost_items/.gitkeep", + "uploads/claims/.gitkeep", + + // Web Frontend + "web/index.html", + "web/login.html", + "web/admin.html", + "web/manager.html", + "web/user.html", + "web/css/style.css", + "web/js/main.js", + "web/js/admin.js", + "web/js/manager.js", + "web/js/user.js", + + // Database (Manual migration via HeidiSQL) + "database/schema.sql", + "database/seed.sql", + + // Root Files + ".env.example", + "README.md", + "Makefile", // BARU: untuk command shortcuts + "go.mod", + "go.sum", +} + +func main() { + fmt.Println("🚀 Memulai pembuatan struktur project Lost & Found...") + fmt.Println("📦 Total file yang akan dibuat:", len(structure)) + fmt.Println() + + successCount := 0 + failCount := 0 + + for _, path := range structure { + dir := getDir(path) + if dir != "" { + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + fmt.Printf("❌ Gagal buat folder: %s - %v\n", dir, err) + failCount++ + continue + } + } + + file, err := os.Create(path) + if err != nil { + fmt.Printf("❌ Gagal buat file: %s - %v\n", path, err) + failCount++ + continue + } + file.Close() + + fmt.Printf("✅ Dibuat: %s\n", path) + successCount++ + } + + fmt.Println() + fmt.Println("════════════════════════════════════════════════════") + fmt.Printf("🎉 Proses selesai!\n") + fmt.Printf("✅ Berhasil: %d file\n", successCount) + if failCount > 0 { + fmt.Printf("❌ Gagal: %d file\n", failCount) + } + fmt.Println("════════════════════════════════════════════════════") + fmt.Println() + + printStructureInfo() + printNextSteps() + printNewFeatures() +} + +func getDir(path string) string { + i := len(path) - 1 + for i >= 0 && path[i] != '/' && path[i] != '\\' { + i-- + } + if i > 0 { + return path[:i] + } + return "" +} + +func printStructureInfo() { + fmt.Println("📁 Struktur Project:") + fmt.Println(" - cmd/server : Entry point aplikasi") + fmt.Println(" - internal/config : Konfigurasi (DB, JWT)") + fmt.Println(" - internal/models : Entity models (12 models)") + fmt.Println(" - internal/repos : Database operations") + fmt.Println(" - internal/services : Business logic") + fmt.Println(" - internal/controllers: HTTP handlers") + fmt.Println(" - internal/middleware : Auth, RBAC & logging") + fmt.Println(" - internal/workers : Background jobs (4 workers)") + fmt.Println(" - internal/utils : Helper functions") + fmt.Println(" - internal/routes : API routing") + fmt.Println(" - uploads : Storage untuk foto upload") + fmt.Println(" - web : Frontend files (HTML, CSS, JS)") + fmt.Println(" - database : Schema & seed (manual via HeidiSQL)") + fmt.Println() +} + +func printNextSteps() { + fmt.Println("🔧 Next Steps:") + fmt.Println(" 1. Copy .env.example ke .env") + fmt.Println(" 2. Edit .env dengan konfigurasi database Anda") + fmt.Println(" 3. Run: go mod init lost-and-found") + fmt.Println(" 4. Run: go mod tidy") + fmt.Println(" 5. Install dependencies:") + fmt.Println(" - go get github.com/gin-gonic/gin") + fmt.Println(" - go get github.com/golang-jwt/jwt/v5") + fmt.Println(" - go get gorm.io/gorm") + fmt.Println(" - go get gorm.io/driver/postgres") + fmt.Println(" 6. Buka HeidiSQL dan jalankan database/schema.sql") + fmt.Println(" 7. (Opsional) Jalankan database/seed.sql untuk data dummy") + fmt.Println(" 8. Mulai coding dari cmd/server/main.go") + fmt.Println() +} + +func printNewFeatures() { + fmt.Println("✨ File Baru yang Ditambahkan:") + fmt.Println() + fmt.Println("📌 MODELS (4 baru):") + fmt.Println(" • claim_verification.go → Tracking verifikasi & % matching") + fmt.Println(" • match_result.go → Hasil auto-matching barang") + fmt.Println(" • revision_log.go → Audit trail edit barang") + fmt.Println(" • notification.go → Notifikasi ke user") + fmt.Println() + fmt.Println("📌 SERVICES (2 baru):") + fmt.Println(" • verification_service.go → Logic verifikasi klaim terpisah") + fmt.Println(" • notification_service.go → Handle notifikasi") + fmt.Println() + fmt.Println("📌 WORKERS (2 baru):") + fmt.Println(" • matching_worker.go → Auto-matching background") + fmt.Println(" • notification_worker.go → Kirim notifikasi async") + fmt.Println() + fmt.Println("📌 UTILS (1 baru):") + fmt.Println(" • image_handler.go → Handle upload & resize foto") + fmt.Println() + fmt.Println("📌 MIDDLEWARE (1 baru):") + fmt.Println(" • rate_limiter.go → Rate limiting untuk security") + fmt.Println() + fmt.Println("📌 UPLOADS:") + fmt.Println(" • uploads/items/ → Foto barang ditemukan") + fmt.Println(" • uploads/lost_items/ → Foto barang hilang") + fmt.Println(" • uploads/claims/ → Foto bukti klaim") + fmt.Println() + fmt.Println("════════════════════════════════════════════════════") + fmt.Println("💡 Tips:") + fmt.Println(" - Foto disimpan di folder uploads/") + fmt.Println(" - Database hanya simpan path foto (bukan file)") + fmt.Println(" - Gunakan Makefile untuk shortcuts command") + fmt.Println(" - Database dikelola manual via HeidiSQL") + fmt.Println("════════════════════════════════════════════════════") +} \ No newline at end of file diff --git a/lost-and-found/uploads/claims/.gitkeep b/lost-and-found/uploads/claims/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lost-and-found/uploads/items/.gitkeep b/lost-and-found/uploads/items/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lost-and-found/uploads/lost_items/.gitkeep b/lost-and-found/uploads/lost_items/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lost-and-found/web/admin.html b/lost-and-found/web/admin.html new file mode 100644 index 0000000..e99d9ba --- /dev/null +++ b/lost-and-found/web/admin.html @@ -0,0 +1,316 @@ + + + + + + Dashboard Admin - Lost & Found + + + + + +
+ + +
+
+

Total User

+
0
+
+
+

Total Barang

+
0
+
+
+

Total Klaim

+
0
+
+
+

Di Arsip

+
0
+
+
+ +
+ + + + +
+ + +
+
+
+

Daftar User

+ +
+ + + +
+ + + + + + + + + + + + +
NamaEmailNRPRoleStatusAksi
+
+
+
+ + +
+
+
+

Kelola Kategori

+ +
+ +
+
+
+ + +
+
+
+

Export Laporan

+
+ +
+
+

Filter Laporan

+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+

Preview Laporan

+
+
+ Total Barang: + - +
+
+ Diklaim: + - +
+
+ Expired: + - +
+
+
+
+
+
+ + +
+
+
+

Audit Log

+
+ + + +
+ + + + + + + + + + + +
WaktuUserAksiDetailIP Address
+
+
+
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lost-and-found/web/css/style.css b/lost-and-found/web/css/style.css new file mode 100644 index 0000000..489d955 --- /dev/null +++ b/lost-and-found/web/css/style.css @@ -0,0 +1,675 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #2563eb; + --primary-dark: #1e40af; + --danger: #ef4444; + --success: #10b981; + --warning: #f59e0b; + --light: #f8fafc; + --dark: #1e293b; + --secondary: #64748b; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: #f1f5f9; +} + +/* Navbar */ +.navbar { + background: white; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + padding: 15px 30px; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.navbar-brand { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary); +} + +.navbar-menu { + display: flex; + gap: 20px; + align-items: center; +} + +.nav-link { + text-decoration: none; + color: var(--dark); + font-weight: 500; + padding: 8px 15px; + border-radius: 8px; + transition: all 0.3s; +} + +.nav-link:hover { + background: var(--light); + color: var(--primary); +} + +.user-info { + display: flex; + align-items: center; + gap: 10px; +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; +} + +.user-role { + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 600; +} + +.btn-logout { + background: var(--danger); + color: white; + border: none; + padding: 8px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s; +} + +.btn-logout:hover { + background: #dc2626; +} + +/* Container */ +.container { + max-width: 1400px; + margin: 30px auto; + padding: 0 20px; +} + +.page-header { + margin-bottom: 30px; +} + +.page-header h1 { + color: var(--dark); + font-size: 2rem; + margin-bottom: 10px; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.stats-grid-4 { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.stat-card { + background: white; + padding: 25px; + border-radius: 15px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); +} + +.stat-card h3 { + color: var(--secondary); + font-size: 0.9rem; + margin-bottom: 10px; +} + +.stat-number { + font-size: 2.5rem; + font-weight: 700; + color: var(--primary); +} + +.stat-success { + color: var(--success); +} + +.stat-warning { + color: var(--warning); +} + +.stat-danger { + color: var(--danger); +} + +/* Tabs */ +.tabs { + display: flex; + gap: 10px; + margin-bottom: 30px; + flex-wrap: wrap; +} + +.tab-btn { + padding: 12px 25px; + background: white; + border: 2px solid #e2e8f0; + border-radius: 10px; + cursor: pointer; + font-weight: 600; + color: var(--secondary); + transition: all 0.3s; +} + +.tab-btn.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Card */ +.card { + background: white; + border-radius: 15px; + padding: 25px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + margin-bottom: 20px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.card-title { + font-size: 1.3rem; + color: var(--dark); + font-weight: 600; +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-success:hover { + background: #059669; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-warning { + background: var(--warning); + color: white; +} + +.btn-warning:hover { + background: #d97706; +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.85rem; +} + +/* Search Box */ +.search-box { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.search-input { + flex: 1; + min-width: 200px; + padding: 12px 15px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 1rem; +} + +.search-input:focus { + outline: none; + border-color: var(--primary); +} + +.filter-select { + padding: 12px 15px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 1rem; +} + +/* Items Grid */ +.items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; +} + +.item-card { + background: white; + border: 2px solid #e2e8f0; + border-radius: 15px; + overflow: hidden; + transition: all 0.3s; + cursor: pointer; +} + +.item-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(0,0,0,0.1); +} + +.item-image { + width: 100%; + height: 200px; + object-fit: cover; + background: var(--light); +} + +.item-body { + padding: 15px; +} + +.item-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--dark); + margin-bottom: 8px; +} + +.item-meta { + display: flex; + flex-direction: column; + gap: 5px; + color: var(--secondary); + font-size: 0.9rem; + margin-bottom: 10px; +} + +.item-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +/* Badge */ +.badge { + display: inline-block; + padding: 5px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; +} + +.badge-success { + background: #d1fae5; + color: var(--success); +} + +.badge-warning { + background: #fef3c7; + color: var(--warning); +} + +.badge-danger { + background: #fee2e2; + color: var(--danger); +} + +.badge-primary { + background: #dbeafe; + color: var(--primary); +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + animation: fadeIn 0.3s; +} + +.modal.active { + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background: white; + border-radius: 20px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 0.3s; +} + +.modal-large { + max-width: 900px; +} + +.modal-header { + padding: 25px; + border-bottom: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-title { + font-size: 1.5rem; + font-weight: 600; +} + +.close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--secondary); +} + +.modal-body { + padding: 25px; +} + +/* Form */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--dark); +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 12px 15px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 1rem; +} + +.form-group textarea { + resize: vertical; + min-height: 100px; +} + +/* Table */ +.table-container { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +.data-table thead { + background: var(--light); +} + +.data-table th, +.data-table td { + padding: 15px; + text-align: left; + border-bottom: 1px solid #e2e8f0; +} + +.data-table th { + font-weight: 600; + color: var(--dark); +} + +.data-table tr:hover { + background: var(--light); +} + +/* Claims List */ +.claims-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.claim-card { + background: white; + border: 2px solid #e2e8f0; + border-radius: 15px; + padding: 20px; + transition: all 0.3s; +} + +.claim-card:hover { + box-shadow: 0 5px 15px rgba(0,0,0,0.1); +} + +.claim-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.claim-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + margin-bottom: 15px; +} + +.claim-actions { + display: flex; + gap: 10px; +} + +/* Categories Grid */ +.categories-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; +} + +.category-card { + background: white; + border: 2px solid #e2e8f0; + border-radius: 15px; + padding: 20px; + text-align: center; + transition: all 0.3s; +} + +.category-card:hover { + transform: translateY(-5px); + box-shadow: 0 5px 15px rgba(0,0,0,0.1); +} + +.category-icon { + font-size: 3rem; + margin-bottom: 10px; +} + +/* Report Section */ +.report-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; +} + +.report-filters, +.report-preview { + background: var(--light); + padding: 20px; + border-radius: 15px; +} + +.report-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.report-stats { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; +} + +.report-stat-item { + display: flex; + justify-content: space-between; + padding: 10px; + background: white; + border-radius: 8px; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--secondary); +} + +.empty-state-icon { + font-size: 4rem; + margin-bottom: 20px; +} + +/* Loading */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255,255,255,.3); + border-radius: 50%; + border-top-color: white; + animation: spin 1s ease-in-out infinite; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + transform: translateY(50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Responsive */ +@media (max-width: 768px) { + .navbar { + flex-direction: column; + gap: 15px; + } + + .navbar-menu { + flex-direction: column; + width: 100%; + } + + .items-grid { + grid-template-columns: 1fr; + } + + .search-box { + flex-direction: column; + } + + .search-input { + width: 100%; + } + + .report-section { + grid-template-columns: 1fr; + } + + .data-table { + font-size: 0.9rem; + } + + .data-table th, + .data-table td { + padding: 10px; + } +} \ No newline at end of file diff --git a/lost-and-found/web/index.html b/lost-and-found/web/index.html new file mode 100644 index 0000000..2123eeb --- /dev/null +++ b/lost-and-found/web/index.html @@ -0,0 +1,322 @@ + + + + + + Lost & Found System + + + +
+
+
+

🔍 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

+
+
+ +
+

Mulai Sekarang

+ +
+ +
+
+
0
+
Barang Ditemukan
+
+
+
0
+
Sudah Diklaim
+
+
+
0
+
Pengguna Terdaftar
+
+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/lost-and-found/web/js/admin.js b/lost-and-found/web/js/admin.js new file mode 100644 index 0000000..0433b78 --- /dev/null +++ b/lost-and-found/web/js/admin.js @@ -0,0 +1,434 @@ +// Dashboard Admin JavaScript - FIXED ENDPOINTS + +let allUsers = []; +let allCategories = []; +let allAuditLogs = []; + +// Initialize dashboard +window.addEventListener("DOMContentLoaded", async () => { + const user = checkAuth(); + if (!user || user.role !== "admin") { + window.location.href = "/login"; + return; + } + + await loadStats(); + await loadUsers(); + + setupSearchAndFilters(); + setupReportFilters(); +}); + +// Load statistics - FIXED +async function loadStats() { + try { + const stats = await apiCall("/api/admin/dashboard"); + document.getElementById("statTotalUsers").textContent = + stats.total_users || 0; + document.getElementById("statTotalItems").textContent = + stats.total_items || 0; + document.getElementById("statTotalClaims").textContent = + stats.total_claims || 0; + document.getElementById("statTotalArchive").textContent = + stats.total_archive || 0; + } catch (error) { + console.error("Error loading stats:", error); + } +} + +// Load users - CORRECT (sudah sesuai) +async function loadUsers() { + try { + const response = await apiCall("/api/admin/users"); + allUsers = response.data || []; + renderUsers(allUsers); + } catch (error) { + console.error("Error loading users:", error); + showAlert("Gagal memuat data user", "danger"); + } +} + +// Render users +function renderUsers(users) { + const tbody = document.getElementById("usersTableBody"); + + if (!users || users.length === 0) { + tbody.innerHTML = ` + + + Belum ada data user + + + `; + return; + } + + tbody.innerHTML = users + .map( + (user) => ` + + ${user.name} + ${user.email} + ${user.nrp} + ${getRoleBadge(user.role)} + ${getStatusBadge(user.status || "active")} + + + ${ + user.status === "active" + ? `` + : `` + } + + + ` + ) + .join(""); +} + +// Edit user +async function editUser(userId) { + try { + const user = await apiCall(`/api/admin/users/${userId}`); + + const form = document.getElementById("editUserForm"); + form.elements.user_id.value = user.id; + form.elements.name.value = user.name; + form.elements.email.value = user.email; + form.elements.nrp.value = user.nrp; + form.elements.phone.value = user.phone || ""; + form.elements.role.value = user.role; + + openModal("editUserModal"); + } catch (error) { + console.error("Error loading user:", error); + showAlert("Gagal memuat data user", "danger"); + } +} + +// Submit edit user +document + .getElementById("editUserForm") + ?.addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(e.target); + const userId = formData.get("user_id"); + const role = formData.get("role"); + + try { + const submitBtn = e.target.querySelector('button[type="submit"]'); + submitBtn.disabled = true; + submitBtn.innerHTML = ' Menyimpan...'; + + // Update role + await apiCall(`/api/admin/users/${userId}/role`, { + method: "PATCH", + body: JSON.stringify({ role }), + }); + + showAlert("User berhasil diupdate!", "success"); + closeModal("editUserModal"); + await loadUsers(); + } catch (error) { + console.error("Error updating user:", error); + showAlert(error.message || "Gagal update user", "danger"); + } finally { + const submitBtn = e.target.querySelector('button[type="submit"]'); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = "Update User"; + } + } + }); + +// Block user +async function blockUser(userId) { + if (!confirmAction("Block user ini?")) return; + + try { + await apiCall(`/api/admin/users/${userId}/block`, { + method: "POST", + }); + + showAlert("User berhasil diblock!", "success"); + await loadUsers(); + } catch (error) { + console.error("Error blocking user:", error); + showAlert(error.message || "Gagal block user", "danger"); + } +} + +// Unblock user +async function unblockUser(userId) { + if (!confirmAction("Unblock user ini?")) return; + + try { + await apiCall(`/api/admin/users/${userId}/unblock`, { + method: "POST", + }); + + showAlert("User berhasil di-unblock!", "success"); + await loadUsers(); + } catch (error) { + console.error("Error unblocking user:", error); + showAlert(error.message || "Gagal unblock user", "danger"); + } +} + +// Load categories +async function loadCategories() { + try { + const response = await apiCall("/api/categories"); + allCategories = response.data || []; + renderCategories(allCategories); + } catch (error) { + console.error("Error loading categories:", error); + showAlert("Gagal memuat data kategori", "danger"); + } +} + +// Render categories +function renderCategories(categories) { + const grid = document.getElementById("categoriesGrid"); + + if (!categories || categories.length === 0) { + grid.innerHTML = ` +
+
🏷️
+

Belum ada kategori

+
+ `; + return; + } + + const icons = { + pakaian: "👕", + alat_makan: "🍽️", + aksesoris: "👓", + elektronik: "💻", + alat_tulis: "✏️", + lainnya: "📦", + }; + + grid.innerHTML = categories + .map( + (cat) => ` +
+
${icons[cat.slug] || "📦"}
+

${cat.name}

+ ${ + cat.description + ? `

${cat.description}

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

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(); +}); diff --git a/lost-and-found/web/js/manager.js b/lost-and-found/web/js/manager.js new file mode 100644 index 0000000..58dfb71 --- /dev/null +++ b/lost-and-found/web/js/manager.js @@ -0,0 +1,767 @@ +// Dashboard Manager JavaScript - FIXED ENDPOINTS + +let allItems = []; +let allClaims = []; +let allLostItems = []; +let allArchive = []; + +// Initialize dashboard +window.addEventListener("DOMContentLoaded", async () => { + const user = checkAuth(); + if (!user || user.role !== "manager") { + window.location.href = "/login"; + return; + } + + await loadStats(); + await loadItems(); + setupSearchAndFilters(); +}); + +// Load statistics - FIXED +async function loadStats() { + try { + const stats = await apiCall("/api/manager/dashboard"); + document.getElementById("statTotalItems").textContent = + stats.total_items || 0; + document.getElementById("statPendingClaims").textContent = + stats.pending_claims || 0; + document.getElementById("statVerified").textContent = stats.verified || 0; + document.getElementById("statExpired").textContent = stats.expired || 0; + } catch (error) { + console.error("Error loading stats:", error); + } +} + +// Load items - FIXED +async function loadItems() { + setLoading("itemsGrid", true); + + try { + const response = await apiCall("/api/items"); + allItems = response.data || []; + renderItems(allItems); + } catch (error) { + console.error("Error loading items:", error); + showEmptyState("itemsGrid", "📦", "Gagal memuat data barang"); + } +} + +// Render items +function renderItems(items) { + const grid = document.getElementById("itemsGrid"); + + if (!items || items.length === 0) { + showEmptyState("itemsGrid", "📦", "Belum ada barang"); + return; + } + + grid.innerHTML = items + .map( + (item) => ` +
+ ${item.name} +
+

${item.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); +} diff --git a/lost-and-found/web/js/user.js b/lost-and-found/web/js/user.js new file mode 100644 index 0000000..7796542 --- /dev/null +++ b/lost-and-found/web/js/user.js @@ -0,0 +1,490 @@ +// Dashboard User JavaScript - FIXED ENDPOINTS + +let allItems = []; +let allLostItems = []; +let allClaims = []; + +// Initialize dashboard +window.addEventListener("DOMContentLoaded", async () => { + const user = checkAuth(); + if (!user || user.role !== "user") { + window.location.href = "/login"; + return; + } + + await loadStats(); + await loadBrowseItems(); + setupSearchAndFilters(); +}); + +// Load statistics - CORRECT (sudah sesuai) +async function loadStats() { + try { + const stats = await apiCall("/api/user/stats"); + document.getElementById("statLost").textContent = stats.lost_items || 0; + document.getElementById("statFound").textContent = stats.found_items || 0; + document.getElementById("statClaims").textContent = stats.claims || 0; + } catch (error) { + console.error("Error loading stats:", error); + } +} + +// Load browse items - CORRECT (sudah sesuai) +async function loadBrowseItems() { + setLoading("itemsGrid", true); + + try { + const response = await apiCall("/api/items"); + allItems = response.data || []; + renderItems(allItems); + } catch (error) { + console.error("Error loading items:", error); + showEmptyState("itemsGrid", "📦", "Gagal memuat data barang"); + } +} + +// Render items +function renderItems(items) { + const grid = document.getElementById("itemsGrid"); + + if (!items || items.length === 0) { + showEmptyState("itemsGrid", "📦", "Belum ada barang ditemukan"); + return; + } + + grid.innerHTML = items + .map( + (item) => ` +
+ ${item.name} +
+

${item.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); +} diff --git a/lost-and-found/web/login.html b/lost-and-found/web/login.html new file mode 100644 index 0000000..6c6a4cb --- /dev/null +++ b/lost-and-found/web/login.html @@ -0,0 +1,396 @@ + + + + + + Login - Lost & Found + + + + + + + + \ No newline at end of file diff --git a/lost-and-found/web/manager.html b/lost-and-found/web/manager.html new file mode 100644 index 0000000..0563ec9 --- /dev/null +++ b/lost-and-found/web/manager.html @@ -0,0 +1,253 @@ + + + + + + Dashboard Manager - Lost & Found + + + + + +
+ + +
+
+

Total Barang

+
0
+
+
+

Pending Claim

+
0
+
+
+

Verified

+
0
+
+
+

Expired

+
0
+
+
+ +
+ + + + +
+ + +
+
+
+

Daftar Barang Ditemukan

+ +
+ + + +
+
+
+ + +
+
+
+

Daftar Klaim

+
+ + + +
+
+
+ + +
+
+
+

Barang Hilang

+
+ + + +
+
+
+ + +
+
+
+

Arsip Barang

+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lost-and-found/web/register.html b/lost-and-found/web/register.html new file mode 100644 index 0000000..482830c --- /dev/null +++ b/lost-and-found/web/register.html @@ -0,0 +1,519 @@ + + + + + + Register - Lost & Found + + + +
+
+

🔐 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
+ + + + +
+
+ + + + \ No newline at end of file diff --git a/lost-and-found/web/user.html b/lost-and-found/web/user.html new file mode 100644 index 0000000..a1cfb6a --- /dev/null +++ b/lost-and-found/web/user.html @@ -0,0 +1,638 @@ + + + + + + Dashboard User - Lost & Found + + + + + +
+ + +
+
+

Barang Hilang Saya

+
0
+
+
+

Barang yang Saya Temukan

+
0
+
+
+

Klaim Saya

+
0
+
+
+ +
+ + + + +
+ + +
+
+
+

Barang Ditemukan

+
+ + + +
+
+
+ + +
+
+
+

Barang Hilang Saya

+ +
+
+
+
+ + +
+
+
+

Barang yang Saya Temukan

+ +
+
+
+
+ + +
+
+
+

Riwayat Klaim

+
+
+
+
+
+ + + + + + + + + + + \ No newline at end of file