Initial commit - Lost and Found Revisi
This commit is contained in:
commit
f4c838471c
30
.env
Normal file
30
.env
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=8080
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Database Configuration (MySQL/MariaDB)
|
||||||
|
DB_HOST=202.46.28.160
|
||||||
|
DB_PORT=53306
|
||||||
|
DB_USER=bambang
|
||||||
|
DB_PASSWORD=baminfor25
|
||||||
|
DB_NAME=iot_db
|
||||||
|
DB_CHARSET=utf8mb4
|
||||||
|
DB_PARSE_TIME=True
|
||||||
|
DB_LOC=Local
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET_KEY=your-secret-key-change-this-in-production-2024
|
||||||
|
|
||||||
|
ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456
|
||||||
|
|
||||||
|
# Upload Configuration
|
||||||
|
UPLOAD_PATH=./uploads
|
||||||
|
MAX_UPLOAD_SIZE=10485760
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
# Gemini AI Configuration
|
||||||
|
GROQ_API_KEY=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
|
||||||
|
|
||||||
|
GROQ_MODEL=llama-3.3-70b-versatile
|
||||||
243
cmd/server/main.go
Normal file
243
cmd/server/main.go
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
// cmd/server/main.go - ENHANCED WITH DEBUG LOGGING
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"lost-and-found/internal/config"
|
||||||
|
"lost-and-found/internal/middleware"
|
||||||
|
"lost-and-found/internal/routes"
|
||||||
|
"lost-and-found/internal/workers"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load .env file
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Println("⚠️ No .env file found, using environment variables")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Structured Logging
|
||||||
|
logger, err := zap.NewProduction()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Failed to initialize logger: %v", err)
|
||||||
|
}
|
||||||
|
defer logger.Sync()
|
||||||
|
|
||||||
|
logger.Info("🚀 Starting Lost & Found System...")
|
||||||
|
|
||||||
|
// Initialize JWT config
|
||||||
|
config.InitJWT()
|
||||||
|
logger.Info("✅ JWT configuration initialized")
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
logger.Info("📊 Initializing database...")
|
||||||
|
if err := config.InitDB(); err != nil {
|
||||||
|
logger.Fatal("❌ Failed to initialize database", zap.Error(err))
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logger.Info("🗄️ Closing database connections...")
|
||||||
|
if err := config.CloseDB(); err != nil {
|
||||||
|
logger.Error("Failed to close database", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
logger.Info("✅ Database connections closed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
logger.Info("📋 Running migrations...")
|
||||||
|
db := config.GetDB()
|
||||||
|
if err := config.RunMigrations(db); err != nil {
|
||||||
|
logger.Fatal("❌ Failed to run migrations", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Gin
|
||||||
|
if config.IsProduction() {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
router := gin.Default()
|
||||||
|
|
||||||
|
// Apply middleware
|
||||||
|
router.Use(middleware.CORSMiddleware())
|
||||||
|
router.Use(middleware.LoggerMiddleware())
|
||||||
|
router.Use(middleware.RateLimiterMiddleware())
|
||||||
|
|
||||||
|
if config.IsDevelopment() {
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
c.Writer.Header().Set("Pragma", "no-cache")
|
||||||
|
c.Writer.Header().Set("Expires", "0")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
router.Static("/uploads", "./uploads")
|
||||||
|
router.Static("/css", "./web/css")
|
||||||
|
router.Static("/js", "./web/js")
|
||||||
|
router.Static("/assets", "./web/assets")
|
||||||
|
|
||||||
|
// Frontend routes
|
||||||
|
router.GET("/", func(c *gin.Context) { c.File("./web/index.html") })
|
||||||
|
router.GET("/login", func(c *gin.Context) { c.File("./web/login.html") })
|
||||||
|
router.GET("/login.html", func(c *gin.Context) { c.File("./web/login.html") }) // ✅ Tambahkan ini
|
||||||
|
router.GET("/register", func(c *gin.Context) { c.File("./web/register.html") })
|
||||||
|
router.GET("/register.html", func(c *gin.Context) { c.File("./web/register.html") }) // ✅ Tambahkan ini
|
||||||
|
router.GET("/admin", func(c *gin.Context) { c.File("./web/admin.html") })
|
||||||
|
router.GET("/admin.html", func(c *gin.Context) { c.File("./web/admin.html") }) // ✅ Tambahkan ini
|
||||||
|
router.GET("/manager", func(c *gin.Context) { c.File("./web/manager.html") })
|
||||||
|
router.GET("/manager.html", func(c *gin.Context) { c.File("./web/manager.html") }) // ✅ Tambahkan ini
|
||||||
|
router.GET("/user", func(c *gin.Context) { c.File("./web/user.html") })
|
||||||
|
router.GET("/user.html", func(c *gin.Context) { c.File("./web/user.html") }) // ✅ Tambahkan ini
|
||||||
|
|
||||||
|
// Setup API routes
|
||||||
|
routes.SetupRoutes(router, db, logger)
|
||||||
|
logger.Info("✅ API routes configured")
|
||||||
|
|
||||||
|
// ✅ Start Background Workers
|
||||||
|
logger.Info("🔄 Starting background workers...")
|
||||||
|
expireWorker := workers.NewExpireWorker(db)
|
||||||
|
auditWorker := workers.NewAuditWorker(db)
|
||||||
|
matchingWorker := workers.NewMatchingWorker(db)
|
||||||
|
notificationWorker := workers.NewNotificationWorker(db)
|
||||||
|
|
||||||
|
// ✅ Background Workers - 4 Goroutines
|
||||||
|
expireWorker.Start()
|
||||||
|
auditWorker.Start()
|
||||||
|
matchingWorker.Start()
|
||||||
|
notificationWorker.Start()
|
||||||
|
logger.Info("✅ All background workers started")
|
||||||
|
|
||||||
|
// Get server config
|
||||||
|
serverConfig := config.GetServerConfig()
|
||||||
|
port := serverConfig.Port
|
||||||
|
|
||||||
|
// ✅ DEBUG: Print sebelum create server
|
||||||
|
log.Println("🔧 DEBUG: Creating HTTP server...")
|
||||||
|
log.Printf("🔧 DEBUG: Port = %s\n", port)
|
||||||
|
log.Printf("🔧 DEBUG: Address = :%s\n", port)
|
||||||
|
|
||||||
|
// ✅ HTTP Server with Timeouts
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ DEBUG: Print sebelum start goroutine
|
||||||
|
log.Println("🔧 DEBUG: Starting server goroutine...")
|
||||||
|
|
||||||
|
// ✅ Start server in goroutine
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// ✅ PENTING: Print ini HARUS muncul
|
||||||
|
log.Println("🚀 Server starting...")
|
||||||
|
log.Printf(" 📍 URL: http://localhost:%s\n", port)
|
||||||
|
log.Printf(" 📡 API: http://localhost:%s/api\n", port)
|
||||||
|
log.Printf(" 🌍 ENV: %s\n", serverConfig.Environment)
|
||||||
|
log.Println("✨ Press Ctrl+C to stop")
|
||||||
|
log.Println("🔧 DEBUG: Calling srv.ListenAndServe()...")
|
||||||
|
|
||||||
|
logger.Info("🚀 Server starting",
|
||||||
|
zap.String("url", "http://localhost:"+port),
|
||||||
|
zap.String("api", "http://localhost:"+port+"/api"),
|
||||||
|
zap.String("environment", serverConfig.Environment),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ Ini yang benar-benar mulai server
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("❌ SERVER ERROR: %v\n", err)
|
||||||
|
serverErrors <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ✅ DEBUG: Print setelah goroutine
|
||||||
|
log.Println("🔧 DEBUG: Server goroutine launched, waiting for signals...")
|
||||||
|
|
||||||
|
// ✅ Wait for interrupt signal OR server error
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sig := <-quit:
|
||||||
|
logger.Info("🛑 Shutdown signal received", zap.String("signal", sig.String()))
|
||||||
|
case err := <-serverErrors:
|
||||||
|
logger.Fatal("❌ Server error", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("🔄 Starting graceful shutdown...")
|
||||||
|
|
||||||
|
// ✅ Step 1: Create shutdown context with timeout
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
// Implementasi ACID dan Kontrol Transaksi (15%)
|
||||||
|
// ✅ Step 2: Stop accepting new HTTP requests
|
||||||
|
// Implementasi Graceful Shutdown dengan srv.Shutdown(shutdownCtx) memastikan Durability data yang sedang diproses.
|
||||||
|
logger.Info("🔌 Stopping server from accepting new requests...")
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("⚠️ Server forced to shutdown", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
logger.Info("✅ Server stopped accepting new requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Step 3: Stop background workers
|
||||||
|
logger.Info("🔄 Stopping background workers...")
|
||||||
|
|
||||||
|
workersDone := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
wg.Add(4)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
expireWorker.Stop()
|
||||||
|
logger.Info("✅ Expire worker stopped")
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
auditWorker.Stop()
|
||||||
|
logger.Info("✅ Audit worker stopped")
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
matchingWorker.Stop()
|
||||||
|
logger.Info("✅ Matching worker stopped")
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
notificationWorker.Stop()
|
||||||
|
logger.Info("✅ Notification worker stopped")
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(workersDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ✅ Wait for workers or timeout
|
||||||
|
select {
|
||||||
|
case <-workersDone:
|
||||||
|
logger.Info("✅ All background workers stopped gracefully")
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
logger.Warn("⚠️ Worker shutdown timeout exceeded, forcing exit")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("✅ Graceful shutdown completed")
|
||||||
|
logger.Info("👋 Goodbye!")
|
||||||
|
}
|
||||||
197
database/enhancement.sql
Normal file
197
database/enhancement.sql
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
-- database/enhancement.sql
|
||||||
|
-- ============================================
|
||||||
|
-- ENHANCEMENT FOR LOST & FOUND DATABASE (SAFE MODE)
|
||||||
|
-- Hanya Procedures, Views, dan Indexes
|
||||||
|
USE iot_db;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STORED PROCEDURES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Procedure: Archive expired items
|
||||||
|
DROP PROCEDURE IF EXISTS sp_archive_expired_items$$
|
||||||
|
CREATE PROCEDURE sp_archive_expired_items(
|
||||||
|
OUT p_archived_count INT
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
DECLARE done INT DEFAULT FALSE;
|
||||||
|
DECLARE v_item_id INT;
|
||||||
|
DECLARE cur CURSOR FOR
|
||||||
|
SELECT id FROM items
|
||||||
|
WHERE expires_at < NOW()
|
||||||
|
AND status = 'unclaimed'
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||||
|
|
||||||
|
SET p_archived_count = 0;
|
||||||
|
|
||||||
|
OPEN cur;
|
||||||
|
|
||||||
|
read_loop: LOOP
|
||||||
|
FETCH cur INTO v_item_id;
|
||||||
|
IF done THEN
|
||||||
|
LEAVE read_loop;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Archive the item
|
||||||
|
INSERT INTO archives (
|
||||||
|
item_id, name, category_id, photo_url, location,
|
||||||
|
description, date_found, status, reporter_name,
|
||||||
|
reporter_contact, archived_reason
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, name, category_id, photo_url, location,
|
||||||
|
description, date_found, status, reporter_name,
|
||||||
|
reporter_contact, 'expired'
|
||||||
|
FROM items
|
||||||
|
WHERE id = v_item_id;
|
||||||
|
|
||||||
|
-- Update item status
|
||||||
|
UPDATE items
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE id = v_item_id;
|
||||||
|
|
||||||
|
SET p_archived_count = p_archived_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
CLOSE cur;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
-- Procedure: Get dashboard statistics
|
||||||
|
DROP PROCEDURE IF EXISTS sp_get_dashboard_stats$$
|
||||||
|
CREATE PROCEDURE sp_get_dashboard_stats(
|
||||||
|
OUT p_total_items INT,
|
||||||
|
OUT p_unclaimed_items INT,
|
||||||
|
OUT p_verified_items INT,
|
||||||
|
OUT p_pending_claims INT
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO p_total_items FROM items WHERE deleted_at IS NULL;
|
||||||
|
SELECT COUNT(*) INTO p_unclaimed_items FROM items WHERE status = 'unclaimed' AND deleted_at IS NULL;
|
||||||
|
SELECT COUNT(*) INTO p_verified_items FROM items WHERE status = 'verified' AND deleted_at IS NULL;
|
||||||
|
SELECT COUNT(*) INTO p_pending_claims FROM claims WHERE status = 'pending' AND deleted_at IS NULL;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VIEWS
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_dashboard_stats AS
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM items WHERE status = 'unclaimed' AND deleted_at IS NULL) AS total_unclaimed,
|
||||||
|
(SELECT COUNT(*) FROM items WHERE status = 'verified' AND deleted_at IS NULL) AS total_verified,
|
||||||
|
(SELECT COUNT(*) FROM lost_items WHERE status = 'active' AND deleted_at IS NULL) AS total_lost_reports,
|
||||||
|
(SELECT COUNT(*) FROM claims WHERE status = 'pending' AND deleted_at IS NULL) AS pending_claims,
|
||||||
|
(SELECT COUNT(*) FROM match_results WHERE is_notified = FALSE AND deleted_at IS NULL) AS unnotified_matches;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_items_detail AS
|
||||||
|
SELECT
|
||||||
|
i.id, i.name, c.name AS category_name, c.slug AS category_slug,
|
||||||
|
i.photo_url, i.location, i.date_found, i.status,
|
||||||
|
i.reporter_name, i.reporter_contact, i.expires_at,
|
||||||
|
u.name AS reporter_user_name, u.email AS reporter_email,
|
||||||
|
DATEDIFF(i.expires_at, NOW()) AS days_until_expire, i.created_at
|
||||||
|
FROM items i
|
||||||
|
JOIN categories c ON i.category_id = c.id
|
||||||
|
JOIN users u ON i.reporter_id = u.id
|
||||||
|
WHERE i.deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_claims_detail AS
|
||||||
|
SELECT
|
||||||
|
c.id, c.status, i.name AS item_name, cat.name AS category_name,
|
||||||
|
u.name AS claimant_name, u.email AS claimant_email, u.phone AS claimant_phone,
|
||||||
|
c.description AS claim_description, c.contact, cv.similarity_score,
|
||||||
|
c.verified_at, v.name AS verified_by_name, c.notes, c.created_at
|
||||||
|
FROM claims c
|
||||||
|
JOIN items i ON c.item_id = i.id
|
||||||
|
JOIN categories cat ON i.category_id = cat.id
|
||||||
|
JOIN users u ON c.user_id = u.id
|
||||||
|
LEFT JOIN claim_verifications cv ON c.id = cv.claim_id
|
||||||
|
LEFT JOIN users v ON c.verified_by = v.id
|
||||||
|
WHERE c.deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_match_results_detail AS
|
||||||
|
SELECT
|
||||||
|
mr.id, li.name AS lost_item_name, li.user_id AS lost_by_user_id,
|
||||||
|
u.name AS lost_by_user_name, u.email AS lost_by_email,
|
||||||
|
i.name AS found_item_name, i.reporter_name AS found_by_name,
|
||||||
|
mr.similarity_score, mr.is_notified, mr.matched_at,
|
||||||
|
i.id AS found_item_id, li.id AS lost_item_id
|
||||||
|
FROM match_results mr
|
||||||
|
JOIN lost_items li ON mr.lost_item_id = li.id
|
||||||
|
JOIN items i ON mr.item_id = i.id
|
||||||
|
JOIN users u ON li.user_id = u.id
|
||||||
|
WHERE mr.deleted_at IS NULL
|
||||||
|
ORDER BY mr.similarity_score DESC;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_category_stats AS
|
||||||
|
SELECT
|
||||||
|
c.id, c.name, c.slug,
|
||||||
|
COUNT(DISTINCT i.id) AS total_items,
|
||||||
|
COUNT(DISTINCT CASE WHEN i.status = 'unclaimed' THEN i.id END) AS unclaimed_items,
|
||||||
|
COUNT(DISTINCT CASE WHEN i.status = 'verified' THEN i.id END) AS verified_items,
|
||||||
|
COUNT(DISTINCT li.id) AS total_lost_reports
|
||||||
|
FROM categories c
|
||||||
|
LEFT JOIN items i ON c.id = i.category_id AND i.deleted_at IS NULL
|
||||||
|
LEFT JOIN lost_items li ON c.id = li.category_id AND li.deleted_at IS NULL
|
||||||
|
WHERE c.deleted_at IS NULL
|
||||||
|
GROUP BY c.id, c.name, c.slug;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_user_activity AS
|
||||||
|
SELECT
|
||||||
|
u.id, u.name, u.email, r.name AS role_name,
|
||||||
|
COUNT(DISTINCT i.id) AS items_reported,
|
||||||
|
COUNT(DISTINCT li.id) AS lost_items_reported,
|
||||||
|
COUNT(DISTINCT cl.id) AS claims_made,
|
||||||
|
COUNT(DISTINCT CASE WHEN cl.status = 'approved' THEN cl.id END) AS claims_approved,
|
||||||
|
u.created_at AS member_since
|
||||||
|
FROM users u
|
||||||
|
JOIN roles r ON u.role_id = r.id
|
||||||
|
LEFT JOIN items i ON u.id = i.reporter_id AND i.deleted_at IS NULL
|
||||||
|
LEFT JOIN lost_items li ON u.id = li.user_id AND li.deleted_at IS NULL
|
||||||
|
LEFT JOIN claims cl ON u.id = cl.user_id AND cl.deleted_at IS NULL
|
||||||
|
WHERE u.deleted_at IS NULL
|
||||||
|
GROUP BY u.id, u.name, u.email, r.name, u.created_at;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW vw_recent_activities AS
|
||||||
|
SELECT
|
||||||
|
al.id, al.action, al.entity_type, al.entity_id, al.details,
|
||||||
|
u.name AS user_name, u.email AS user_email, r.name AS user_role,
|
||||||
|
al.ip_address, al.created_at
|
||||||
|
FROM audit_logs al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 100;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- INDEXES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Hapus index jika exist (cara aman di MySQL lama agak ribet, jadi langsung CREATE saja biasanya OK jika belum ada)
|
||||||
|
-- Gunakan DROP INDEX jika perlu mereset index
|
||||||
|
|
||||||
|
CREATE INDEX idx_items_status_category ON items(status, category_id, deleted_at);
|
||||||
|
CREATE INDEX idx_items_date_status ON items(date_found, status, deleted_at);
|
||||||
|
CREATE INDEX idx_claims_status_item ON claims(status, item_id, deleted_at);
|
||||||
|
CREATE INDEX idx_match_results_scores ON match_results(similarity_score DESC, is_notified);
|
||||||
|
CREATE INDEX idx_audit_logs_date_user ON audit_logs(created_at DESC, user_id);
|
||||||
|
CREATE INDEX idx_lost_items_status_user ON lost_items(status, user_id, deleted_at);
|
||||||
|
CREATE INDEX idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
|
||||||
|
|
||||||
|
-- Full-text indexes
|
||||||
|
CREATE FULLTEXT INDEX idx_items_search ON items(name, location);
|
||||||
|
CREATE FULLTEXT INDEX idx_lost_items_search ON lost_items(name, description);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SUCCESS MESSAGE
|
||||||
|
-- ============================================
|
||||||
|
SELECT '✅ Database enhancements (Safe Mode) created!' AS Status;
|
||||||
|
SELECT '⚙️ Procedures: 2' AS Info;
|
||||||
|
SELECT '📈 Views: 7' AS Info;
|
||||||
|
SELECT '🚀 Indexes: 9' AS Info;
|
||||||
|
SELECT '⚠️ Note: Triggers & Functions dihapus untuk menghindari Error 1419' AS Note;
|
||||||
31
database/expired_item.sql
Normal file
31
database/expired_item.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
START TRANSACTION;
|
||||||
|
|
||||||
|
-- 1. Pindahkan item yang sudah kadaluarsa ke tabel archives
|
||||||
|
INSERT INTO archives (
|
||||||
|
item_id, name, category_id, photo_url, location,
|
||||||
|
description, date_found, status, reporter_name,
|
||||||
|
reporter_contact, archived_reason, archived_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, name, category_id, photo_url, location,
|
||||||
|
description, date_found, 'expired', reporter_name,
|
||||||
|
reporter_contact, 'expired', NOW()
|
||||||
|
FROM items
|
||||||
|
WHERE expires_at < NOW()
|
||||||
|
AND status = 'unclaimed'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
-- Pastikan item ini belum ada di archives untuk mencegah duplikat
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM archives WHERE archives.item_id = items.id);
|
||||||
|
|
||||||
|
-- 2. Update status di tabel items menjadi 'expired'
|
||||||
|
UPDATE items
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE expires_at < NOW()
|
||||||
|
AND status = 'unclaimed'
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- 3. Cek Hasilnya
|
||||||
|
SELECT id, name, status, expires_at FROM items WHERE status = 'expired';
|
||||||
|
SELECT * FROM archives WHERE archived_reason = 'expired';
|
||||||
16
database/migration_ai_chat.sql
Normal file
16
database/migration_ai_chat.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
response TEXT NOT NULL,
|
||||||
|
context_data JSON DEFAULT NULL COMMENT 'Data konteks (items, lost_items, dll)',
|
||||||
|
intent VARCHAR(50) DEFAULT NULL COMMENT 'search_item, report_lost, claim_help, general',
|
||||||
|
confidence_score DECIMAL(5,2) DEFAULT 0.00,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_chat_user_id (user_id),
|
||||||
|
INDEX idx_chat_intent (intent),
|
||||||
|
INDEX idx_chat_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
SELECT '✅ AI Chat table created successfully!' AS Status;
|
||||||
34
database/migration_case_close.sql
Normal file
34
database/migration_case_close.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
-- ✅ ADD CASE CLOSE COLUMNS TO items TABLE
|
||||||
|
|
||||||
|
ALTER TABLE items
|
||||||
|
ADD COLUMN berita_acara_no VARCHAR(100) NULL AFTER expires_at,
|
||||||
|
ADD COLUMN bukti_serah_terima VARCHAR(255) NULL AFTER berita_acara_no,
|
||||||
|
ADD COLUMN case_closed_at DATETIME NULL AFTER bukti_serah_terima,
|
||||||
|
ADD COLUMN case_closed_by INT UNSIGNED NULL AFTER case_closed_at,
|
||||||
|
ADD COLUMN case_closed_notes TEXT NULL AFTER case_closed_by,
|
||||||
|
ADD CONSTRAINT fk_items_case_closed_by FOREIGN KEY (case_closed_by) REFERENCES users(id);
|
||||||
|
|
||||||
|
-- ✅ ADD CASE CLOSE COLUMNS TO archives TABLE
|
||||||
|
|
||||||
|
ALTER TABLE archives
|
||||||
|
ADD COLUMN berita_acara_no VARCHAR(100) NULL AFTER claimed_by,
|
||||||
|
ADD COLUMN bukti_serah_terima VARCHAR(255) NULL AFTER berita_acara_no;
|
||||||
|
|
||||||
|
ALTER TABLE lost_items ADD COLUMN matched_at DATETIME NULL;
|
||||||
|
|
||||||
|
-- ✅ ADD INDEX FOR BETTER QUERY PERFORMANCE
|
||||||
|
|
||||||
|
CREATE INDEX idx_items_case_closed ON items(case_closed_at, case_closed_by);
|
||||||
|
CREATE INDEX idx_items_berita_acara ON items(berita_acara_no);
|
||||||
|
|
||||||
|
-- ✅ VERIFY CHANGES
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
COLUMN_NAME,
|
||||||
|
DATA_TYPE,
|
||||||
|
IS_NULLABLE,
|
||||||
|
COLUMN_DEFAULT
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_NAME = 'items'
|
||||||
|
AND COLUMN_NAME IN ('berita_acara_no', 'bukti_serah_terima', 'case_closed_at', 'case_closed_by', 'case_closed_notes')
|
||||||
|
ORDER BY ORDINAL_POSITION;
|
||||||
60
database/migration_direct_claim.sql
Normal file
60
database/migration_direct_claim.sql
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
-- Add direct_claim_id to lost_items table
|
||||||
|
ALTER TABLE lost_items
|
||||||
|
ADD COLUMN direct_claim_id INT UNSIGNED NULL,
|
||||||
|
ADD CONSTRAINT fk_lost_items_direct_claim
|
||||||
|
FOREIGN KEY (direct_claim_id) REFERENCES claims(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add new claim status
|
||||||
|
ALTER TABLE claims
|
||||||
|
MODIFY COLUMN status VARCHAR(50) DEFAULT 'pending';
|
||||||
|
|
||||||
|
-- Add index for better performance
|
||||||
|
CREATE INDEX idx_lost_items_direct_claim ON lost_items(direct_claim_id);
|
||||||
|
CREATE INDEX idx_lost_items_status ON lost_items(status);
|
||||||
|
CREATE INDEX idx_claims_status ON claims(status);
|
||||||
|
|
||||||
|
-- Add direct_claim_id to lost_items table
|
||||||
|
ALTER TABLE lost_items
|
||||||
|
ADD COLUMN IF NOT EXISTS direct_claim_id INT UNSIGNED NULL,
|
||||||
|
ADD CONSTRAINT fk_lost_items_direct_claim
|
||||||
|
FOREIGN KEY (direct_claim_id) REFERENCES claims(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add new claim status
|
||||||
|
ALTER TABLE claims
|
||||||
|
MODIFY COLUMN status VARCHAR(50) DEFAULT 'pending';
|
||||||
|
|
||||||
|
-- Add indexes (only if they don't exist)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lost_items_direct_claim ON lost_items(direct_claim_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lost_items_status ON lost_items(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status);
|
||||||
|
|
||||||
|
ALTER TABLE lost_items
|
||||||
|
ADD COLUMN direct_claim_id INT UNSIGNED NULL;
|
||||||
|
|
||||||
|
-- Add foreign key constraint
|
||||||
|
ALTER TABLE lost_items
|
||||||
|
ADD CONSTRAINT fk_lost_items_direct_claim
|
||||||
|
FOREIGN KEY (direct_claim_id) REFERENCES claims(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Modify claim status column
|
||||||
|
ALTER TABLE claims
|
||||||
|
MODIFY COLUMN status VARCHAR(50) DEFAULT 'pending';
|
||||||
|
|
||||||
|
-- Create index for direct_claim_id (new index, shouldn't exist)
|
||||||
|
CREATE INDEX idx_lost_items_direct_claim ON lost_items(direct_claim_id);
|
||||||
|
|
||||||
|
-- 1. Ubah kolom item_id agar boleh NULL
|
||||||
|
ALTER TABLE claims MODIFY item_id INT UNSIGNED NULL;
|
||||||
|
|
||||||
|
-- 2. Tambah kolom lost_item_id
|
||||||
|
ALTER TABLE claims
|
||||||
|
ADD COLUMN lost_item_id INT UNSIGNED NULL AFTER item_id;
|
||||||
|
|
||||||
|
-- 3. Tambah Foreign Key untuk lost_item_id
|
||||||
|
ALTER TABLE claims
|
||||||
|
ADD CONSTRAINT fk_claims_lost_item
|
||||||
|
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
376
database/schema.sql
Normal file
376
database/schema.sql
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
-- database/schema.sql
|
||||||
|
-- MySQL/MariaDB Database
|
||||||
|
-- Set charset dan collation
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET CHARACTER SET utf8mb4;
|
||||||
|
|
||||||
|
-- Drop tables if exists (untuk clean migration)
|
||||||
|
DROP TABLE IF EXISTS notifications;
|
||||||
|
DROP TABLE IF EXISTS revision_logs;
|
||||||
|
DROP TABLE IF EXISTS verification_logs;
|
||||||
|
DROP TABLE IF EXISTS match_results;
|
||||||
|
DROP TABLE IF EXISTS claim_verifications;
|
||||||
|
DROP TABLE IF EXISTS audit_logs;
|
||||||
|
DROP TABLE IF EXISTS archives;
|
||||||
|
DROP TABLE IF EXISTS claims;
|
||||||
|
DROP TABLE IF EXISTS attachments;
|
||||||
|
DROP TABLE IF EXISTS items;
|
||||||
|
DROP TABLE IF EXISTS lost_items;
|
||||||
|
DROP TABLE IF EXISTS categories;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
DROP TABLE IF EXISTS roles;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ROLES TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
permissions JSON DEFAULT NULL COMMENT 'RBAC permissions in JSON format',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
INDEX idx_roles_name (name),
|
||||||
|
INDEX idx_roles_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- USERS TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
nrp VARCHAR(20) DEFAULT NULL, -- ✅ Ubah dari 500 ke 20
|
||||||
|
phone VARCHAR(20) DEFAULT NULL, -- ✅ Ubah dari 500 ke 20
|
||||||
|
role_id INT UNSIGNED NOT NULL DEFAULT 3,
|
||||||
|
status VARCHAR(20) DEFAULT 'active',
|
||||||
|
last_login DATETIME DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT,
|
||||||
|
INDEX idx_users_email (email),
|
||||||
|
INDEX idx_users_nrp (nrp), -- ✅ Hapus (255) karena sudah tidak perlu
|
||||||
|
INDEX idx_users_role_id (role_id),
|
||||||
|
INDEX idx_users_status (status),
|
||||||
|
INDEX idx_users_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CATEGORIES TABLE
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon_url VARCHAR(255) DEFAULT NULL COMMENT 'Category icon URL',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
INDEX idx_categories_slug (slug),
|
||||||
|
INDEX idx_categories_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ITEMS TABLE (Barang Ditemukan)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE items (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
category_id INT UNSIGNED NOT NULL,
|
||||||
|
photo_url VARCHAR(255) DEFAULT NULL,
|
||||||
|
location VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT NOT NULL COMMENT 'Public description',
|
||||||
|
secret_details TEXT DEFAULT NULL COMMENT 'RAHASIA - untuk verifikasi klaim (hanya visible untuk owner/admin)',
|
||||||
|
date_found DATE NOT NULL COMMENT 'Tanggal barang ditemukan',
|
||||||
|
status VARCHAR(50) DEFAULT 'unclaimed' COMMENT 'unclaimed, claimed, expired',
|
||||||
|
reporter_id INT UNSIGNED NOT NULL,
|
||||||
|
reporter_name VARCHAR(100) NOT NULL,
|
||||||
|
reporter_contact VARCHAR(50) NOT NULL,
|
||||||
|
view_count INT DEFAULT 0 COMMENT 'Total views untuk analytics',
|
||||||
|
expires_at TIMESTAMP NULL DEFAULT NULL COMMENT 'Tanggal barang akan dihapus (3 bulan dari date_found)',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
INDEX idx_items_category_id (category_id),
|
||||||
|
INDEX idx_items_status (status),
|
||||||
|
INDEX idx_items_reporter_id (reporter_id),
|
||||||
|
INDEX idx_items_date_found (date_found),
|
||||||
|
INDEX idx_items_expires_at (expires_at),
|
||||||
|
INDEX idx_items_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- LOST_ITEMS TABLE (Barang Hilang)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE lost_items (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
category_id INT UNSIGNED NOT NULL,
|
||||||
|
color VARCHAR(50) DEFAULT NULL,
|
||||||
|
location VARCHAR(200) DEFAULT NULL,
|
||||||
|
description TEXT NOT NULL COMMENT 'Deskripsi untuk auto-matching',
|
||||||
|
date_lost DATE NOT NULL COMMENT 'Tanggal barang hilang',
|
||||||
|
status VARCHAR(50) DEFAULT 'active' COMMENT 'active, resolved',
|
||||||
|
resolved_at DATETIME DEFAULT NULL COMMENT 'Kapan laporan hilang ditandai sebagai selesai',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
||||||
|
INDEX idx_lost_items_user_id (user_id),
|
||||||
|
INDEX idx_lost_items_category_id (category_id),
|
||||||
|
INDEX idx_lost_items_status (status),
|
||||||
|
INDEX idx_lost_items_date_lost (date_lost),
|
||||||
|
INDEX idx_lost_items_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ATTACHMENTS TABLE (Foto Barang)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to items, nullable for lost_items',
|
||||||
|
lost_item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to lost_items, nullable for items',
|
||||||
|
file_url VARCHAR(255) NOT NULL,
|
||||||
|
file_type VARCHAR(50) DEFAULT NULL COMMENT 'jpg, png, gif, dll',
|
||||||
|
file_size INT DEFAULT NULL COMMENT 'File size in bytes',
|
||||||
|
upload_by_user_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
display_order INT DEFAULT 0 COMMENT 'Order untuk display multiple photos',
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE COMMENT 'Primary photo untuk display',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (upload_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_attachments_item_id (item_id),
|
||||||
|
INDEX idx_attachments_lost_item_id (lost_item_id),
|
||||||
|
INDEX idx_attachments_is_primary (is_primary)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CLAIMS TABLE (Klaim Barang)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE claims (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT UNSIGNED NOT NULL,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
description TEXT NOT NULL COMMENT 'Deskripsi dari user untuk verifikasi',
|
||||||
|
proof_url VARCHAR(255) DEFAULT NULL,
|
||||||
|
contact VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'pending' COMMENT 'pending, approved, rejected',
|
||||||
|
notes TEXT DEFAULT NULL COMMENT 'Admin verification notes',
|
||||||
|
rejection_reason VARCHAR(255) DEFAULT NULL COMMENT 'Alasan penolakan klaim',
|
||||||
|
attempt_count INT DEFAULT 1 COMMENT 'Jumlah percobaan klaim (fraud prevention)',
|
||||||
|
verified_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
verified_by INT UNSIGNED DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_claims_item_id (item_id),
|
||||||
|
INDEX idx_claims_user_id (user_id),
|
||||||
|
INDEX idx_claims_status (status),
|
||||||
|
INDEX idx_claims_verified_by (verified_by),
|
||||||
|
INDEX idx_claims_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- CLAIM_VERIFICATIONS TABLE (Data Verifikasi Klaim)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE claim_verifications (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
claim_id INT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
similarity_score DECIMAL(5,2) DEFAULT 0.00 COMMENT 'Similarity score 0-100',
|
||||||
|
matched_keywords TEXT DEFAULT NULL COMMENT 'Keywords matched (JSON format)',
|
||||||
|
verification_notes TEXT DEFAULT NULL,
|
||||||
|
is_auto_matched BOOLEAN DEFAULT FALSE,
|
||||||
|
verification_method VARCHAR(50) DEFAULT 'manual' COMMENT 'manual, auto, hybrid',
|
||||||
|
metadata JSON DEFAULT NULL COMMENT 'Additional verification data (extensible)',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_claim_verifications_claim_id (claim_id),
|
||||||
|
INDEX idx_claim_verifications_similarity_score (similarity_score),
|
||||||
|
INDEX idx_claim_verifications_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VERIFICATION_LOGS TABLE (Audit Trail Verifikasi)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE verification_logs (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
verification_id INT UNSIGNED NOT NULL,
|
||||||
|
verified_by_user_id INT UNSIGNED NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL COMMENT 'approve, reject, pending, review',
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (verification_id) REFERENCES claim_verifications(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (verified_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
INDEX idx_verification_logs_verification_id (verification_id),
|
||||||
|
INDEX idx_verification_logs_verified_by_user_id (verified_by_user_id),
|
||||||
|
INDEX idx_verification_logs_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- MATCH_RESULTS TABLE (Hasil Auto-Matching)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE match_results (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
lost_item_id INT UNSIGNED NOT NULL,
|
||||||
|
item_id INT UNSIGNED NOT NULL,
|
||||||
|
similarity_score DECIMAL(5,2) NOT NULL,
|
||||||
|
matched_fields TEXT DEFAULT NULL COMMENT 'JSON format: {category, description, color}',
|
||||||
|
match_reason VARCHAR(100) DEFAULT NULL COMMENT 'Reason for matching: color, location, description, etc',
|
||||||
|
matched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_notified BOOLEAN DEFAULT FALSE,
|
||||||
|
notified_at DATETIME DEFAULT NULL COMMENT 'Kapan user diberitahu tentang match',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_match_results_lost_item_id (lost_item_id),
|
||||||
|
INDEX idx_match_results_item_id (item_id),
|
||||||
|
INDEX idx_match_results_similarity_score (similarity_score),
|
||||||
|
INDEX idx_match_results_is_notified (is_notified),
|
||||||
|
INDEX idx_match_results_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ARCHIVES TABLE (Barang yang Diarsipkan)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE archives (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT UNSIGNED UNIQUE NOT NULL COMMENT 'Original item ID',
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
category_id INT UNSIGNED NOT NULL,
|
||||||
|
photo_url VARCHAR(255) DEFAULT NULL,
|
||||||
|
location VARCHAR(200) DEFAULT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
date_found DATE DEFAULT NULL COMMENT 'Tanggal barang ditemukan (dari items)',
|
||||||
|
status VARCHAR(50) DEFAULT NULL,
|
||||||
|
reporter_name VARCHAR(100) DEFAULT NULL,
|
||||||
|
reporter_contact VARCHAR(50) DEFAULT NULL,
|
||||||
|
archived_reason VARCHAR(100) DEFAULT NULL COMMENT 'expired, case_closed',
|
||||||
|
claimed_by INT UNSIGNED DEFAULT NULL,
|
||||||
|
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (claimed_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_archives_item_id (item_id),
|
||||||
|
INDEX idx_archives_category_id (category_id),
|
||||||
|
INDEX idx_archives_archived_reason (archived_reason),
|
||||||
|
INDEX idx_archives_archived_at (archived_at),
|
||||||
|
INDEX idx_archives_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- REVISION_LOGS TABLE (Audit Trail Edit Barang)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE revision_logs (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
item_id INT UNSIGNED NOT NULL,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
field_name VARCHAR(50) NOT NULL,
|
||||||
|
old_value TEXT DEFAULT NULL,
|
||||||
|
new_value TEXT DEFAULT NULL,
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_revision_logs_item_id (item_id),
|
||||||
|
INDEX idx_revision_logs_user_id (user_id),
|
||||||
|
INDEX idx_revision_logs_created_at (created_at),
|
||||||
|
INDEX idx_revision_logs_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- AUDIT_LOGS TABLE (System Audit Trail)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
entity_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
details TEXT DEFAULT NULL,
|
||||||
|
ip_address VARCHAR(50) DEFAULT NULL,
|
||||||
|
user_agent VARCHAR(255) DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
INDEX idx_audit_logs_user_id (user_id),
|
||||||
|
INDEX idx_audit_logs_action (action),
|
||||||
|
INDEX idx_audit_logs_entity_type (entity_type),
|
||||||
|
INDEX idx_audit_logs_entity_id (entity_id),
|
||||||
|
INDEX idx_audit_logs_created_at (created_at),
|
||||||
|
INDEX idx_audit_logs_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 1. Tabel Daftar Hak Akses (Permissions)
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
slug VARCHAR(50) UNIQUE NOT NULL COMMENT 'Kode unik, misal: item:create',
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT 'Nama deskriptif, misal: Create Item',
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_permissions_slug (slug)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 2. Tabel Pivot (Menghubungkan Role dengan Permission)
|
||||||
|
CREATE TABLE role_permissions (
|
||||||
|
role_id INT UNSIGNED NOT NULL,
|
||||||
|
permission_id INT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (role_id, permission_id),
|
||||||
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- NOTIFICATIONS TABLE (Notifikasi User)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
entity_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
entity_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
channel VARCHAR(50) DEFAULT 'push' COMMENT 'email, sms, push',
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
read_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_notifications_user_id (user_id),
|
||||||
|
INDEX idx_notifications_type (type),
|
||||||
|
INDEX idx_notifications_is_read (is_read),
|
||||||
|
INDEX idx_notifications_created_at (created_at),
|
||||||
|
INDEX idx_notifications_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SUCCESS MESSAGE
|
||||||
|
-- ============================================
|
||||||
|
SELECT '✅ Database schema created successfully!' AS Status;
|
||||||
|
SELECT '📋 Total tables: 15 (UPGRADED from 13)' AS Info;
|
||||||
|
SELECT '🔑 Indexes created on all tables' AS Info;
|
||||||
|
SELECT '🔗 Foreign keys with proper constraints' AS Info;
|
||||||
|
SELECT '✨ NOW SYNC with ERD diagram!' AS Info;
|
||||||
|
SELECT '📝 Next step: Run seed.sql to populate initial data' AS NextStep;
|
||||||
315
database/seed.sql
Normal file
315
database/seed.sql
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
-- seed.sql
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. ROLES & CATEGORIES
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO roles (id, name, description) VALUES
|
||||||
|
(1, 'admin', 'Administrator with full access'),
|
||||||
|
(2, 'manager', 'Manager for verification and approval'),
|
||||||
|
(3, 'user', 'Regular user (student)');
|
||||||
|
|
||||||
|
INSERT INTO categories (id, name, slug, description) VALUES
|
||||||
|
(1, 'Pakaian', 'pakaian', 'Baju, celana, jaket, dll'),
|
||||||
|
(2, 'Alat Makan', 'alat-makan', 'Botol, tupperware, dll'),
|
||||||
|
(3, 'Aksesoris', 'aksesoris', 'Jam tangan, kacamata, perhiasan'),
|
||||||
|
(4, 'Elektronik', 'elektronik', 'HP, laptop, charger, dll'),
|
||||||
|
(5, 'Alat Tulis', 'alat-tulis', 'Pulpen, buku, pensil, dll'),
|
||||||
|
(6, 'Lainnya', 'lainnya', 'Barang lain yang tidak masuk kategori');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. USERS (Explicit IDs 1-10)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO users (id, name, email, password, nrp, phone, role_id, status, last_login) VALUES
|
||||||
|
(1, 'Admin', 'admin@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '0001', '081234567890', 1, 'active', NULL),
|
||||||
|
(2, 'Pak Budi', 'manager1@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567890', '081234567891', 2, 'active', NULL),
|
||||||
|
(3, 'Bu Siti', 'manager2@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567891', '081234567892', 2, 'active', NULL),
|
||||||
|
(4, 'Ahmad Rizki', 'ahmad@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211004', '081234567893', 3, 'active', '2024-02-12 09:15:00'),
|
||||||
|
(5, 'Siti Nurhaliza', 'siti@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211005', '081234567894', 3, 'active', '2024-02-11 14:20:00'),
|
||||||
|
(6, 'Budi Santoso', 'budi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211008', '081234567895', 3, 'active', '2024-02-08 16:30:00'),
|
||||||
|
(7, 'Dewi Lestari', 'dewi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211009', '081234567896', 3, 'active', '2024-01-15 11:00:00'),
|
||||||
|
(8, 'Pak Joko', 'manager3@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567892', '081234567898', 2, 'active', '2024-02-11 08:30:00'),
|
||||||
|
(9, 'Rina Melati', 'rina@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211006', '081234567899', 3, 'active', '2024-02-10 13:20:00'),
|
||||||
|
(10, 'Fajar Ramadhan', 'fajar@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211007', '081234567800', 3, 'active', '2024-02-09 10:45:00');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. ITEMS (FOUND ITEMS) - Explicit IDs 1-19
|
||||||
|
-- ============================================
|
||||||
|
-- ============================================
|
||||||
|
-- 3. ITEMS (FOUND ITEMS) - Updated with Real Photo URLs
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO items (id, name, category_id, photo_url, location, description, secret_details, date_found, status, reporter_id, reporter_name, reporter_contact, view_count, expires_at) VALUES
|
||||||
|
(1, 'Sweater Adidas Abu-abu', 1, '/uploads/items/crewnecksweateradidasabugr170555915195375838_20251215_120350.jpg', 'Ruang Kelas A201', 'Sweater abu-abu Adidas ukuran L', 'Kancing tengah hilang', '2024-01-15', 'claimed', 2, 'Pak Budi', '081234567891', 25, '2024-04-15 10:00:00'),
|
||||||
|
|
||||||
|
(2, 'Mouse Wireless Logitech Pebble', 4, '/uploads/items/logitech-pebble-2-mouse-wireless-bluetooth-silent-_20251215_120318.jpg', 'Lab Komputer 1', 'Mouse wireless Logitech Pebble warna biru', 'Tidak ada receiver USB', '2024-01-25', 'unclaimed', 2, 'Pak Budi', '081234567891', 31, '2024-04-25 14:00:00'),
|
||||||
|
|
||||||
|
(3, 'Charger Laptop HP', 4, '/uploads/items/17213fdc51f440e20c356e0cb0daff85_20251215_120303.jpg', 'Perpustakaan', 'Charger HP 65W original', 'Kabel kusut, konektor bulat', '2024-02-01', 'unclaimed', 2, 'Pak Budi', '081234567891', 27, '2024-05-01 09:00:00'),
|
||||||
|
|
||||||
|
(4, 'Jam Tangan G-Shock', 3, '/uploads/items/71fbd7e4ed969327b4c63fd0337fa076_20251215_120248.jpg', 'Lapangan Basket', 'Jam G-Shock hitam strip orange', 'Baterai bagus, tali lentur', '2024-02-05', 'claimed', 3, 'Bu Siti', '081234567892', 42, '2024-05-05 11:00:00'),
|
||||||
|
|
||||||
|
(5, 'Powerbank Xiaomi', 4, '/uploads/items/4854ab7ea206ecf4608a421a3aae9d84_20251215_120228.jpeg', 'Ruang Kelas E102', 'Powerbank Xiaomi warna putih 10000mAh', 'Kapasitas baterai masih 80%', '2024-02-16', 'unclaimed', 2, 'Pak Budi', '081234567891', 33, '2024-05-16 15:00:00'),
|
||||||
|
|
||||||
|
(6, 'Kacamata Minus Frame Hitam', 3, '/uploads/items/images6_20251215_120215.jpeg', 'Masjid Kampus', 'Kacamata frame hitam rectangular', 'Minus tinggi, lensa tebal', '2024-02-06', 'unclaimed', 2, 'Pak Budi', '081234567891', 19, '2024-05-06 13:00:00'),
|
||||||
|
|
||||||
|
(7, 'Pensil Mekanik Rotring', 5, '/uploads/items/6361b48b1cdbce097e6c44f4-brand-new-rotring-300-bla_20251215_120202.jpg', 'Studio Gambar', 'Pensil Rotring 300 warna hitam 0.5mm', 'Ada penyok kecil di barrel', '2024-02-09', 'unclaimed', 2, 'Pak Budi', '081234567891', 15, '2024-05-09 15:00:00'),
|
||||||
|
|
||||||
|
(8, 'Tumbler Stainless', 2, '/uploads/items/sg-11134201-22120-nm8gakxbodlvf3_20251215_120151.jpeg', 'Kantin Utama', 'Tumbler stainless steel warna silver 500ml', 'Ada lecet di bagian bawah', '2024-02-12', 'unclaimed', 2, 'Pak Budi', '081234567891', 45, '2024-05-12 09:00:00'),
|
||||||
|
|
||||||
|
(9, 'Dompet Kulit Coklat', 6, '/uploads/items/9f3daf02f82abac0969c375c2e969711_20251215_120141.jpeg', 'Toilet Gedung B', 'Dompet kulit warna coklat tua branded', 'Ada foto keluarga di dalam', '2024-02-13', 'unclaimed', 3, 'Bu Siti', '081234567892', 52, '2024-05-13 10:30:00'),
|
||||||
|
|
||||||
|
(10, 'Payung Lipat Hitam', 6, '/uploads/items/08ebc54bcd3dd6a6d56e8164f825b70djpg720x720q80_20251215_120130.jpg', 'Gedung C Lantai 2', 'Payung lipat otomatis warna hitam', 'Gagang ada retak kecil', '2024-02-14', 'unclaimed', 2, 'Pak Budi', '081234567891', 38, '2024-05-14 11:00:00'),
|
||||||
|
|
||||||
|
(11, 'Tas Ransel Abu-abu', 6, '/uploads/items/5203b11f93f324a1a9eed170ef3425ecjpg720x720q80_20251215_120113.jpg', 'Perpustakaan lantai 2', 'Tas ransel warna abu-abu dengan banyak kompartemen', 'Resleting saku kecil rusak', '2024-02-15', 'unclaimed', 2, 'Pak Budi', '081234567891', 29, '2024-05-15 13:30:00'),
|
||||||
|
|
||||||
|
(12, 'Flashdisk SanDisk 32GB', 4, '/uploads/items/38f52092-5525-4684-b85c-1820b540353b169_20251215_120103.png', 'Lab Komputer 2', 'Flashdisk SanDisk Ultra 32GB warna hitam', 'Ada file kuliah di dalamnya', '2024-02-17', 'unclaimed', 3, 'Bu Siti', '081234567892', 21, '2024-05-17 08:45:00'),
|
||||||
|
|
||||||
|
(13, 'Botol Minum Sport', 2, '/uploads/items/a90e0ef7f1b7696015c444446f367073_20251215_120050.jpeg', 'Lapangan Olahraga', 'Botol minum sport warna biru 750ml', 'Ada stiker nama yang sudah pudar', '2024-02-18', 'unclaimed', 3, 'Bu Siti', '081234567892', 35, '2024-05-18 14:20:00'),
|
||||||
|
|
||||||
|
(14, 'Gelang Emas', 3, '/uploads/items/4356295123897739306453191304230020784342341n21_20251215_120038.jpg', 'Toilet Wanita Gedung C', 'Gelang emas motif bunga halus', 'Cap 22K di bagian dalam', '2024-02-19', 'unclaimed', 3, 'Bu Siti', '081234567892', 61, '2024-05-19 09:15:00'),
|
||||||
|
|
||||||
|
(15, 'Kalung Model Italy', 3, '/uploads/items/KalungRantaiModelItaly1-600x600_20251215_115944.jpg', 'Mushola Kampus', 'Kalung rantai model Italy warna gold', 'Rantai ada yang kusut sedikit', '2024-02-20', 'unclaimed', 2, 'Pak Budi', '081234567891', 17, '2024-05-20 09:15:00'),
|
||||||
|
|
||||||
|
(16, 'Buku Kalkulus Purcell', 5, '/uploads/items/productimage-1752141317_20251215_120335.jpg', 'Perpustakaan Lantai 3', 'Buku Kalkulus Purcell edisi 9 hardcover', 'Coretan nama di halaman pertama', '2024-02-08', 'claimed', 3, 'Bu Siti', '081234567892', 34, '2024-05-08 10:00:00');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. LOST_ITEMS (BARANG HILANG) - Explicit IDs 1-11
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO lost_items (id, user_id, name, category_id, color, location, description, date_lost, status, resolved_at) VALUES
|
||||||
|
(1, 4, 'Charger Laptop', 4, 'Hitam', 'Perpustakaan', 'Charger laptop HP 65W dengan kabel panjang', '2024-02-01', 'active', NULL),
|
||||||
|
(2, 5, 'Lunchbox Hello Kitty', 2, 'Pink', 'Gedung B', 'Kotak makan pink dengan gambar Hello Kitty di tutup', '2024-01-22', 'active', NULL),
|
||||||
|
(3, 7, 'Sweater ITS', 1, 'Abu-abu', 'Perpustakaan', 'Sweater abu-abu dengan tulisan ITS di punggung', '2024-01-18', 'active', NULL),
|
||||||
|
(4, 7, 'Pensil Mekanik Rotring', 5, 'Silver', 'Studio Gambar', 'Pensil mekanik Rotring 0.5mm warna silver', '2024-02-10', 'active', NULL),
|
||||||
|
-- Items 5-11 match the match_results requirements
|
||||||
|
(5, 4, 'Charger Laptop', 4, 'Hitam', 'Perpustakaan', 'Charger laptop HP 65W', '2024-02-01', 'active', NULL),
|
||||||
|
(6, 6, 'Mouse Wireless', 4, 'Hitam', 'Lab Komputer 1', 'Mouse Logitech tanpa receiver', '2024-01-25', 'active', NULL),
|
||||||
|
(7, 7, 'Kacamata Minus', 3, 'Hitam', 'Masjid Kampus', 'Kacamata frame hitam', '2024-02-06', 'active', NULL),
|
||||||
|
(8, 4, 'Charger HP', 4, 'Hitam', 'Perpustakaan', 'Charger HP original', '2024-02-01', 'active', NULL),
|
||||||
|
(9, 5, 'Botol Minum', 2, 'Biru', 'Kantin', 'Botol minum biru 500ml', '2024-01-20', 'active', NULL),
|
||||||
|
(10, 7, 'Pensil Mekanik', 5, 'Silver', 'Studio Gambar', 'Pensil Rotring 0.5mm', '2024-02-10', 'active', NULL),
|
||||||
|
(11, 7, 'Topi Merah', 6, 'Merah', 'Lapangan', 'Topi baseball merah', '2024-02-15', 'active', NULL);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. CLAIMS (Explicit IDs 1-10)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO claims (id, item_id, user_id, description, proof_url, contact, status, notes, rejection_reason, attempt_count, verified_at, verified_by) VALUES
|
||||||
|
(1, 8, 5, 'Jam tangan G-Shock hitam dengan strip orange', '/proofs/claim4.jpg', '081234567894', 'approved', 'Semua detail cocok', NULL, 1, '2024-02-07 14:00:00', 2),
|
||||||
|
(2, 1, 7, 'Sweater abu-abu dengan tulisan ITS', '/proofs/claim5.jpg', '081234567896', 'approved', 'Deskripsi sangat detail', NULL, 1, '2024-02-06 11:20:00', 3),
|
||||||
|
(3, 7, 7, 'Buku Kalkulus Purcell edisi 9', '/proofs/claim6.jpg', '081234567896', 'approved', 'Buku diserahkan', NULL, 1, '2024-02-11 09:45:00', 2),
|
||||||
|
(4, 5, 5, 'Jam tangan G-Shock hitam', '/proofs/claim_gshock.jpg', '081234567894', 'approved', 'Valid', NULL, 1, '2024-02-07 14:00:00', 2),
|
||||||
|
(5, 1, 7, 'Sweater ITS', '/proofs/claim_sweater.jpg', '081234567896', 'approved', 'Valid', NULL, 1, '2024-02-06 11:20:00', 3),
|
||||||
|
(6, 7, 7, 'Buku Kalkulus', '/proofs/claim_buku.jpg', '081234567896', 'approved', 'Valid', NULL, 1, '2024-02-11 09:45:00', 2),
|
||||||
|
(7, 4, 4, 'Charger laptop HP 65W original', '/proofs/claim7.jpg', '081234567893', 'pending', NULL, NULL, 1, NULL, NULL),
|
||||||
|
(8, 2, 5, 'Botol minum biru ada stiker nama', '/proofs/claim8.jpg', '081234567894', 'pending', NULL, NULL, 1, NULL, NULL),
|
||||||
|
(9, 8, 7, 'Pensil mekanik Rotring', '/proofs/claim9.jpg', '081234567896', 'pending', NULL, NULL, 1, NULL, NULL),
|
||||||
|
(10, 3, 6, 'Mouse wireless Logitech merah', '/proofs/claim10.jpg', '081234567895', 'rejected', NULL, 'Deskripsi tidak cocok', 1, NULL, 2);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 6. CLAIM_VERIFICATIONS (Explicit IDs)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO claim_verifications (id, claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched, verification_method, metadata) VALUES
|
||||||
|
(1, 4, 89.40, '["jam", "tangan", "gshock"]', 'Excellent match', TRUE, 'hybrid', JSON_OBJECT('confidence', 'very_high')),
|
||||||
|
(2, 5, 93.20, '["sweater", "abu", "its"]', 'Perfect match', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high')),
|
||||||
|
(3, 6, 90.10, '["buku", "kalkulus"]', 'Verified with original name', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high')),
|
||||||
|
(4, 7, 86.80, '["charger", "laptop", "hp"]', 'High similarity', FALSE, 'manual', JSON_OBJECT('confidence', 'high')),
|
||||||
|
(5, 8, 91.50, '["botol", "minum", "biru"]', 'Very high match', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high')),
|
||||||
|
(6, 9, 87.60, '["pensil", "mekanik"]', 'Good match', FALSE, 'manual', JSON_OBJECT('confidence', 'high')),
|
||||||
|
(7, 10, 45.30, '["mouse", "wireless"]', 'Low match', FALSE, 'manual', JSON_OBJECT('confidence', 'low'));
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 7. VERIFICATION LOGS
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO verification_logs (verification_id, verified_by_user_id, action, reason) VALUES
|
||||||
|
(1, 2, 'approve', 'Semua detail cocok.'),
|
||||||
|
(2, 3, 'approve', 'Detail kancing hilang cocok.'),
|
||||||
|
(3, 2, 'approve', 'Nama asli di buku cocok.'),
|
||||||
|
(4, 2, 'pending', 'Menunggu konfirmasi SN.'),
|
||||||
|
(5, 3, 'pending', 'Dalam proses scheduling.'),
|
||||||
|
(6, 2, 'pending', 'Menunggu cek fisik.'),
|
||||||
|
(7, 2, 'reject', 'Deskripsi receiver tidak cocok.');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 8. MATCH RESULTS (Explicit Foreign Keys)
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, match_reason, matched_at, is_notified, notified_at) VALUES
|
||||||
|
(6, 3, 88.90, '{"name": 87}', 'Mouse match', '2024-02-08 10:30:00', TRUE, '2024-02-08 11:00:00'),
|
||||||
|
(7, 6, 85.70, '{"name": 83}', 'Kacamata match', '2024-02-05 15:00:00', TRUE, '2024-02-05 15:30:00'),
|
||||||
|
(8, 4, 87.30, '{"name": 85}', 'Charger match', '2024-02-01 11:00:00', TRUE, '2024-02-01 11:45:00'),
|
||||||
|
(9, 2, 90.50, '{"name": 89}', 'Botol match', '2024-01-18 14:30:00', TRUE, '2024-01-18 15:00:00'),
|
||||||
|
(10, 8, 86.20, '{"name": 84}', 'Pensil match', '2024-02-10 16:00:00', TRUE, '2024-02-10 16:30:00');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 9. NOTIFICATIONS
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, channel, is_read, read_at) VALUES
|
||||||
|
(5, 'match_found', 'Barang Mirip', 'Item found: Jam Tangan', 'match', 4, 'push', TRUE, '2024-02-03 14:30:00'),
|
||||||
|
(7, 'match_found', 'Barang Mirip', 'Item found: Buku Kalkulus', 'match', 6, 'push', TRUE, '2024-02-08 11:15:00'),
|
||||||
|
(7, 'match_found', 'Barang Mirip', 'Item found: Kacamata', 'match', 7, 'push', FALSE, NULL),
|
||||||
|
(4, 'match_found', 'Barang Mirip', 'Item found: Charger', 'match', 8, 'push', FALSE, NULL),
|
||||||
|
(5, 'claim_approved', 'Klaim Disetujui', 'Klaim G-Shock disetujui', 'claim', 4, 'email', TRUE, '2024-02-07 14:30:00'),
|
||||||
|
(7, 'claim_approved', 'Klaim Disetujui', 'Klaim Sweater disetujui', 'claim', 5, 'email', TRUE, '2024-02-06 12:00:00'),
|
||||||
|
(2, 'new_claim', 'Klaim Baru', 'Klaim: Charger Laptop', 'claim', 7, 'push', FALSE, NULL);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 10. AUDIT LOGS
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details) VALUES
|
||||||
|
(4, 'create', 'lost_item', 1, 'Lost item report created'),
|
||||||
|
(5, 'create', 'lost_item', 2, 'Lost item report created'),
|
||||||
|
(7, 'create', 'lost_item', 3, 'Lost item report created'),
|
||||||
|
(5, 'create', 'claim', 4, 'Claim created for item'),
|
||||||
|
(7, 'create', 'claim', 5, 'Claim created for item'),
|
||||||
|
(2, 'approve', 'claim', 4, 'Claim approved'),
|
||||||
|
(3, 'approve', 'claim', 5, 'Claim approved');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 11. ARCHIVES (Target: 10 Data)
|
||||||
|
-- Menambahkan data barang lama yang sudah diarsipkan
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO archives (item_id, name, category_id, photo_url, location, description, date_found, status, reporter_name, reporter_contact, archived_reason, claimed_by, archived_at) VALUES
|
||||||
|
(101, 'Jaket Denim', 1, '/photos/archive1.jpg', 'Kantin', 'Jaket denim pudar', '2023-12-01', 'claimed', 'Pak Budi', '081234567891', 'case_closed', 4, '2023-12-05 10:00:00'),
|
||||||
|
(102, 'Kunci Motor Honda', 6, '/photos/archive2.jpg', 'Parkiran', 'Kunci motor gantungan boneka', '2023-12-02', 'claimed', 'Satpam', '081234567777', 'case_closed', 5, '2023-12-03 14:00:00'),
|
||||||
|
(103, 'Payung Kuning', 6, '/photos/archive3.jpg', 'Lobi Utama', 'Payung panjang kuning', '2023-11-15', 'expired', 'Bu Siti', '081234567892', 'expired', NULL, '2024-02-15 00:00:00'),
|
||||||
|
(104, 'Tumblr Starbucks', 2, '/photos/archive4.jpg', 'Perpustakaan', 'Tumblr hitam logo hijau', '2023-12-10', 'claimed', 'Pak Budi', '081234567891', 'case_closed', 6, '2023-12-12 09:00:00'),
|
||||||
|
(105, 'Buku Catatan Fisika', 5, '/photos/archive5.jpg', 'Kelas B201', 'Buku spiral biru', '2023-12-05', 'expired', 'Cleaning Service', '081234567888', 'expired', NULL, '2024-03-05 00:00:00'),
|
||||||
|
(106, 'Topi Rimba', 1, '/photos/archive6.jpg', 'Mushola', 'Topi rimba warna krem', '2023-12-20', 'claimed', 'Pak Joko', '081234567898', 'case_closed', 7, '2023-12-21 16:00:00'),
|
||||||
|
(107, 'Headset Sony', 4, '/photos/archive7.jpg', 'Lab Komputer', 'Headset kabel hitam', '2023-11-20', 'claimed', 'Admin', '081234567890', 'case_closed', 4, '2023-11-25 11:30:00'),
|
||||||
|
(108, 'Syal Batik', 1, '/photos/archive8.jpg', 'Gedung Rektorat', 'Syal motif batik coklat', '2023-12-25', 'expired', 'Resepsionis', '081234567000', 'expired', NULL, '2024-03-25 00:00:00'),
|
||||||
|
(109, 'Kalkulator Casio', 4, '/photos/archive9.jpg', 'Meja Piket', 'Kalkulator scientific', '2023-12-15', 'claimed', 'Bu Siti', '081234567892', 'case_closed', 8, '2023-12-16 08:00:00'),
|
||||||
|
(110, 'Earphone Bluetooth', 4, '/photos/archive10.jpg', 'Taman Alumni', 'Case putih sebelah kiri saja', '2023-11-30', 'expired', 'Mahasiswa', '081xxx', 'expired', NULL, '2024-02-28 00:00:00');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 12. ATTACHMENTS (Target: 10 Data)
|
||||||
|
-- Menambahkan foto tambahan untuk barang yang ada
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO attachments (item_id, lost_item_id, file_url, file_type, file_size, upload_by_user_id, is_primary) VALUES
|
||||||
|
(1, NULL, '/photos/item2_detail1.jpg', 'jpg', 204800, 2, TRUE),
|
||||||
|
(1, NULL, '/photos/item2_detail2.jpg', 'jpg', 150300, 2, FALSE),
|
||||||
|
(2, NULL, '/photos/item4_zoom.jpg', 'jpg', 180500, 3, TRUE),
|
||||||
|
(3, NULL, '/photos/item6_back.jpg', 'jpg', 220100, 2, TRUE),
|
||||||
|
(4, NULL, '/photos/item7_plug.jpg', 'jpg', 190000, 2, TRUE),
|
||||||
|
(NULL, 1, '/photos/lost_item1_ref.jpg', 'jpg', 300000, 4, TRUE),
|
||||||
|
(NULL, 2, '/photos/lost_item2_ref.jpg', 'jpg', 250000, 5, TRUE),
|
||||||
|
(5, NULL, '/photos/item8_strap.jpg', 'jpg', 120000, 3, FALSE),
|
||||||
|
(6, NULL, '/photos/item9_case.jpg', 'jpg', 140000, 2, FALSE),
|
||||||
|
(10, NULL, '/photos/item13_inside.jpg', 'jpg', 210000, 3, FALSE);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 13. REVISION LOGS (Target: 10 Data)
|
||||||
|
-- Simulasi edit data barang
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value, reason, created_at) VALUES
|
||||||
|
(1, 2, 'location', 'Kelas A200', 'Ruang Kelas A201', 'Koreksi nomor ruangan', '2024-01-16 09:00:00'),
|
||||||
|
(2, 3, 'description', 'Botol minum biru', 'Botol minum biru 500ml', 'Menambah detail ukuran', '2024-01-21 10:00:00'),
|
||||||
|
(3, 2, 'status', 'unclaimed', 'process', 'Ada yang bertanya', '2024-01-26 14:00:00'),
|
||||||
|
(3, 2, 'status', 'process', 'unclaimed', 'Bukan pemiliknya', '2024-01-27 09:00:00'),
|
||||||
|
(4, 2, 'location', 'Perpus', 'Perpustakaan', 'Typo', '2024-02-02 08:30:00'),
|
||||||
|
(5, 3, 'name', 'Jam Tangan', 'Jam Tangan G-Shock', 'Spesifikasi merk', '2024-02-05 12:00:00'),
|
||||||
|
(8, 2, 'description', 'Pensil mekanik', 'Pensil Mekanik Rotring 0.5mm silver', 'Detail tambahan', '2024-02-09 16:00:00'),
|
||||||
|
(10, 3, 'secret_details', 'Ada uang', 'Ada foto keluarga di dalam', 'Update info rahasia', '2024-02-13 11:00:00'),
|
||||||
|
(11, 2, 'photo_url', NULL, '/photos/item14.jpg', 'Foto baru diupload', '2024-02-14 12:00:00'),
|
||||||
|
(1, 2, 'status', 'unclaimed', 'claimed', 'Barang diambil pemilik', '2024-02-15 10:00:00');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 14. AUDIT LOGS (Tambahan 3 Data -> Total 10)
|
||||||
|
-- Menambah log aktivitas sistem
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details) VALUES
|
||||||
|
(1, 'login', 'user', 1, 'Admin login via web'),
|
||||||
|
(2, 'update', 'item', 1, 'Manager updated item location'),
|
||||||
|
(3, 'delete', 'comment', 45, 'Manager removed spam comment');
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 15. CLAIM VERIFICATIONS (Tambahan 3 Data -> Total 10)
|
||||||
|
-- Melengkapi verifikasi untuk Claim ID 1, 2, 3
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO claim_verifications (claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched, verification_method, metadata) VALUES
|
||||||
|
(1, 95.50, '["jam", "tangan", "hitam"]', 'Bukti foto sangat jelas dan cocok', TRUE, 'hybrid', JSON_OBJECT('confidence', 'very_high')),
|
||||||
|
(2, 88.00, '["sweater", "abu", "its"]', 'Ciri-ciri fisik sesuai deskripsi', FALSE, 'manual', JSON_OBJECT('confidence', 'high')),
|
||||||
|
(3, 92.10, '["buku", "kalkulus", "purcell"]', 'Nama di halaman depan sesuai KTM', FALSE, 'manual', JSON_OBJECT('confidence', 'very_high'));
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 16. NOTIFICATIONS (Tambahan 3 Data -> Total 10)
|
||||||
|
-- Notifikasi tambahan
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, channel, is_read) VALUES
|
||||||
|
(4, 'claim_update', 'Status Klaim', 'Klaim Anda sedang diverifikasi', 'claim', 7, 'email', TRUE),
|
||||||
|
(6, 'system_info', 'Maintenance', 'Sistem akan maintenance jam 12 malam', NULL, NULL, 'push', FALSE),
|
||||||
|
(8, 'new_task', 'Verifikasi Baru', 'Ada 3 klaim baru menunggu verifikasi', 'claim', NULL, 'push', FALSE);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 17. MATCH RESULTS (Tambahan 5 Data -> Total 10)
|
||||||
|
-- Matching untuk Lost Items 1, 2, 3, 5, 11
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, match_reason, matched_at, is_notified) VALUES
|
||||||
|
(1, 4, 91.20, '{"name": 90, "category": 100}', 'Charger HP match', '2024-02-02 08:00:00', TRUE),
|
||||||
|
(2, 2, 65.50, '{"category": 100, "color": 50}', 'Possible bottle match', '2024-01-23 09:00:00', FALSE),
|
||||||
|
(3, 1, 89.80, '{"name": 88, "color": 100}', 'Sweater match confirmed', '2024-01-19 10:00:00', TRUE),
|
||||||
|
(5, 13, 75.40, '{"category": 100}', 'Powerbank similar category', '2024-02-16 16:00:00', TRUE),
|
||||||
|
(11, 12, 95.00, '{"name": 95, "color": 100}', 'Topi merah exact match', '2024-02-15 14:00:00', TRUE);
|
||||||
|
|
||||||
|
-- 1. Insert Permissions
|
||||||
|
INSERT INTO permissions (id, slug, name, description) VALUES
|
||||||
|
(1, 'item:read', 'View Items', 'Melihat daftar barang (public/dashboard)'),
|
||||||
|
(2, 'item:create', 'Create Item', 'Melaporkan barang temuan/hilang'),
|
||||||
|
(3, 'item:update', 'Update Item', 'Mengedit data barang'),
|
||||||
|
(4, 'item:delete', 'Delete Item', 'Menghapus data barang'),
|
||||||
|
(5, 'item:verify', 'Verify Item', 'Verifikasi detail barang (lihat detail rahasia)'),
|
||||||
|
(6, 'claim:read', 'View Claims', 'Melihat daftar klaim'),
|
||||||
|
(7, 'claim:create', 'Create Claim', 'Mengajukan klaim barang'),
|
||||||
|
(8, 'claim:approve', 'Approve Claim', 'Menyetujui klaim (verifikasi fisik)'),
|
||||||
|
(9, 'claim:reject', 'Reject Claim', 'Menolak klaim'),
|
||||||
|
(10, 'user:read', 'View Users', 'Melihat daftar pengguna'),
|
||||||
|
(11, 'user:update', 'Update User Role', 'Mengubah role pengguna'),
|
||||||
|
(12, 'user:block', 'Block/Unblock User', 'Memblokir atau membuka blokir user'),
|
||||||
|
(13, 'report:export', 'Export Report', 'Export laporan ke PDF/Excel'),
|
||||||
|
(14, 'audit:read', 'View Audit Log', 'Melihat log aktivitas sistem'),
|
||||||
|
(15, 'category:manage', 'Manage Categories', 'Membuat, edit, hapus kategori');
|
||||||
|
|
||||||
|
-- 2. Assign Permissions to Roles
|
||||||
|
-- Role ID 1: Admin (All Permissions)
|
||||||
|
-- A. ADMIN (Role ID: 1) - Punya SEMUA (1-15)
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT 1, id FROM permissions;
|
||||||
|
|
||||||
|
-- B. MANAGER (Role ID: 2) - Operasional
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(2, 1), (2, 3), (2, 5), (2, 6), (2, 8), (2, 9), (2, 10), (2, 13);
|
||||||
|
|
||||||
|
-- C. USER (Role ID: 3) - Dasar
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(3, 1), (3, 2), (3, 6), (3, 7);
|
||||||
|
|
||||||
|
-- 1. Tambahkan permission baru khusus untuk Laporan Kehilangan (Lost Item)
|
||||||
|
-- Melanjutkan ID terakhir dari seed2.sql (ID 15)
|
||||||
|
INSERT INTO permissions (id, slug, name, description) VALUES
|
||||||
|
(16, 'lost_item:update', 'Update Lost Item', 'Mengedit laporan kehilangan (Admin/Manager)'),
|
||||||
|
(17, 'lost_item:delete', 'Delete Lost Item', 'Menghapus laporan kehilangan (Admin/Manager)');
|
||||||
|
|
||||||
|
-- 2. Berikan akses ke ADMIN (Role ID: 1)
|
||||||
|
-- Admin harus punya semua akses
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(1, 16),
|
||||||
|
(1, 17);
|
||||||
|
|
||||||
|
-- 3. Berikan akses ke MANAGER (Role ID: 2)
|
||||||
|
-- Sesuai permintaan: akses edit dan hapus laporan barang hilang
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(2, 16), -- Akses Edit Lost Item
|
||||||
|
(2, 17); -- Akses Hapus Lost Item
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SUCCESS MESSAGE - UPDATED
|
||||||
|
-- ============================================
|
||||||
|
SELECT '✅ Extended database seed completed!' AS Status;
|
||||||
|
SELECT '🎭 Roles: 3 roles (admin, manager, user) - NO CHANGES' AS Info;
|
||||||
|
SELECT '📂 Categories: 6 categories (Pakaian, Alat Makan, Aksesoris, Elektronik, Alat Tulis, Lainnya) - NO CHANGES' AS Info;
|
||||||
|
SELECT '👥 Users: 10 total (1 admin, 3 managers, 6 students) (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '📦 Items: 19 found items (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '📝 Lost items: 11 reports (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '🎫 Claims: 11 claims - 4 approved, 4 pending, 1 rejected (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '✔️ Claim verifications: 10 records (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '🔗 Match results: 10 matches (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '🔔 Notifications: 17 notifications (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT '📋 Audit logs: 28+ records (EXTENDED ✓)' AS Info;
|
||||||
|
SELECT 'Additional information' AS Info;
|
||||||
|
SELECT '✨ All main tables now have 10+ meaningful records!' AS Complete;
|
||||||
|
SELECT '🚀 Database is fully populated and ready!' AS Ready;
|
||||||
71
database/seed2.sql
Normal file
71
database/seed2.sql
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
-- seed2.sql
|
||||||
|
-- 1. ROLES & CATEGORIES
|
||||||
|
INSERT INTO roles (id, name, description) VALUES
|
||||||
|
(1, 'admin', 'Administrator with full access'),
|
||||||
|
(2, 'manager', 'Manager for verification and approval'),
|
||||||
|
(3, 'user', 'Regular user (student)');
|
||||||
|
INSERT INTO categories (id, name, slug, description) VALUES
|
||||||
|
(1, 'Pakaian', 'pakaian', 'Baju, celana, jaket, dll'),
|
||||||
|
(2, 'Alat Makan', 'alat-makan', 'Botol, tupperware, dll'),
|
||||||
|
(3, 'Aksesoris', 'aksesoris', 'Jam tangan, kacamata, perhiasan'),
|
||||||
|
(4, 'Elektronik', 'elektronik', 'HP, laptop, charger, dll'),
|
||||||
|
(5, 'Alat Tulis', 'alat-tulis', 'Pulpen, buku, pensil, dll'),
|
||||||
|
(6, 'Lainnya', 'lainnya', 'Barang lain yang tidak masuk kategori');
|
||||||
|
-- 2. USERS (Explicit IDs 1-10)
|
||||||
|
INSERT INTO users (id, name, email, password, nrp, phone, role_id, status, last_login) VALUES
|
||||||
|
(1, 'Admin', 'admin@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '0001', '081234567890', 1, 'active', NULL),
|
||||||
|
(2, 'Pak Budi', 'manager1@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567890', '081234567891', 2, 'active', NULL),
|
||||||
|
(3, 'Bu Siti', 'manager2@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567891', '081234567892', 2, 'active', NULL),
|
||||||
|
(4, 'Ahmad Rizki', 'ahmad@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211004', '081234567893', 3, 'active', '2024-02-12 09:15:00'),
|
||||||
|
(5, 'Siti Nurhaliza', 'siti@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211005', '081234567894', 3, 'active', '2024-02-11 14:20:00'),
|
||||||
|
(6, 'Budi Santoso', 'budi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211008', '081234567895', 3, 'active', '2024-02-08 16:30:00'),
|
||||||
|
(7, 'Dewi Lestari', 'dewi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211009', '081234567896', 3, 'active', '2024-01-15 11:00:00'),
|
||||||
|
(8, 'Pak Joko', 'manager3@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567892', '081234567898', 2, 'active', '2024-02-11 08:30:00'),
|
||||||
|
(9, 'Rina Melati', 'rina@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211006', '081234567899', 3, 'active', '2024-02-10 13:20:00'),
|
||||||
|
(10, 'Fajar Ramadhan', 'fajar@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211007', '081234567800', 3, 'active', '2024-02-09 10:45:00');
|
||||||
|
-- 1. Insert Permissions
|
||||||
|
INSERT INTO permissions (id, slug, name, description) VALUES
|
||||||
|
(1, 'item:read', 'View Items', 'Melihat daftar barang (public/dashboard)'),
|
||||||
|
(2, 'item:create', 'Create Item', 'Melaporkan barang temuan/hilang'),
|
||||||
|
(3, 'item:update', 'Update Item', 'Mengedit data barang'),
|
||||||
|
(4, 'item:delete', 'Delete Item', 'Menghapus data barang'),
|
||||||
|
(5, 'item:verify', 'Verify Item', 'Verifikasi detail barang (lihat detail rahasia)'),
|
||||||
|
(6, 'claim:read', 'View Claims', 'Melihat daftar klaim'),
|
||||||
|
(7, 'claim:create', 'Create Claim', 'Mengajukan klaim barang'),
|
||||||
|
(8, 'claim:approve', 'Approve Claim', 'Menyetujui klaim (verifikasi fisik)'),
|
||||||
|
(9, 'claim:reject', 'Reject Claim', 'Menolak klaim'),
|
||||||
|
(10, 'user:read', 'View Users', 'Melihat daftar pengguna'),
|
||||||
|
(11, 'user:update', 'Update User Role', 'Mengubah role pengguna'),
|
||||||
|
(12, 'user:block', 'Block/Unblock User', 'Memblokir atau membuka blokir user'),
|
||||||
|
(13, 'report:export', 'Export Report', 'Export laporan ke PDF/Excel'),
|
||||||
|
(14, 'audit:read', 'View Audit Log', 'Melihat log aktivitas sistem'),
|
||||||
|
(15, 'category:manage', 'Manage Categories', 'Membuat, edit, hapus kategori');
|
||||||
|
-- 2. Assign Permissions to Roles
|
||||||
|
-- Role ID 1: Admin (All Permissions)
|
||||||
|
-- A. ADMIN (Role ID: 1) - Punya SEMUA (1-15)
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT 1, id FROM permissions;
|
||||||
|
-- B. MANAGER (Role ID: 2) - Operasional
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(2, 1), (2, 3), (2, 5), (2, 6), (2, 8), (2, 9), (2, 10), (2, 13);
|
||||||
|
-- C. USER (Role ID: 3) - Dasar
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(3, 1), (3, 2), (3, 6), (3, 7);
|
||||||
|
|
||||||
|
-- 1. Tambahkan permission baru khusus untuk Laporan Kehilangan (Lost Item)
|
||||||
|
-- Melanjutkan ID terakhir dari seed2.sql (ID 15)
|
||||||
|
INSERT INTO permissions (id, slug, name, description) VALUES
|
||||||
|
(16, 'lost_item:update', 'Update Lost Item', 'Mengedit laporan kehilangan (Admin/Manager)'),
|
||||||
|
(17, 'lost_item:delete', 'Delete Lost Item', 'Menghapus laporan kehilangan (Admin/Manager)');
|
||||||
|
|
||||||
|
-- 2. Berikan akses ke ADMIN (Role ID: 1)
|
||||||
|
-- Admin harus punya semua akses
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(1, 16),
|
||||||
|
(1, 17);
|
||||||
|
|
||||||
|
-- 3. Berikan akses ke MANAGER (Role ID: 2)
|
||||||
|
-- Sesuai permintaan: akses edit dan hapus laporan barang hilang
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
(2, 16), -- Akses Edit Lost Item
|
||||||
|
(2, 17); -- Akses Hapus Lost Item
|
||||||
68
go.mod
Normal file
68
go.mod
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// go.mod
|
||||||
|
module lost-and-found
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
|
golang.org/x/crypto v0.44.0
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
|
golang.org/x/net v0.47.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
155
go.sum
Normal file
155
go.sum
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||||
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
|
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
89
internal/config/config.go
Normal file
89
internal/config/config.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Database DatabaseConfig
|
||||||
|
JWT JWTConfig
|
||||||
|
Server ServerConfig
|
||||||
|
Groq GroqConfig // NEW: Groq configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port string
|
||||||
|
Environment string
|
||||||
|
UploadPath string
|
||||||
|
MaxUploadSize int64
|
||||||
|
AllowedOrigins []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Groq configuration struct
|
||||||
|
type GroqConfig struct {
|
||||||
|
APIKey string
|
||||||
|
DefaultModel string
|
||||||
|
MaxTokens int
|
||||||
|
Temperature float64
|
||||||
|
TopP float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Database: GetDatabaseConfig(),
|
||||||
|
JWT: GetJWTConfig(),
|
||||||
|
Server: GetServerConfig(),
|
||||||
|
Groq: GetGroqConfig(), // NEW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetServerConfig() ServerConfig {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
env := os.Getenv("ENVIRONMENT")
|
||||||
|
if env == "" {
|
||||||
|
env = "development"
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadPath := os.Getenv("UPLOAD_PATH")
|
||||||
|
if uploadPath == "" {
|
||||||
|
uploadPath = "./uploads"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ServerConfig{
|
||||||
|
Port: port,
|
||||||
|
Environment: env,
|
||||||
|
UploadPath: uploadPath,
|
||||||
|
MaxUploadSize: 10 * 1024 * 1024,
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Get Groq configuration from environment
|
||||||
|
func GetGroqConfig() GroqConfig {
|
||||||
|
apiKey := os.Getenv("GROQ_API_KEY")
|
||||||
|
|
||||||
|
model := os.Getenv("GROQ_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
model = "llama-3.3-70b-versatile" // Default to best model
|
||||||
|
}
|
||||||
|
|
||||||
|
return GroqConfig{
|
||||||
|
APIKey: apiKey,
|
||||||
|
DefaultModel: model,
|
||||||
|
MaxTokens: 1024,
|
||||||
|
Temperature: 0.7,
|
||||||
|
TopP: 0.95,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsProduction() bool {
|
||||||
|
return os.Getenv("ENVIRONMENT") == "production"
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsDevelopment() bool {
|
||||||
|
return os.Getenv("ENVIRONMENT") != "production"
|
||||||
|
}
|
||||||
333
internal/config/database.go
Normal file
333
internal/config/database.go
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *gorm.DB
|
||||||
|
|
||||||
|
// DatabaseConfig holds database connection configuration
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
DBName string
|
||||||
|
Charset string
|
||||||
|
ParseTime string
|
||||||
|
Loc string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabaseConfig returns database configuration from environment
|
||||||
|
func GetDatabaseConfig() DatabaseConfig {
|
||||||
|
return DatabaseConfig{
|
||||||
|
Host: getEnv("DB_HOST", ""),
|
||||||
|
Port: getEnv("DB_PORT", ""),
|
||||||
|
User: getEnv("DB_USER", ""),
|
||||||
|
Password: getEnv("DB_PASSWORD", ""),
|
||||||
|
DBName: getEnv("DB_NAME", ""),
|
||||||
|
Charset: getEnv("DB_CHARSET", "utf8mb4"),
|
||||||
|
ParseTime: getEnv("DB_PARSE_TIME", "True"),
|
||||||
|
Loc: getEnv("DB_LOC", "Local"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDB initializes database connection
|
||||||
|
func InitDB() error {
|
||||||
|
config := GetDatabaseConfig()
|
||||||
|
|
||||||
|
// Step 1: Connect to MySQL without specifying database (to create if not exists)
|
||||||
|
if err := ensureDatabaseExists(config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Connect to the specific database
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s&multiStatements=true",
|
||||||
|
config.User,
|
||||||
|
config.Password,
|
||||||
|
config.Host,
|
||||||
|
config.Port,
|
||||||
|
config.DBName,
|
||||||
|
config.Charset,
|
||||||
|
config.ParseTime,
|
||||||
|
config.Loc,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure GORM logger
|
||||||
|
gormLogger := logger.Default
|
||||||
|
if IsDevelopment() {
|
||||||
|
gormLogger = logger.Default.LogMode(logger.Info)
|
||||||
|
} else {
|
||||||
|
gormLogger = logger.Default.LogMode(logger.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database connection
|
||||||
|
var err error
|
||||||
|
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: gormLogger,
|
||||||
|
NowFunc: func() time.Time {
|
||||||
|
return time.Now().Local()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get underlying SQL database
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connection pool settings
|
||||||
|
sqlDB.SetMaxIdleConns(10)
|
||||||
|
sqlDB.SetMaxOpenConns(100)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("✅ Database connected successfully")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDatabaseExists checks if database exists, creates it if not
|
||||||
|
func ensureDatabaseExists(config DatabaseConfig) error {
|
||||||
|
// Connect to MySQL without specifying a database
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"%s:%s@tcp(%s:%s)/?charset=%s&parseTime=%s&loc=%s",
|
||||||
|
config.User,
|
||||||
|
config.Password,
|
||||||
|
config.Host,
|
||||||
|
config.Port,
|
||||||
|
config.Charset,
|
||||||
|
config.ParseTime,
|
||||||
|
config.Loc,
|
||||||
|
)
|
||||||
|
|
||||||
|
tempDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to MySQL server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🔍 Checking if database '%s' exists...", config.DBName)
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
var dbExists int64
|
||||||
|
if err := tempDB.Raw(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?",
|
||||||
|
config.DBName,
|
||||||
|
).Scan(&dbExists).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check database existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbExists == 0 {
|
||||||
|
log.Printf("📝 Creating database '%s'...", config.DBName)
|
||||||
|
createSQL := fmt.Sprintf(
|
||||||
|
"CREATE DATABASE IF NOT EXISTS %s CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||||
|
config.DBName,
|
||||||
|
)
|
||||||
|
if err := tempDB.Exec(createSQL).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create database: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("✅ Database '%s' created successfully", config.DBName)
|
||||||
|
} else {
|
||||||
|
log.Printf("✅ Database '%s' already exists", config.DBName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close temporary connection
|
||||||
|
sqlDB, _ := tempDB.DB()
|
||||||
|
sqlDB.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDB returns the database instance
|
||||||
|
func GetDB() *gorm.DB {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations runs database migrations from SQL files
|
||||||
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
log.Println("📊 Starting database migrations...")
|
||||||
|
|
||||||
|
// Check if tables already exist
|
||||||
|
if db.Migrator().HasTable(&models.Role{}) {
|
||||||
|
log.Println("✅ Database tables already exist, skipping migration")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("📋 Tables not found, running migration scripts...")
|
||||||
|
|
||||||
|
// Step 1: Run schema.sql
|
||||||
|
if err := runSQLFile(db, "database/schema.sql"); err != nil {
|
||||||
|
return fmt.Errorf("❌ Failed to run schema.sql: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("✅ Schema created successfully")
|
||||||
|
|
||||||
|
// Step 2: Run seed.sql
|
||||||
|
if err := runSQLFile(db, "database/seed.sql"); err != nil {
|
||||||
|
return fmt.Errorf("❌ Failed to run seed.sql: %w", err)
|
||||||
|
}
|
||||||
|
log.Println("✅ Seed data inserted successfully")
|
||||||
|
|
||||||
|
// Step 3: Run enhancement.sql (optional - for triggers, procedures, etc)
|
||||||
|
if err := runSQLFile(db, "database/enhancement.sql"); err != nil {
|
||||||
|
log.Printf("⚠️ Warning: Failed to run enhancement.sql: %v", err)
|
||||||
|
log.Println("💡 Enhancement features (triggers, procedures) may not be available")
|
||||||
|
} else {
|
||||||
|
log.Println("✅ Enhancement features loaded successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("🎉 Database migration completed!")
|
||||||
|
log.Println("🔧 Default admin: admin@lostandfound.com / password123")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSQLFile executes SQL from file
|
||||||
|
func runSQLFile(db *gorm.DB, filepath string) error {
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("file not found: %s", filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📄 Reading SQL file: %s", filepath)
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
content, err := os.ReadFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split SQL content by delimiter
|
||||||
|
sqlContent := string(content)
|
||||||
|
|
||||||
|
// Remove comments and empty lines
|
||||||
|
sqlContent = removeComments(sqlContent)
|
||||||
|
|
||||||
|
// Split by DELIMITER if exists (for procedures/triggers)
|
||||||
|
if strings.Contains(sqlContent, "DELIMITER") {
|
||||||
|
return executeSQLWithDelimiter(db, sqlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute SQL normally
|
||||||
|
if err := db.Exec(sqlContent).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to execute SQL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ SQL file executed: %s", filepath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeSQLWithDelimiter handles SQL with custom delimiters (for procedures/triggers)
|
||||||
|
func executeSQLWithDelimiter(db *gorm.DB, content string) error {
|
||||||
|
// Split by DELIMITER changes
|
||||||
|
parts := strings.Split(content, "DELIMITER")
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" || part == "$$" || part == ";" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the delimiter declaration line
|
||||||
|
lines := strings.Split(part, "\n")
|
||||||
|
if len(lines) > 0 && (strings.HasPrefix(lines[0], "$$") || strings.HasPrefix(lines[0], ";")) {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
part = strings.Join(lines, "\n")
|
||||||
|
|
||||||
|
// Split by custom delimiter ($$)
|
||||||
|
if i%2 == 1 { // Odd parts use $$ delimiter
|
||||||
|
statements := strings.Split(part, "$$")
|
||||||
|
for _, stmt := range statements {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" || stmt == ";" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
log.Printf("⚠️ Warning executing statement: %v", err)
|
||||||
|
// Don't fail on enhancement errors (triggers, procedures)
|
||||||
|
// These might fail if they already exist or MySQL version issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Even parts use ; delimiter
|
||||||
|
statements := strings.Split(part, ";")
|
||||||
|
for _, stmt := range statements {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to execute statement: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeComments removes SQL comments from content
|
||||||
|
func removeComments(sql string) string {
|
||||||
|
lines := strings.Split(sql, "\n")
|
||||||
|
var cleaned []string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Skip empty lines
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip single-line comments
|
||||||
|
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the line
|
||||||
|
cleaned = append(cleaned, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(cleaned, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseDB closes database connection
|
||||||
|
func CloseDB() error {
|
||||||
|
if db != nil {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get environment variable with default value
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
132
internal/config/jwt.go
Normal file
132
internal/config/jwt.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
// internal/config/jwt.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtConfig *JWTConfig
|
||||||
|
|
||||||
|
// JWTConfig holds JWT configuration
|
||||||
|
type JWTConfig struct {
|
||||||
|
SecretKey string
|
||||||
|
ExpirationHours int
|
||||||
|
Issuer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTClaims represents the JWT claims
|
||||||
|
type JWTClaims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitJWT initializes JWT configuration
|
||||||
|
func InitJWT() {
|
||||||
|
secretKey := os.Getenv("JWT_SECRET_KEY")
|
||||||
|
if secretKey == "" {
|
||||||
|
secretKey = "your-secret-key-change-this-in-production" // Default for development
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtConfig = &JWTConfig{
|
||||||
|
SecretKey: secretKey,
|
||||||
|
ExpirationHours: 24 * 7, // 7 days
|
||||||
|
Issuer: "lost-and-found-system",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJWTConfig returns JWT configuration
|
||||||
|
func GetJWTConfig() JWTConfig {
|
||||||
|
if jwtConfig == nil {
|
||||||
|
InitJWT()
|
||||||
|
}
|
||||||
|
return *jwtConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken generates a new JWT token for a user
|
||||||
|
func GenerateToken(userID uint, email, role string) (string, error) {
|
||||||
|
config := GetJWTConfig()
|
||||||
|
|
||||||
|
// Create claims
|
||||||
|
claims := JWTClaims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(config.ExpirationHours))),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: config.Issuer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
// Sign token with secret key
|
||||||
|
tokenString, err := token.SignedString([]byte(config.SecretKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token and returns the claims
|
||||||
|
func ValidateToken(tokenString string) (*JWTClaims, error) {
|
||||||
|
config := GetJWTConfig()
|
||||||
|
|
||||||
|
// Parse token
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Validate signing method
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("invalid signing method")
|
||||||
|
}
|
||||||
|
return []byte(config.SecretKey), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken generates a new token with extended expiration
|
||||||
|
func RefreshToken(oldTokenString string) (string, error) {
|
||||||
|
// Validate old token
|
||||||
|
claims, err := ValidateToken(oldTokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token with same user info
|
||||||
|
return GenerateToken(claims.UserID, claims.Email, claims.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractUserID extracts user ID from token string
|
||||||
|
func ExtractUserID(tokenString string) (uint, error) {
|
||||||
|
claims, err := ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return claims.UserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractRole extracts role from token string
|
||||||
|
func ExtractRole(tokenString string) (string, error) {
|
||||||
|
claims, err := ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return claims.Role, nil
|
||||||
|
}
|
||||||
303
internal/controllers/admin_controller.go
Normal file
303
internal/controllers/admin_controller.go
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
// internal/controllers/admin_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminController struct {
|
||||||
|
DB *gorm.DB // ✅ DITAMBAHKAN: Untuk akses transaksi manual (Begin/Commit)
|
||||||
|
userService *services.UserService
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
claimRepo *repositories.ClaimRepository
|
||||||
|
archiveService *services.ArchiveService // ✅ Service Archive
|
||||||
|
auditService *services.AuditService
|
||||||
|
dashboardService *services.DashboardService
|
||||||
|
itemService *services.ItemService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminController(db *gorm.DB) *AdminController {
|
||||||
|
return &AdminController{
|
||||||
|
DB: db, // ✅ Simpan koneksi DB
|
||||||
|
userService: services.NewUserService(db),
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
claimRepo: repositories.NewClaimRepository(db),
|
||||||
|
archiveService: services.NewArchiveService(db),
|
||||||
|
auditService: services.NewAuditService(db),
|
||||||
|
dashboardService: services.NewDashboardService(db),
|
||||||
|
itemService: services.NewItemService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardStats - ✅ MENGGUNAKAN VIEW vw_dashboard_stats
|
||||||
|
// GET /api/admin/dashboard
|
||||||
|
func (c *AdminController) GetDashboardStats(ctx *gin.Context) {
|
||||||
|
// 1. Ambil stats dasar dari View
|
||||||
|
dashStats, err := c.dashboardService.GetDashboardStats()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get dashboard stats", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hitung Total User
|
||||||
|
allUsers, totalUsers, _ := c.userService.GetAllUsers(1, 10000)
|
||||||
|
|
||||||
|
// 3. Hitung Total Items
|
||||||
|
totalItems, _ := c.itemRepo.CountAll()
|
||||||
|
|
||||||
|
// 4. Hitung Total Claims
|
||||||
|
totalClaims, _ := c.claimRepo.CountAll()
|
||||||
|
|
||||||
|
// 5. Ambil Statistik Arsip
|
||||||
|
archiveStats, err := c.archiveService.GetArchiveStats()
|
||||||
|
var totalArchives int64 = 0
|
||||||
|
|
||||||
|
// Type assertion yang aman
|
||||||
|
if err == nil {
|
||||||
|
if val, ok := archiveStats["total"]; ok {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case int:
|
||||||
|
totalArchives = int64(v)
|
||||||
|
case int64:
|
||||||
|
totalArchives = v
|
||||||
|
case float64:
|
||||||
|
totalArchives = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Ambil Category Stats
|
||||||
|
categoryStats, _ := c.dashboardService.GetCategoryStats()
|
||||||
|
|
||||||
|
_, totalAuditLogs, _ := c.auditService.GetAllAuditLogs(1, 1, "", "", nil)
|
||||||
|
|
||||||
|
// 7. Susun Response
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"total": totalItems,
|
||||||
|
"unclaimed": dashStats.TotalUnclaimed,
|
||||||
|
"verified": dashStats.TotalVerified,
|
||||||
|
},
|
||||||
|
"claims": map[string]interface{}{
|
||||||
|
"total": totalClaims,
|
||||||
|
"pending": dashStats.PendingClaims,
|
||||||
|
},
|
||||||
|
"archives": map[string]interface{}{
|
||||||
|
"total": totalArchives,
|
||||||
|
},
|
||||||
|
"lost_items": map[string]interface{}{
|
||||||
|
"total": dashStats.TotalLostReports,
|
||||||
|
},
|
||||||
|
"matches": map[string]interface{}{
|
||||||
|
"unnotified": dashStats.UnnotifiedMatches,
|
||||||
|
},
|
||||||
|
"users": map[string]interface{}{
|
||||||
|
"total": totalUsers,
|
||||||
|
"count": len(allUsers),
|
||||||
|
},
|
||||||
|
"categories": categoryStats,
|
||||||
|
"audit_logs": map[string]interface{}{
|
||||||
|
"total": totalAuditLogs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats retrieved", stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogs gets audit logs (admin only)
|
||||||
|
// GET /api/admin/audit-logs
|
||||||
|
func (c *AdminController) GetAuditLogs(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
|
||||||
|
action := ctx.Query("action")
|
||||||
|
entityType := ctx.Query("entity_type")
|
||||||
|
|
||||||
|
var userID *uint
|
||||||
|
if userIDStr := ctx.Query("user_id"); userIDStr != "" {
|
||||||
|
id, _ := strconv.ParseUint(userIDStr, 10, 32)
|
||||||
|
userID = new(uint)
|
||||||
|
*userID = uint(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, total, err := c.auditService.GetAllAuditLogs(page, limit, action, entityType, userID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get audit logs", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Audit logs retrieved", logs, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemsDetail - Get Items with Details (PAKAI VIEW)
|
||||||
|
// GET /api/admin/items-detail
|
||||||
|
func (c *AdminController) GetItemsDetail(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
status := ctx.Query("status")
|
||||||
|
|
||||||
|
items, total, err := c.dashboardService.GetItemsWithDetails(page, limit, status)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items detail", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items detail retrieved", items, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OPSI 1: ARSIP VIA STORED PROCEDURE (Database Logic)
|
||||||
|
// =========================================================================
|
||||||
|
// TriggerAutoArchive manually triggers the cleanup procedure
|
||||||
|
// POST /api/admin/archive/trigger
|
||||||
|
func (c *AdminController) TriggerAutoArchive(ctx *gin.Context) {
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// Memanggil service yang menjalankan RAW SQL "CALL sp_archive_expired_items()"
|
||||||
|
count, err := c.itemService.RunAutoArchive(ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to run archive procedure", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Auto-archive completed", gin.H{
|
||||||
|
"archived_count": count,
|
||||||
|
"method": "Stored Procedure (Database)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// OPSI 2: ARSIP (Golang Logic - Begin/Commit)
|
||||||
|
// =========================================================================
|
||||||
|
// ArchiveExpired melakukan arsip menggunakan Begin/Commit/Rollback di Golang
|
||||||
|
// POST /api/admin/archive/manual-transaction
|
||||||
|
func (c *AdminController) ArchiveExpiredItemsManual(ctx *gin.Context) {
|
||||||
|
// 1. BEGIN: Memulai Transaksi
|
||||||
|
tx := c.DB.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to start transaction", tx.Error.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: Defer Rollback (Jaga-jaga jika aplikasi crash/panic)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
copyQuery := `
|
||||||
|
INSERT INTO archives (item_id, name, description, category, found_location, found_date, image_url, finder_id)
|
||||||
|
SELECT id, name, description, category, found_location, found_date, image_url, user_id
|
||||||
|
FROM items
|
||||||
|
WHERE expires_at < NOW() AND status = 'unclaimed'
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := tx.Exec(copyQuery).Error; err != nil {
|
||||||
|
tx.Rollback() // ❌ ROLLBACK JIKA GAGAL COPY
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not copy to archives", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LANGKAH 2: Update status barang di tabel items
|
||||||
|
updateQuery := `
|
||||||
|
UPDATE items
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE expires_at < NOW() AND status = 'unclaimed'
|
||||||
|
`
|
||||||
|
|
||||||
|
result := tx.Exec(updateQuery)
|
||||||
|
if result.Error != nil {
|
||||||
|
tx.Rollback() // ❌ ROLLBACK JIKA GAGAL UPDATE (Data di archives juga akan batal masuk)
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not update item status", result.Error.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hitung berapa baris yang berubah
|
||||||
|
itemsArchived := result.RowsAffected
|
||||||
|
|
||||||
|
// 4. COMMIT: Simpan Perubahan Permanen
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Transaction Failed: Could not commit changes", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sukses
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Manual Archive Transaction Successful", gin.H{
|
||||||
|
"archived_count": itemsArchived,
|
||||||
|
"method": "Go Transaction (Begin/Commit/Rollback)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFastDashboardStats uses Stored Procedure instead of View/Count
|
||||||
|
// GET /api/admin/dashboard/fast
|
||||||
|
func (c *AdminController) GetFastDashboardStats(ctx *gin.Context) {
|
||||||
|
stats, err := c.dashboardService.GetStatsFromSP()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats from SP", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats (SP) retrieved", stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaimsDetail - Get Claims with Details (PAKAI VIEW)
|
||||||
|
// GET /api/admin/claims-detail
|
||||||
|
func (c *AdminController) GetClaimsDetail(ctx *gin.Context) {
|
||||||
|
status := ctx.Query("status")
|
||||||
|
|
||||||
|
claims, err := c.dashboardService.GetClaimsWithDetails(status)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims detail", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Claims detail retrieved", claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchesDetail - Get Match Results with Details (PAKAI VIEW)
|
||||||
|
// GET /api/admin/matches-detail
|
||||||
|
func (c *AdminController) GetMatchesDetail(ctx *gin.Context) {
|
||||||
|
minScore, _ := strconv.ParseFloat(ctx.DefaultQuery("min_score", "0"), 64)
|
||||||
|
|
||||||
|
matches, err := c.dashboardService.GetMatchesWithDetails(minScore)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches detail", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Matches detail retrieved", matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserActivity - Get User Activity (PAKAI VIEW)
|
||||||
|
// GET /api/admin/user-activity
|
||||||
|
func (c *AdminController) GetUserActivity(ctx *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
|
||||||
|
|
||||||
|
activities, err := c.dashboardService.GetUserActivity(limit)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get user activity", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User activity retrieved", activities)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentActivities - Get Recent Activities (PAKAI VIEW vw_recent_activities)
|
||||||
|
// GET /api/admin/recent-activities
|
||||||
|
func (c *AdminController) GetRecentActivities(ctx *gin.Context) {
|
||||||
|
activities, err := c.dashboardService.GetRecentActivities()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get recent activities", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Recent activities retrieved", activities)
|
||||||
|
}
|
||||||
249
internal/controllers/ai_controller.go
Normal file
249
internal/controllers/ai_controller.go
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrontendMessage struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIController struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAIController(db *gorm.DB) *AIController {
|
||||||
|
return &AIController{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groq API structures
|
||||||
|
type GroqMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroqRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []GroqMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
TopP float64 `json:"top_p,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroqResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Choices []struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AIController) Chat(ctx *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
History []FrontendMessage `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Groq API key from environment
|
||||||
|
apiKey := os.Getenv("GROQ_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Groq API key not configured", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get model from environment or use default
|
||||||
|
model := os.Getenv("GROQ_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
model = "llama-3.3-70b-versatile" // Default to best model
|
||||||
|
}
|
||||||
|
|
||||||
|
// System prompt for Lost & Found context
|
||||||
|
systemPrompt := `Kamu adalah AI Assistant untuk sistem Lost & Found (Barang Hilang & Temuan) bernama "FindItBot".
|
||||||
|
|
||||||
|
Konteks Sistem:
|
||||||
|
- Sistem ini membantu mahasiswa dan staff melaporkan barang hilang dan menemukan barang
|
||||||
|
- User bisa melaporkan barang hilang mereka
|
||||||
|
- User bisa melaporkan barang yang mereka temukan
|
||||||
|
- User bisa klaim barang yang hilang
|
||||||
|
- Manager dan Admin memverifikasi klaim
|
||||||
|
|
||||||
|
Tugasmu:
|
||||||
|
1. 🔍 Jawab pertanyaan tentang cara menggunakan sistem
|
||||||
|
2. 📝 Bantu user memahami proses pelaporan dan klaim
|
||||||
|
3. ✅ Berikan informasi yang jelas dan membantu
|
||||||
|
4. 💬 Gunakan bahasa Indonesia yang ramah dan profesional
|
||||||
|
5. 🎯 Fokus pada solusi praktis
|
||||||
|
|
||||||
|
Panduan Respons:
|
||||||
|
- Gunakan emoji yang relevan
|
||||||
|
- Jawab dengan singkat tapi lengkap
|
||||||
|
- Jika user mencari barang, tanyakan detail spesifik
|
||||||
|
- Jika user ingin lapor kehilangan, tanyakan: nama barang, kategori, lokasi, tanggal hilang
|
||||||
|
- Jika user ingin klaim, jelaskan proses verifikasi
|
||||||
|
|
||||||
|
Jawab dengan helpful dan supportive!`
|
||||||
|
|
||||||
|
// Build messages array for Groq
|
||||||
|
groqMessages := []GroqMessage{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: systemPrompt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation history
|
||||||
|
for _, msg := range request.History {
|
||||||
|
role := "user"
|
||||||
|
if msg.Sender == "ai" {
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
groqMessages = append(groqMessages, GroqMessage{
|
||||||
|
Role: role,
|
||||||
|
Content: msg.Text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current user message
|
||||||
|
groqMessages = append(groqMessages, GroqMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: request.Message,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prepare Groq request
|
||||||
|
groqReq := GroqRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: groqMessages,
|
||||||
|
Temperature: 0.7,
|
||||||
|
MaxTokens: 1024,
|
||||||
|
TopP: 0.95,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make API call to Groq
|
||||||
|
groqURL := "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(groqReq)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to prepare request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", groqURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to create request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to call Groq API", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to read response", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Groq API error (status %d)", resp.StatusCode), string(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groqResp GroqResponse
|
||||||
|
if err := json.Unmarshal(body, &groqResp); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to parse response", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract AI response
|
||||||
|
aiResponse := "Maaf, tidak dapat memproses permintaan Anda."
|
||||||
|
if len(groqResp.Choices) > 0 {
|
||||||
|
aiResponse = groqResp.Choices[0].Message.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
response := AIMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: aiResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "AI response generated", gin.H{
|
||||||
|
"message": response,
|
||||||
|
"response": aiResponse,
|
||||||
|
"model": model,
|
||||||
|
"usage": gin.H{
|
||||||
|
"prompt_tokens": groqResp.Usage.PromptTokens,
|
||||||
|
"completion_tokens": groqResp.Usage.CompletionTokens,
|
||||||
|
"total_tokens": groqResp.Usage.TotalTokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AIController) GetHistory(ctx *gin.Context) {
|
||||||
|
userID, exists := ctx.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not authenticated", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual history retrieval from database
|
||||||
|
history := []AIMessage{}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Chat history retrieved", gin.H{
|
||||||
|
"user_id": userID,
|
||||||
|
"history": history,
|
||||||
|
"count": len(history),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AIController) ClearHistory(ctx *gin.Context) {
|
||||||
|
userID, exists := ctx.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not authenticated", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual history clearing from database
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Chat history cleared", gin.H{
|
||||||
|
"user_id": userID,
|
||||||
|
"message": "History successfully deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
69
internal/controllers/archive_controller.go
Normal file
69
internal/controllers/archive_controller.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// internal/controllers/archive_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveController struct {
|
||||||
|
archiveService *services.ArchiveService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArchiveController(db *gorm.DB) *ArchiveController {
|
||||||
|
return &ArchiveController{
|
||||||
|
archiveService: services.NewArchiveService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllArchives gets all archived items
|
||||||
|
// GET /api/archives
|
||||||
|
func (c *ArchiveController) GetAllArchives(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
reason := ctx.Query("reason")
|
||||||
|
search := ctx.Query("search")
|
||||||
|
|
||||||
|
archives, total, err := c.archiveService.GetAllArchives(page, limit, reason, search)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get archives", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Archives retrieved", archives, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveByID gets archive by ID
|
||||||
|
// GET /api/archives/:id
|
||||||
|
func (c *ArchiveController) GetArchiveByID(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid archive ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := c.archiveService.GetArchiveByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Archive not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Archive retrieved", archive.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveStats gets archive statistics
|
||||||
|
// GET /api/archives/stats
|
||||||
|
func (c *ArchiveController) GetArchiveStats(ctx *gin.Context) {
|
||||||
|
stats, err := c.archiveService.GetArchiveStats()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Archive stats retrieved", stats)
|
||||||
|
}
|
||||||
104
internal/controllers/auth_controller.go
Normal file
104
internal/controllers/auth_controller.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// internal/controllers/auth_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthController struct {
|
||||||
|
authService *services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthController(db *gorm.DB, logger *zap.Logger) *AuthController {
|
||||||
|
return &AuthController{
|
||||||
|
authService: services.NewAuthService(db, logger),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handles user registration
|
||||||
|
// POST /api/register
|
||||||
|
func (c *AuthController) Register(ctx *gin.Context) {
|
||||||
|
var req services.RegisterRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP and User-Agent
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// Register user
|
||||||
|
result, err := c.authService.Register(req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Registration failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Registration successful", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login handles user login
|
||||||
|
// POST /api/login
|
||||||
|
func (c *AuthController) Login(ctx *gin.Context) {
|
||||||
|
var req services.LoginRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP and User-Agent
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// Login user
|
||||||
|
result, err := c.authService.Login(req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Login failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Login successful", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken handles token refresh
|
||||||
|
// POST /api/refresh-token
|
||||||
|
func (c *AuthController) RefreshToken(ctx *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
newToken, err := c.authService.RefreshToken(req.Token)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Token refreshed", gin.H{
|
||||||
|
"token": newToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMe returns current user info
|
||||||
|
// GET /api/me
|
||||||
|
func (c *AuthController) GetMe(ctx *gin.Context) {
|
||||||
|
user, exists := ctx.Get("user")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User info retrieved", user)
|
||||||
|
}
|
||||||
130
internal/controllers/category_controller.go
Normal file
130
internal/controllers/category_controller.go
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// internal/controllers/category_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryController struct {
|
||||||
|
categoryService *services.CategoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryController(db *gorm.DB) *CategoryController {
|
||||||
|
return &CategoryController{
|
||||||
|
categoryService: services.NewCategoryService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllCategories gets all categories
|
||||||
|
// GET /api/categories
|
||||||
|
func (c *CategoryController) GetAllCategories(ctx *gin.Context) {
|
||||||
|
categories, err := c.categoryService.GetAllCategories()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get categories", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Categories retrieved", categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryByID gets category by ID
|
||||||
|
// GET /api/categories/:id
|
||||||
|
func (c *CategoryController) GetCategoryByID(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := c.categoryService.GetCategoryByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Category not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Category retrieved", category.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCategory creates a new category (admin only)
|
||||||
|
// POST /api/categories
|
||||||
|
func (c *CategoryController) CreateCategory(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.CreateCategoryRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
category, err := c.categoryService.CreateCategory(admin.ID, req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create category", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Category created", category.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategory updates a category (admin only)
|
||||||
|
// PUT /api/categories/:id
|
||||||
|
func (c *CategoryController) UpdateCategory(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.UpdateCategoryRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
category, err := c.categoryService.UpdateCategory(admin.ID, uint(categoryID), req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update category", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Category updated", category.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategory deletes a category (admin only)
|
||||||
|
// DELETE /api/categories/:id
|
||||||
|
func (c *CategoryController) DeleteCategory(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.categoryService.DeleteCategory(admin.ID, uint(categoryID), ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete category", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Category deleted", nil)
|
||||||
|
}
|
||||||
420
internal/controllers/claim_controller.go
Normal file
420
internal/controllers/claim_controller.go
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
// internal/controllers/claim_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaimController struct {
|
||||||
|
claimService *services.ClaimService
|
||||||
|
verificationService *services.VerificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaimController(db *gorm.DB) *ClaimController {
|
||||||
|
return &ClaimController{
|
||||||
|
claimService: services.NewClaimService(db),
|
||||||
|
verificationService: services.NewVerificationService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllClaims gets all claims
|
||||||
|
// GET /api/claims
|
||||||
|
func (c *ClaimController) GetAllClaims(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
status := ctx.Query("status")
|
||||||
|
|
||||||
|
var itemID, userID *uint
|
||||||
|
if itemIDStr := ctx.Query("item_id"); itemIDStr != "" {
|
||||||
|
id, _ := strconv.ParseUint(itemIDStr, 10, 32)
|
||||||
|
itemID = new(uint)
|
||||||
|
*itemID = uint(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If regular user, only show their claims
|
||||||
|
if userObj, exists := ctx.Get("user"); exists {
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
if user.IsUser() {
|
||||||
|
userID = &user.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, total, err := c.claimService.GetAllClaims(page, limit, status, itemID, userID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pastikan claims selalu berupa array, bukan null
|
||||||
|
if claims == nil {
|
||||||
|
claims = []models.ClaimResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/user/claims/:id/respond
|
||||||
|
func (c *ClaimController) UserApproveClaim(ctx *gin.Context) {
|
||||||
|
userObj, exists := ctx.Get("user")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Unauthorized", "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action" binding:"required"` // 'approve' or 'reject'
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validasi Action
|
||||||
|
if req.Action != "approve" && req.Action != "reject" {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid action", "Action must be 'approve' or 'reject'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic approve/reject khusus user
|
||||||
|
err = c.claimService.ProcessUserDecision(user.ID, uint(claimID), req.Action)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Gagal memproses keputusan", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "Klaim berhasil disetujui"
|
||||||
|
if req.Action == "reject" {
|
||||||
|
message = "Klaim berhasil ditolak"
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaimByID gets claim by ID
|
||||||
|
// GET /api/claims/:id
|
||||||
|
func (c *ClaimController) GetClaimByID(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isManager := false
|
||||||
|
if userObj, exists := ctx.Get("user"); exists {
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
isManager = user.IsManager() || user.IsAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jika Manager, paksa hitung similarity dulu sebelum ambil data
|
||||||
|
if isManager {
|
||||||
|
_, err := c.verificationService.VerifyClaimDescription(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
// Log error tapi jangan stop flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claim, err := c.claimService.GetClaimByID(uint(id), isManager)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Claim not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim retrieved", claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaimController) UserConfirmCompletion(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, _ := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
|
||||||
|
err := c.claimService.UserConfirmCompletion(user.ID, uint(claimID))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Gagal menyelesaikan kasus", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Kasus selesai. Barang telah diterima.", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClaim creates a new claim
|
||||||
|
// POST /api/claims
|
||||||
|
func (c *ClaimController) CreateClaim(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.CreateClaimRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
claim, err := c.claimService.CreateClaim(user.ID, req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create claim", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Claim created", claim.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyClaim verifies a claim (manager only)
|
||||||
|
// POST /api/claims/:id/verify
|
||||||
|
func (c *ClaimController) VerifyClaim(ctx *gin.Context) {
|
||||||
|
managerObj, _ := ctx.Get("user")
|
||||||
|
manager := managerObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.VerifyClaimRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-verify description similarity
|
||||||
|
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// Verify the claim
|
||||||
|
if err := c.claimService.VerifyClaim(
|
||||||
|
manager.ID,
|
||||||
|
uint(claimID),
|
||||||
|
req,
|
||||||
|
verification.SimilarityScore,
|
||||||
|
stringSliceToString(verification.MatchedKeywords),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to verify claim", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim verified", gin.H{
|
||||||
|
"verification": verification,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaimVerification gets verification data for a claim
|
||||||
|
// GET /api/claims/:id/verification
|
||||||
|
func (c *ClaimController) GetClaimVerification(ctx *gin.Context) {
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Verification retrieved", verification)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelClaimApproval handles cancelling an approved claim
|
||||||
|
// POST /api/claims/:id/cancel-approval
|
||||||
|
// ✅ RENAMED: CancelApproval -> CancelClaimApproval (Matches routes.go)
|
||||||
|
func (c *ClaimController) CancelClaimApproval(ctx *gin.Context) {
|
||||||
|
managerObj, _ := ctx.Get("user")
|
||||||
|
manager := managerObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.claimService.CancelClaimApproval(manager.ID, uint(claimID)); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to cancel approval", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Approval cancelled successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseCase handles case closure (Manager only)
|
||||||
|
// POST /api/claims/:id/close
|
||||||
|
// ✅ RENAMED: CloseClaim -> CloseCase (Matches routes.go)
|
||||||
|
func (c *ClaimController) CloseCase(ctx *gin.Context) {
|
||||||
|
// 1. Ambil User (Manager) dari context secara konsisten
|
||||||
|
managerObj, exists := ctx.Get("user")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Unauthorized", "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager := managerObj.(*models.User)
|
||||||
|
|
||||||
|
// 2. Ambil ID Klaim dari URL
|
||||||
|
idStr := ctx.Param("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid ID format", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Parse Body Request
|
||||||
|
var req services.CloseCaseRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Ambil Info Audit
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// 5. Panggil Service
|
||||||
|
if err := c.claimService.CloseCase(manager.ID, uint(id), req, ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to close case", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Case closed successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReopenCase handles reopening a closed case
|
||||||
|
// POST /api/claims/:id/reopen
|
||||||
|
// ✅ RENAMED: ReopenClaim -> ReopenCase (Matches routes.go)
|
||||||
|
func (c *ClaimController) ReopenCase(ctx *gin.Context) {
|
||||||
|
managerObj, _ := ctx.Get("user")
|
||||||
|
manager := managerObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.ReopenCaseRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.claimService.ReopenCase(manager.ID, uint(claimID), req, ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to reopen case", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Case reopened successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaimsByUser gets claims by user
|
||||||
|
// GET /api/user/claims
|
||||||
|
func (c *ClaimController) GetClaimsByUser(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
|
||||||
|
claims, total, err := c.claimService.GetClaimsByUser(user.ID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateClaim updates a claim
|
||||||
|
// PUT /api/claims/:id
|
||||||
|
func (c *ClaimController) UpdateClaim(ctx *gin.Context) {
|
||||||
|
// Bisa Admin atau User pemilik klaim (dicek di middleware/service)
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.UpdateClaimRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
claim, err := c.claimService.UpdateClaim(user.ID, uint(claimID), req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update claim", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim updated successfully", claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteClaim deletes a claim
|
||||||
|
// DELETE /api/claims/:id
|
||||||
|
func (c *ClaimController) DeleteClaim(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.claimService.DeleteClaim(user.ID, uint(claimID), ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete claim", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim deleted", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert string slice to string
|
||||||
|
func stringSliceToString(slice []string) string {
|
||||||
|
if len(slice) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := ""
|
||||||
|
for i, s := range slice {
|
||||||
|
if i > 0 {
|
||||||
|
result += ", "
|
||||||
|
}
|
||||||
|
result += s
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
296
internal/controllers/item_controller.go
Normal file
296
internal/controllers/item_controller.go
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
// internal/controllers/item_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/repositories" // ✅ TAMBAHKAN INI
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ItemController struct {
|
||||||
|
itemService *services.ItemService
|
||||||
|
matchService *services.MatchService
|
||||||
|
itemRepo *repositories.ItemRepository // ✅ TAMBAHKAN INI
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewItemController(db *gorm.DB) *ItemController {
|
||||||
|
return &ItemController{
|
||||||
|
itemService: services.NewItemService(db),
|
||||||
|
matchService: services.NewMatchService(db),
|
||||||
|
itemRepo: repositories.NewItemRepository(db), // ✅ INITIALIZE INI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED GetItemByID
|
||||||
|
// ✅ PASTIKAN response detail lengkap untuk manager
|
||||||
|
// ✅ FIXED GetItemByIDfunc (c *ItemController) GetItemByID(ctx *gin.Context) {
|
||||||
|
// ✅ FIXED GetItemByID - NOW RETURNS FULL DETAILS FOR MANAGER
|
||||||
|
func (c *ItemController) GetItemByID(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Check if user is manager or admin
|
||||||
|
isManager := false
|
||||||
|
if userObj, exists := ctx.Get("user"); exists {
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
isManager = user.IsManager() || user.IsAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Get item DIRECTLY from repository with PRELOAD
|
||||||
|
item, err := c.itemRepo.FindByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Item not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ LOG untuk debug
|
||||||
|
log.Printf("🔍 Controller GetItemByID - Item ID: %d", item.ID)
|
||||||
|
log.Printf(" 📝 Description: %s", item.Description)
|
||||||
|
log.Printf(" 🔒 SecretDetails: %s", item.SecretDetails)
|
||||||
|
log.Printf(" 👤 ReporterName: %s", item.ReporterName)
|
||||||
|
log.Printf(" 📞 ReporterContact: %s", item.ReporterContact)
|
||||||
|
|
||||||
|
// ✅ Return response based on role
|
||||||
|
if isManager {
|
||||||
|
// Manager/Admin gets FULL details
|
||||||
|
detailResponse := item.ToDetailResponse()
|
||||||
|
|
||||||
|
// ✅ LOG response yang akan dikirim
|
||||||
|
log.Printf("📤 Sending DetailResponse to Manager:")
|
||||||
|
log.Printf(" Description: %s", detailResponse.Description)
|
||||||
|
log.Printf(" SecretDetails: %s", detailResponse.SecretDetails)
|
||||||
|
log.Printf(" ReporterName: %s", detailResponse.ReporterName)
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", detailResponse)
|
||||||
|
} else {
|
||||||
|
// Regular user gets public view only
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", item.ToPublicResponse())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ItemController) ReportFoundItemLinked(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.CreateFoundItemLinkedRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Data tidak valid", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
item, err := c.itemService.CreateFoundItemLinked(user.ID, req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Gagal membuat laporan", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "Laporan berhasil dibuat. Menunggu verifikasi Manager."
|
||||||
|
if req.IsDirectToOwner {
|
||||||
|
message = "Laporan berhasil! Notifikasi langsung dikirim ke pemilik barang."
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, message, item)
|
||||||
|
}
|
||||||
|
// CreateItem creates a new item
|
||||||
|
// POST /api/items
|
||||||
|
func (c *ItemController) CreateItem(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.CreateItemRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
item, err := c.itemService.CreateItem(user.ID, req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create item", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-match with lost items
|
||||||
|
go c.matchService.AutoMatchNewItem(item.ID)
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Item created", item.ToDetailResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItem updates an item
|
||||||
|
// PUT /api/items/:id
|
||||||
|
func (c *ItemController) UpdateItem(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.UpdateItemRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
item, err := c.itemService.UpdateItem(user.ID, uint(itemID), req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update item", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Item updated", item.ToDetailResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemStatus updates item status
|
||||||
|
// PATCH /api/items/:id/status
|
||||||
|
func (c *ItemController) UpdateItemStatus(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.itemService.UpdateItemStatus(user.ID, uint(itemID), req.Status, ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Item status updated", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteItem deletes an item
|
||||||
|
// DELETE /api/items/:id
|
||||||
|
func (c *ItemController) DeleteItem(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.itemService.DeleteItem(user.ID, uint(itemID), ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete item", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Item deleted", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemsByReporter gets items by reporter
|
||||||
|
// GET /api/user/items
|
||||||
|
func (c *ItemController) GetItemsByReporter(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
|
||||||
|
items, total, err := c.itemService.GetItemsByReporter(user.ID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.ItemDetailResponse
|
||||||
|
for _, item := range items {
|
||||||
|
responses = append(responses, item.ToDetailResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", responses, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemRevisionHistory gets revision history for an item
|
||||||
|
// GET /api/items/:id/revisions
|
||||||
|
func (c *ItemController) GetItemRevisionHistory(ctx *gin.Context) {
|
||||||
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
|
||||||
|
revisions, total, err := c.itemService.GetItemRevisionHistory(uint(itemID), page, limit)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get revision history", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Revision history retrieved", revisions, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ItemController) GetAllItems(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
status := ctx.Query("status")
|
||||||
|
category := ctx.Query("category")
|
||||||
|
search := ctx.Query("search")
|
||||||
|
|
||||||
|
// ✅ CHECK IF USER IS MANAGER/ADMIN
|
||||||
|
isManager := false
|
||||||
|
if userObj, exists := ctx.Get("user"); exists {
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
isManager = user.IsManager() || user.IsAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED: FORCE FILTER OUT EXPIRED for public users
|
||||||
|
if !isManager {
|
||||||
|
// 1. Jika user mencoba meminta status terlarang, paksa filter aman
|
||||||
|
if status == models.ItemStatusExpired || status == models.ItemStatusCaseClosed {
|
||||||
|
status = "!expired"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Jika status kosong (default), set ke !expired
|
||||||
|
if status == "" {
|
||||||
|
status = "!expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, total, err := c.itemService.GetAllItems(page, limit, status, category, search)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", items, total, page, limit)
|
||||||
|
}
|
||||||
230
internal/controllers/lost_item_controller.go
Normal file
230
internal/controllers/lost_item_controller.go
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
// internal/controllers/lost_item_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LostItemController struct {
|
||||||
|
lostItemService *services.LostItemService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLostItemController(db *gorm.DB) *LostItemController {
|
||||||
|
return &LostItemController{
|
||||||
|
lostItemService: services.NewLostItemService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllLostItems gets all lost items
|
||||||
|
// GET /api/lost-items
|
||||||
|
func (c *LostItemController) GetAllLostItems(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
status := ctx.Query("status")
|
||||||
|
category := ctx.Query("category")
|
||||||
|
search := ctx.Query("search")
|
||||||
|
scope := ctx.Query("scope") // Tambahkan ini
|
||||||
|
|
||||||
|
var userID *uint
|
||||||
|
|
||||||
|
// Logic Baru: Defaultnya publik (userID = nil), kecuali minta 'mine'
|
||||||
|
if userObj, exists := ctx.Get("user"); exists {
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
// Hanya filter ID jika user meminta scope="mine"
|
||||||
|
if scope == "mine" {
|
||||||
|
userID = &user.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panggil service (userID akan nil jika melihat publik, terisi jika scope=mine)
|
||||||
|
lostItems, total, err := c.lostItemService.GetAllLostItems(page, limit, status, category, search, userID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLostItemByID gets lost item by ID
|
||||||
|
// GET /api/lost-items/:id
|
||||||
|
func (c *LostItemController) GetLostItemByID(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lostItem, err := c.lostItemService.GetLostItemByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Lost item not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item retrieved", lostItem.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLostItem creates a new lost item report
|
||||||
|
// POST /api/lost-items
|
||||||
|
func (c *LostItemController) CreateLostItem(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.CreateLostItemRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
lostItem, err := c.lostItemService.CreateLostItem(user.ID, req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create lost item report", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Lost item report created", lostItem.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLostItem updates a lost item report
|
||||||
|
// PUT /api/lost-items/:id
|
||||||
|
func (c *LostItemController) UpdateLostItem(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.UpdateLostItemRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
lostItem, err := c.lostItemService.UpdateLostItem(user.ID, uint(lostItemID), req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update lost item report", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report updated", lostItem.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLostItemStatus updates lost item status
|
||||||
|
// PATCH /api/lost-items/:id/status
|
||||||
|
func (c *LostItemController) UpdateLostItemStatus(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.lostItemService.UpdateLostItemStatus(user.ID, uint(lostItemID), req.Status, ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item status updated", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLostItem deletes a lost item report
|
||||||
|
// DELETE /api/lost-items/:id
|
||||||
|
func (c *LostItemController) DeleteLostItem(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.lostItemService.DeleteLostItem(user.ID, uint(lostItemID), ipAddress, userAgent);
|
||||||
|
err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete lost item report", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report deleted", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLostItemsByUser gets lost items by user
|
||||||
|
// GET /api/user/lost-items
|
||||||
|
func (c *LostItemController) GetLostItemsByUser(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
|
||||||
|
lostItems, total, err := c.lostItemService.GetLostItemsByUser(user.ID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LostItemController) DirectClaimToOwner(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.CreateLostItemClaimRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ TAMBAHAN BARU: Ambil IP dan User Agent untuk Audit Log
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// ✅ UPDATE: Kirim ipAddress dan userAgent ke Service
|
||||||
|
claim, err := c.lostItemService.DirectClaimToOwner(user.ID, uint(lostItemID), req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to submit direct claim", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Klaim terkirim ke pemilik untuk persetujuan", claim.ToResponse())
|
||||||
|
}
|
||||||
40
internal/controllers/manager_controller.go
Normal file
40
internal/controllers/manager_controller.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// internal/controllers/manager_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ManagerController struct {
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
claimRepo *repositories.ClaimRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManagerController(db *gorm.DB) *ManagerController {
|
||||||
|
return &ManagerController{
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
claimRepo: repositories.NewClaimRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ManagerController) GetDashboardStats(ctx *gin.Context) {
|
||||||
|
totalItems, _ := c.itemRepo.CountAll()
|
||||||
|
pendingClaims, _ := c.claimRepo.CountByStatus(models.ClaimStatusPending)
|
||||||
|
verifiedItems, _ := c.itemRepo.CountByStatus(models.ItemStatusVerified)
|
||||||
|
expiredItems, _ := c.itemRepo.CountByStatus(models.ItemStatusExpired)
|
||||||
|
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"total_items": totalItems,
|
||||||
|
"pending_claims": pendingClaims,
|
||||||
|
"verified": verifiedItems,
|
||||||
|
"expired": expiredItems,
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Manager dashboard stats", stats)
|
||||||
|
}
|
||||||
87
internal/controllers/match_controller.go
Normal file
87
internal/controllers/match_controller.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// internal/controllers/match_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchController struct {
|
||||||
|
matchService *services.MatchService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatchController(db *gorm.DB) *MatchController {
|
||||||
|
return &MatchController{
|
||||||
|
matchService: services.NewMatchService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSimilarItems finds similar items for a lost item
|
||||||
|
// POST /api/lost-items/:id/find-similar
|
||||||
|
func (c *MatchController) FindSimilarItems(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow managers or the owner to search
|
||||||
|
// Add ownership check here if needed
|
||||||
|
|
||||||
|
results, err := c.matchService.FindSimilarItems(uint(lostItemID))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to find similar items", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Similar items found", gin.H{
|
||||||
|
"total": len(results),
|
||||||
|
"matches": results,
|
||||||
|
"user_id": user.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchesForLostItem gets all matches for a lost item
|
||||||
|
// GET /api/lost-items/:id/matches
|
||||||
|
func (c *MatchController) GetMatchesForLostItem(ctx *gin.Context) {
|
||||||
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := c.matchService.GetMatchesForLostItem(uint(lostItemID))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchesForItem gets all matches for an item
|
||||||
|
// GET /api/items/:id/matches
|
||||||
|
func (c *MatchController) GetMatchesForItem(ctx *gin.Context) {
|
||||||
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := c.matchService.GetMatchesForItem(uint(itemID))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
|
||||||
|
}
|
||||||
82
internal/controllers/notification_controller.go
Normal file
82
internal/controllers/notification_controller.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// internal/controllers/notification_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationController struct {
|
||||||
|
notificationService *services.NotificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotificationController(db *gorm.DB) *NotificationController {
|
||||||
|
return &NotificationController{
|
||||||
|
notificationService: services.NewNotificationService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserNotifications gets notifications for current user
|
||||||
|
// GET /api/notifications
|
||||||
|
func (c *NotificationController) GetUserNotifications(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
onlyUnread := ctx.Query("unread") == "true"
|
||||||
|
|
||||||
|
notifications, total, err := c.notificationService.GetUserNotifications(user.ID, page, limit, onlyUnread)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get notifications", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := c.notificationService.CountUnread(user.ID)
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Notifications retrieved", gin.H{
|
||||||
|
"notifications": notifications,
|
||||||
|
"total": total,
|
||||||
|
"unread_count": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsRead marks a notification as read
|
||||||
|
// PATCH /api/notifications/:id/read
|
||||||
|
func (c *NotificationController) MarkAsRead(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.notificationService.MarkAsRead(user.ID, uint(id)); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to mark as read", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Notification marked as read", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllAsRead marks all notifications as read
|
||||||
|
// PATCH /api/notifications/read-all
|
||||||
|
func (c *NotificationController) MarkAllAsRead(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
if err := c.notificationService.MarkAllAsRead(user.ID); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to mark all as read", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "All notifications marked as read", nil)
|
||||||
|
}
|
||||||
110
internal/controllers/report_controller.go
Normal file
110
internal/controllers/report_controller.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// internal/controllers/report_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportController struct {
|
||||||
|
exportService *services.ExportService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportController(db *gorm.DB) *ReportController {
|
||||||
|
return &ReportController{
|
||||||
|
exportService: services.NewExportService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportReport exports report based on request
|
||||||
|
// POST /api/reports/export
|
||||||
|
func (c *ReportController) ExportReport(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.ExportRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
var buffer *[]byte
|
||||||
|
var filename string
|
||||||
|
var contentType string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Generate report based on type and format
|
||||||
|
switch req.Type {
|
||||||
|
case "items":
|
||||||
|
if req.Format == "pdf" {
|
||||||
|
buf, e := c.exportService.ExportItemsToPDF(req, user.ID, ipAddress, userAgent)
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
} else {
|
||||||
|
data := buf.Bytes()
|
||||||
|
buffer = &data
|
||||||
|
filename = fmt.Sprintf("items_report_%s.pdf", time.Now().Format("20060102"))
|
||||||
|
contentType = "application/pdf"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buf, e := c.exportService.ExportItemsToExcel(req, user.ID, ipAddress, userAgent)
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
} else {
|
||||||
|
data := buf.Bytes()
|
||||||
|
buffer = &data
|
||||||
|
filename = fmt.Sprintf("items_report_%s.xlsx", time.Now().Format("20060102"))
|
||||||
|
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "archives":
|
||||||
|
buf, e := c.exportService.ExportArchivesToPDF(req, user.ID, ipAddress, userAgent)
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
} else {
|
||||||
|
data := buf.Bytes()
|
||||||
|
buffer = &data
|
||||||
|
filename = fmt.Sprintf("archives_report_%s.pdf", time.Now().Format("20060102"))
|
||||||
|
contentType = "application/pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "claims":
|
||||||
|
buf, e := c.exportService.ExportClaimsToPDF(req, user.ID, ipAddress, userAgent)
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
} else {
|
||||||
|
data := buf.Bytes()
|
||||||
|
buffer = &data
|
||||||
|
filename = fmt.Sprintf("claims_report_%s.pdf", time.Now().Format("20060102"))
|
||||||
|
contentType = "application/pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid report type", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to generate report", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
ctx.Header("Content-Type", contentType)
|
||||||
|
ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
ctx.Header("Content-Length", fmt.Sprintf("%d", len(*buffer)))
|
||||||
|
|
||||||
|
// Send file
|
||||||
|
ctx.Data(http.StatusOK, contentType, *buffer)
|
||||||
|
}
|
||||||
105
internal/controllers/role_controller.go
Normal file
105
internal/controllers/role_controller.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleController struct {
|
||||||
|
roleService *services.RoleService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoleController initializes the role controller
|
||||||
|
func NewRoleController(db *gorm.DB) *RoleController {
|
||||||
|
return &RoleController{
|
||||||
|
roleService: services.NewRoleService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoles gets all roles
|
||||||
|
// GET /api/admin/roles
|
||||||
|
func (c *RoleController) GetRoles(ctx *gin.Context) {
|
||||||
|
roles, err := c.roleService.GetAllRoles()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get roles", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Roles retrieved", roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissions gets all permissions
|
||||||
|
// GET /api/admin/permissions
|
||||||
|
func (c *RoleController) GetPermissions(ctx *gin.Context) {
|
||||||
|
permissions, err := c.roleService.GetAllPermissions()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get permissions", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Permissions retrieved", permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole creates a new role
|
||||||
|
// POST /api/admin/roles
|
||||||
|
func (c *RoleController) CreateRole(ctx *gin.Context) {
|
||||||
|
var req services.CreateRoleRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := c.roleService.CreateRole(req)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create role", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusCreated, "Role created", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole updates a role
|
||||||
|
// PUT /api/admin/roles/:id
|
||||||
|
func (c *RoleController) UpdateRole(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid role ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req services.UpdateRoleRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err := c.roleService.UpdateRole(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Role updated", role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole deletes a role
|
||||||
|
// DELETE /api/admin/roles/:id
|
||||||
|
func (c *RoleController) DeleteRole(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid role ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.roleService.DeleteRole(uint(id)); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete role", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Role deleted", nil)
|
||||||
|
}
|
||||||
284
internal/controllers/upload_controller.go
Normal file
284
internal/controllers/upload_controller.go
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
// internal/controllers/upload_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ KRITERIA BACKEND: File Handling (5 Poin)
|
||||||
|
// - Validasi MIME type
|
||||||
|
// - Validasi Max size
|
||||||
|
// - Multiple file support
|
||||||
|
// - Secure filename handling
|
||||||
|
|
||||||
|
type UploadController struct {
|
||||||
|
imageHandler *utils.ImageHandler
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUploadController(db *gorm.DB) *UploadController {
|
||||||
|
return &UploadController{
|
||||||
|
imageHandler: utils.NewImageHandler("./uploads"),
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadItemImage uploads image untuk item
|
||||||
|
// POST /api/upload/item-image
|
||||||
|
func (c *UploadController) UploadItemImage(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
// ✅ VALIDASI: Get file from request
|
||||||
|
file, err := ctx.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "No file uploaded", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ VALIDASI: Check MIME type (dilakukan di ImageHandler)
|
||||||
|
// ✅ VALIDASI: Check file size (dilakukan di ImageHandler)
|
||||||
|
|
||||||
|
// Upload dengan processing (resize, optimize)
|
||||||
|
relativePath, err := c.imageHandler.UploadImage(file, "items")
|
||||||
|
if err != nil {
|
||||||
|
// Error dari ImageHandler sudah mencakup validasi MIME type & size
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Upload failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate URL
|
||||||
|
imageURL := fmt.Sprintf("/uploads/%s", relativePath)
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
c.logAudit(user.ID, "upload", "item_image", nil,
|
||||||
|
fmt.Sprintf("Image uploaded: %s", file.Filename),
|
||||||
|
ctx.ClientIP(), ctx.Request.UserAgent())
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Image uploaded successfully", gin.H{
|
||||||
|
"url": imageURL,
|
||||||
|
"filename": filepath.Base(relativePath),
|
||||||
|
"size": file.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadClaimProof uploads bukti untuk claim
|
||||||
|
// POST /api/upload/claim-proof
|
||||||
|
func (c *UploadController) UploadClaimProof(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
file, err := ctx.FormFile("proof")
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "No file uploaded", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ VALIDASI TAMBAHAN: Check file extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||||
|
allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}
|
||||||
|
|
||||||
|
isAllowed := false
|
||||||
|
for _, allowedExt := range allowedExts {
|
||||||
|
if ext == allowedExt {
|
||||||
|
isAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAllowed {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest,
|
||||||
|
"Invalid file extension",
|
||||||
|
fmt.Sprintf("Allowed: %v", allowedExts))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload simple (tanpa resize untuk PDF)
|
||||||
|
var relativePath string
|
||||||
|
if ext == ".pdf" {
|
||||||
|
relativePath, err = c.imageHandler.UploadImageSimple(file, "proofs")
|
||||||
|
} else {
|
||||||
|
relativePath, err = c.imageHandler.UploadImage(file, "proofs")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Upload failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proofURL := fmt.Sprintf("/uploads/%s", relativePath)
|
||||||
|
|
||||||
|
c.logAudit(user.ID, "upload", "claim_proof", nil,
|
||||||
|
fmt.Sprintf("Proof uploaded: %s", file.Filename),
|
||||||
|
ctx.ClientIP(), ctx.Request.UserAgent())
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Proof uploaded successfully", gin.H{
|
||||||
|
"url": proofURL,
|
||||||
|
"filename": filepath.Base(relativePath),
|
||||||
|
"size": file.Size,
|
||||||
|
"type": ext,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadMultipleImages uploads multiple images sekaligus
|
||||||
|
// POST /api/upload/multiple
|
||||||
|
func (c *UploadController) UploadMultipleImages(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
// ✅ VALIDASI: Parse multipart form
|
||||||
|
form, err := ctx.MultipartForm()
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid form data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := form.File["images"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "No files uploaded", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ VALIDASI: Max 5 files per request
|
||||||
|
const maxFiles = 5
|
||||||
|
if len(files) > maxFiles {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Too many files. Max %d files allowed", maxFiles), "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadedFiles []gin.H
|
||||||
|
var failedFiles []gin.H
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
// Upload each file
|
||||||
|
relativePath, err := c.imageHandler.UploadImage(file, "items")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
failedFiles = append(failedFiles, gin.H{
|
||||||
|
"filename": file.Filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedFiles = append(uploadedFiles, gin.H{
|
||||||
|
"filename": filepath.Base(relativePath),
|
||||||
|
"url": fmt.Sprintf("/uploads/%s", relativePath),
|
||||||
|
"size": file.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logAudit(user.ID, "upload", "multiple_images", nil,
|
||||||
|
fmt.Sprintf("Uploaded %d files, %d failed", len(uploadedFiles), len(failedFiles)),
|
||||||
|
ctx.ClientIP(), ctx.Request.UserAgent())
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Upload completed", gin.H{
|
||||||
|
"uploaded": uploadedFiles,
|
||||||
|
"failed": failedFiles,
|
||||||
|
"total": len(files),
|
||||||
|
"success": len(uploadedFiles),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteImage deletes uploaded image
|
||||||
|
// DELETE /api/upload/delete
|
||||||
|
func (c *UploadController) DeleteImage(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ImageURL string `json:"image_url" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract relative path from URL
|
||||||
|
relativePath := strings.TrimPrefix(req.ImageURL, "/uploads/")
|
||||||
|
|
||||||
|
// ✅ SECURITY: Validate path (prevent directory traversal)
|
||||||
|
if strings.Contains(relativePath, "..") {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid path", "Directory traversal detected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
if err := c.imageHandler.DeleteImage(relativePath); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Delete failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logAudit(user.ID, "delete", "image", nil,
|
||||||
|
fmt.Sprintf("Image deleted: %s", relativePath),
|
||||||
|
ctx.ClientIP(), ctx.Request.UserAgent())
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Image deleted successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetImageInfo gets metadata tentang uploaded image
|
||||||
|
// GET /api/upload/info?url=/uploads/items/image.jpg
|
||||||
|
func (c *UploadController) GetImageInfo(ctx *gin.Context) {
|
||||||
|
imageURL := ctx.Query("url")
|
||||||
|
if imageURL == "" {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Image URL required", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePath := strings.TrimPrefix(imageURL, "/uploads/")
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if strings.Contains(relativePath, "..") {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid path", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join("./uploads", relativePath)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
fileInfo, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "File not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Image info retrieved", gin.H{
|
||||||
|
"path": fileInfo,
|
||||||
|
"url": imageURL,
|
||||||
|
"filename": filepath.Base(relativePath),
|
||||||
|
"folder": filepath.Dir(relativePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// logAudit helper untuk logging
|
||||||
|
func (c *UploadController) logAudit(userID uint, action, entityType string, entityID *uint, details, ip, userAgent string) {
|
||||||
|
auditLog := &models.AuditLog{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: action,
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityID: entityID,
|
||||||
|
Details: details,
|
||||||
|
IPAddress: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
c.db.Create(auditLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ VALIDASI INFO
|
||||||
|
// Validasi yang diterapkan:
|
||||||
|
// 1. MIME type check (di ImageHandler.isAllowedType)
|
||||||
|
// 2. File size check (di ImageHandler dengan maxSize = 10MB)
|
||||||
|
// 3. File extension check (allowedExts)
|
||||||
|
// 4. Max files per request check (5 files)
|
||||||
|
// 5. Directory traversal prevention (.. check)
|
||||||
|
// 6. Image resize untuk optimize storage (di ImageHandler)
|
||||||
238
internal/controllers/user_controller.go
Normal file
238
internal/controllers/user_controller.go
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
// internal/controllers/user_controller.go
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserController struct {
|
||||||
|
userService *services.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserController(db *gorm.DB) *UserController {
|
||||||
|
return &UserController{
|
||||||
|
userService: services.NewUserService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile gets user profile
|
||||||
|
// GET /api/user/profile
|
||||||
|
func (c *UserController) GetProfile(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
profile, err := c.userService.GetProfile(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Profile not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Profile retrieved", profile.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile updates user profile
|
||||||
|
// PUT /api/user/profile
|
||||||
|
func (c *UserController) UpdateProfile(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.UpdateProfileRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
updatedUser, err := c.userService.UpdateProfile(user.ID, req, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Update failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Profile updated", updatedUser.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes user password
|
||||||
|
// POST /api/user/change-password
|
||||||
|
func (c *UserController) ChangePassword(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
var req services.ChangePasswordRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.userService.ChangePassword(user.ID, req, ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Password change failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Password changed successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats gets user statistics
|
||||||
|
// GET /api/user/stats
|
||||||
|
func (c *UserController) GetStats(ctx *gin.Context) {
|
||||||
|
userObj, _ := ctx.Get("user")
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
stats, err := c.userService.GetUserStats(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "Stats retrieved", stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllUsers gets all users (admin only)
|
||||||
|
// GET /api/admin/users
|
||||||
|
func (c *UserController) GetAllUsers(ctx *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||||
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
||||||
|
|
||||||
|
users, total, err := c.userService.GetAllUsers(page, limit)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get users", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.UserResponse
|
||||||
|
for _, user := range users {
|
||||||
|
responses = append(responses, user.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Users retrieved", responses, total, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID gets user by ID (admin only)
|
||||||
|
// GET /api/admin/users/:id
|
||||||
|
func (c *UserController) GetUserByID(ctx *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := c.userService.GetUserByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusNotFound, "User not found", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User retrieved", user.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole updates user role (admin only)
|
||||||
|
// PATCH /api/admin/users/:id/role
|
||||||
|
func (c *UserController) UpdateUserRole(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
RoleID uint `json:"role_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.userService.UpdateUserRole(admin.ID, uint(userID), req.RoleID, ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User role updated", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockUser blocks a user (admin only)
|
||||||
|
// POST /api/admin/users/:id/block
|
||||||
|
func (c *UserController) BlockUser(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.userService.BlockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to block user", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User blocked", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockUser unblocks a user (admin only)
|
||||||
|
// POST /api/admin/users/:id/unblock
|
||||||
|
func (c *UserController) UnblockUser(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.userService.UnblockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to unblock user", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User unblocked", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user (admin only)
|
||||||
|
// DELETE /api/admin/users/:id
|
||||||
|
func (c *UserController) DeleteUser(ctx *gin.Context) {
|
||||||
|
adminObj, _ := ctx.Get("user")
|
||||||
|
admin := adminObj.(*models.User)
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := ctx.ClientIP()
|
||||||
|
userAgent := ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
if err := c.userService.DeleteUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete user", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SuccessResponse(ctx, http.StatusOK, "User deleted", nil)
|
||||||
|
}
|
||||||
23
internal/middleware/cors.go
Normal file
23
internal/middleware/cors.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// internal/middleware/cors.go
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORSMiddleware handles CORS
|
||||||
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||||
|
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
||||||
|
|
||||||
|
if ctx.Request.Method == "OPTIONS" {
|
||||||
|
ctx.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/middleware/idempotency.go
Normal file
169
internal/middleware/idempotency.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
// internal/middleware/idempotency.go
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ KRITERIA BACKEND: Idempotency (5 Poin) - Advanced Feature
|
||||||
|
|
||||||
|
// IdempotencyStore menyimpan hasil request yang sudah diproses
|
||||||
|
type IdempotencyStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
results map[string]*IdempotencyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdempotencyResult menyimpan response dari request sebelumnya
|
||||||
|
type IdempotencyResult struct {
|
||||||
|
StatusCode int
|
||||||
|
Body interface{}
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var idempotencyStore = &IdempotencyStore{
|
||||||
|
results: make(map[string]*IdempotencyResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupIdempotencyStore membersihkan hasil yang sudah lama (> 24 jam)
|
||||||
|
func cleanupIdempotencyStore() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
idempotencyStore.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for key, result := range idempotencyStore.results {
|
||||||
|
if now.Sub(result.Timestamp) > 24*time.Hour {
|
||||||
|
delete(idempotencyStore.results, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idempotencyStore.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cleanupIdempotencyStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdempotencyMiddleware mencegah double-submit pada endpoint kritis
|
||||||
|
// Endpoint kritis: Payment, Create Order, Transfer, Submit Claim
|
||||||
|
func IdempotencyMiddleware() gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
// Hanya apply untuk POST/PUT/PATCH methods
|
||||||
|
if ctx.Request.Method != http.MethodPost &&
|
||||||
|
ctx.Request.Method != http.MethodPut &&
|
||||||
|
ctx.Request.Method != http.MethodPatch {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get idempotency key from header
|
||||||
|
idempotencyKey := ctx.GetHeader("Idempotency-Key")
|
||||||
|
|
||||||
|
// Jika tidak ada idempotency key, skip (tidak wajib untuk semua request)
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique key: idempotency-key + user-id + path + method
|
||||||
|
userID, _ := ctx.Get("user_id")
|
||||||
|
uniqueKey := generateUniqueKey(idempotencyKey, fmt.Sprintf("%v", userID), ctx.Request.URL.Path, ctx.Request.Method)
|
||||||
|
|
||||||
|
// Check if request sudah pernah diproses
|
||||||
|
idempotencyStore.mu.RLock()
|
||||||
|
result, exists := idempotencyStore.results[uniqueKey]
|
||||||
|
idempotencyStore.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
// Request sudah pernah diproses, return hasil sebelumnya
|
||||||
|
ctx.JSON(result.StatusCode, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Request already processed (idempotent)",
|
||||||
|
"data": result.Body,
|
||||||
|
"idempotent": true,
|
||||||
|
"original_at": result.Timestamp,
|
||||||
|
})
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark request sebagai "processing" untuk prevent concurrent duplicate
|
||||||
|
processingLock := fmt.Sprintf("%s-processing", uniqueKey)
|
||||||
|
idempotencyStore.mu.Lock()
|
||||||
|
if _, processing := idempotencyStore.results[processingLock]; processing {
|
||||||
|
idempotencyStore.mu.Unlock()
|
||||||
|
utils.ErrorResponse(ctx, http.StatusConflict, "Request is being processed", "Duplicate request detected")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Lock dengan timestamp sebagai marker
|
||||||
|
idempotencyStore.results[processingLock] = &IdempotencyResult{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
idempotencyStore.mu.Unlock()
|
||||||
|
|
||||||
|
// Custom response writer untuk capture hasil
|
||||||
|
blw := &bodyLogWriter{body: []byte{}, ResponseWriter: ctx.Writer}
|
||||||
|
ctx.Writer = blw
|
||||||
|
|
||||||
|
// Process request
|
||||||
|
ctx.Next()
|
||||||
|
|
||||||
|
// Remove processing lock
|
||||||
|
idempotencyStore.mu.Lock()
|
||||||
|
delete(idempotencyStore.results, processingLock)
|
||||||
|
idempotencyStore.mu.Unlock()
|
||||||
|
|
||||||
|
// Simpan hasil hanya jika sukses (2xx status)
|
||||||
|
if ctx.Writer.Status() >= 200 && ctx.Writer.Status() < 300 {
|
||||||
|
idempotencyStore.mu.Lock()
|
||||||
|
idempotencyStore.results[uniqueKey] = &IdempotencyResult{
|
||||||
|
StatusCode: ctx.Writer.Status(),
|
||||||
|
Body: blw.body,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
idempotencyStore.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyLogWriter untuk capture response body
|
||||||
|
type bodyLogWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bodyLogWriter) Write(b []byte) (int, error) {
|
||||||
|
w.body = append(w.body, b...)
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateUniqueKey membuat unique key dari kombinasi parameter
|
||||||
|
func generateUniqueKey(idempotencyKey, userID, path, method string) string {
|
||||||
|
data := fmt.Sprintf("%s:%s:%s:%s", idempotencyKey, userID, path, method)
|
||||||
|
hash := sha256.Sum256([]byte(data))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearIdempotencyCache membersihkan cache (untuk testing/admin)
|
||||||
|
func ClearIdempotencyCache() {
|
||||||
|
idempotencyStore.mu.Lock()
|
||||||
|
defer idempotencyStore.mu.Unlock()
|
||||||
|
idempotencyStore.results = make(map[string]*IdempotencyResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIdempotencyCacheSize return ukuran cache (untuk monitoring)
|
||||||
|
func GetIdempotencyCacheSize() int {
|
||||||
|
idempotencyStore.mu.RLock()
|
||||||
|
defer idempotencyStore.mu.RUnlock()
|
||||||
|
return len(idempotencyStore.results)
|
||||||
|
}
|
||||||
106
internal/middleware/jwt_middleware.go
Normal file
106
internal/middleware/jwt_middleware.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
// internal/middleware/jwt_middleware.go
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/config"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTMiddleware validates JWT token
|
||||||
|
func JWTMiddleware(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
// Get token from Authorization header
|
||||||
|
authHeader := ctx.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authorization header required", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Bearer token
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid authorization format", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := parts[1]
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
claims, err := config.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid or expired token", err.Error())
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
userRepo := repositories.NewUserRepository(db)
|
||||||
|
user, err := userRepo.FindByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is blocked
|
||||||
|
if user.IsBlocked() {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusForbidden, "Account is blocked", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user in context
|
||||||
|
ctx.Set("user", user)
|
||||||
|
ctx.Set("user_id", user.ID)
|
||||||
|
ctx.Set("user_role", user.Role.Name)
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalJWTMiddleware validates JWT token if present (for public routes that can benefit from auth)
|
||||||
|
func OptionalJWTMiddleware(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
authHeader := ctx.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := parts[1]
|
||||||
|
claims, err := config.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := repositories.NewUserRepository(db)
|
||||||
|
user, err := userRepo.FindByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsBlocked() {
|
||||||
|
ctx.Set("user", user)
|
||||||
|
ctx.Set("user_id", user.ID)
|
||||||
|
ctx.Set("user_role", user.Role.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/middleware/logger.go
Normal file
46
internal/middleware/logger.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// internal/middleware/logger.go
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoggerMiddleware logs HTTP requests
|
||||||
|
func LoggerMiddleware() gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
// Start timer
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Process request
|
||||||
|
ctx.Next()
|
||||||
|
|
||||||
|
// Calculate latency
|
||||||
|
latency := time.Since(startTime)
|
||||||
|
|
||||||
|
// Get request info
|
||||||
|
method := ctx.Request.Method
|
||||||
|
path := ctx.Request.URL.Path
|
||||||
|
statusCode := ctx.Writer.Status()
|
||||||
|
clientIP := ctx.ClientIP()
|
||||||
|
|
||||||
|
// Get user ID if authenticated
|
||||||
|
userID := "guest"
|
||||||
|
if id, exists := ctx.Get("user_id"); exists {
|
||||||
|
userID = fmt.Sprintf("%v", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log format
|
||||||
|
fmt.Printf("[%s] %s | %3d | %13v | %15s | %s | User: %s\n",
|
||||||
|
time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
method,
|
||||||
|
statusCode,
|
||||||
|
latency,
|
||||||
|
clientIP,
|
||||||
|
path,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/middleware/rate_limiter.go
Normal file
113
internal/middleware/rate_limiter.go
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// internal/middleware/rate_limiter.go
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter stores rate limit data
|
||||||
|
type RateLimiter struct {
|
||||||
|
visitors map[string]*Visitor
|
||||||
|
mu sync.RWMutex
|
||||||
|
rate int // requests per window
|
||||||
|
window time.Duration // time window
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visitor represents a visitor's rate limit data
|
||||||
|
type Visitor struct {
|
||||||
|
lastSeen time.Time
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
var limiter *RateLimiter
|
||||||
|
|
||||||
|
// InitRateLimiter initializes the rate limiter
|
||||||
|
func InitRateLimiter(rate int, window time.Duration) {
|
||||||
|
limiter = &RateLimiter{
|
||||||
|
visitors: make(map[string]*Visitor),
|
||||||
|
rate: rate,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old visitors every minute
|
||||||
|
go limiter.cleanupVisitors()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupVisitors removes old visitor entries
|
||||||
|
func (rl *RateLimiter) cleanupVisitors() {
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Minute)
|
||||||
|
rl.mu.Lock()
|
||||||
|
for ip, visitor := range rl.visitors {
|
||||||
|
if time.Since(visitor.lastSeen) > rl.window {
|
||||||
|
delete(rl.visitors, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVisitor retrieves or creates a visitor
|
||||||
|
func (rl *RateLimiter) getVisitor(ip string) *Visitor {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
visitor, exists := rl.visitors[ip]
|
||||||
|
if !exists {
|
||||||
|
visitor = &Visitor{
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
rl.visitors[ip] = visitor
|
||||||
|
}
|
||||||
|
|
||||||
|
return visitor
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowed checks if request is allowed
|
||||||
|
func (rl *RateLimiter) isAllowed(ip string) bool {
|
||||||
|
visitor := rl.getVisitor(ip)
|
||||||
|
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
// Reset count if window has passed
|
||||||
|
if time.Since(visitor.lastSeen) > rl.window {
|
||||||
|
visitor.count = 0
|
||||||
|
visitor.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
if visitor.count >= rl.rate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
visitor.count++
|
||||||
|
visitor.lastSeen = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiterMiddleware applies rate limiting
|
||||||
|
func RateLimiterMiddleware() gin.HandlerFunc {
|
||||||
|
// Initialize rate limiter (1000 requests per minute)
|
||||||
|
if limiter == nil {
|
||||||
|
InitRateLimiter(1000, time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
ip := ctx.ClientIP()
|
||||||
|
|
||||||
|
if !limiter.isAllowed(ip) {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusTooManyRequests, "Rate limit exceeded", "Too many requests, please try again later")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
81
internal/middleware/role_middleware.go
Normal file
81
internal/middleware/role_middleware.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// internal/middleware/role_middleware.go
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequirePermission(requiredPerm string) gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
// 1. Ambil object user dari context (diset oleh JWTMiddleware)
|
||||||
|
userObj, exists := ctx.Get("user")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
|
||||||
|
// 2. Cek Permission menggunakan method helper di model User
|
||||||
|
// Pastikan method HasPermission sudah ditambahkan di internal/models/user.go
|
||||||
|
if !user.HasPermission(requiredPerm) {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "Missing permission: "+requiredPerm)
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole checks if user has required role
|
||||||
|
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
userObj, exists := ctx.Get("user")
|
||||||
|
if !exists {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := userObj.(*models.User)
|
||||||
|
userRole := user.Role.Name
|
||||||
|
|
||||||
|
// Check if user has allowed role
|
||||||
|
hasRole := false
|
||||||
|
for _, role := range allowedRoles {
|
||||||
|
if userRole == role {
|
||||||
|
hasRole = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRole {
|
||||||
|
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin middleware (admin only)
|
||||||
|
func RequireAdmin() gin.HandlerFunc {
|
||||||
|
return RequireRole(models.RoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireManager middleware (manager and admin)
|
||||||
|
func RequireManager() gin.HandlerFunc {
|
||||||
|
return RequireRole(models.RoleAdmin, models.RoleManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireUser middleware (all authenticated users)
|
||||||
|
func RequireUser() gin.HandlerFunc {
|
||||||
|
return RequireRole(models.RoleAdmin, models.RoleManager, models.RoleUser)
|
||||||
|
}
|
||||||
116
internal/models/archive.go
Normal file
116
internal/models/archive.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
// internal/models/archive.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Archive represents an archived item
|
||||||
|
type Archive struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ItemID uint `gorm:"not null;uniqueIndex" json:"item_id"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
CategoryID uint `gorm:"not null" json:"category_id"`
|
||||||
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
|
PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"`
|
||||||
|
Location string `gorm:"type:varchar(200)" json:"location"`
|
||||||
|
Description string `gorm:"type:text" json:"description"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
Status string `gorm:"type:varchar(50)" json:"status"`
|
||||||
|
ReporterName string `gorm:"type:varchar(100)" json:"reporter_name"`
|
||||||
|
ReporterContact string `gorm:"type:varchar(50)" json:"reporter_contact"`
|
||||||
|
ArchivedReason string `gorm:"type:varchar(100)" json:"archived_reason"`
|
||||||
|
ClaimedBy *uint `json:"claimed_by"`
|
||||||
|
Claimer *User `gorm:"foreignKey:ClaimedBy" json:"claimer,omitempty"`
|
||||||
|
|
||||||
|
// ✅ NEW FIELDS
|
||||||
|
BeritaAcaraNo string `gorm:"type:varchar(100)" json:"berita_acara_no"`
|
||||||
|
BuktiSerahTerima string `gorm:"type:varchar(255)" json:"bukti_serah_terima"`
|
||||||
|
|
||||||
|
ArchivedAt time.Time `json:"archived_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for Archive model
|
||||||
|
func (Archive) TableName() string {
|
||||||
|
return "archives"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive reason constants
|
||||||
|
const (
|
||||||
|
ArchiveReasonExpired = "expired"
|
||||||
|
ArchiveReasonCaseClosed = "case_closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BeforeCreate hook
|
||||||
|
func (a *Archive) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if a.ArchivedAt.IsZero() {
|
||||||
|
a.ArchivedAt = time.Now()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveResponse represents archive data for API responses
|
||||||
|
type ArchiveResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ItemID uint `json:"item_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ArchivedReason string `json:"archived_reason"`
|
||||||
|
ClaimedBy string `json:"claimed_by,omitempty"`
|
||||||
|
ArchivedAt time.Time `json:"archived_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts Archive to ArchiveResponse
|
||||||
|
func (a *Archive) ToResponse() ArchiveResponse {
|
||||||
|
categoryName := ""
|
||||||
|
if a.Category.ID != 0 {
|
||||||
|
categoryName = a.Category.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedByName := ""
|
||||||
|
if a.Claimer != nil && a.Claimer.ID != 0 {
|
||||||
|
claimedByName = a.Claimer.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArchiveResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
ItemID: a.ItemID,
|
||||||
|
Name: a.Name,
|
||||||
|
Category: categoryName,
|
||||||
|
PhotoURL: a.PhotoURL,
|
||||||
|
Location: a.Location,
|
||||||
|
DateFound: a.DateFound,
|
||||||
|
Status: a.Status,
|
||||||
|
ArchivedReason: a.ArchivedReason,
|
||||||
|
ClaimedBy: claimedByName,
|
||||||
|
ArchivedAt: a.ArchivedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFromItem creates an Archive from an Item
|
||||||
|
func CreateFromItem(item *Item, reason string, claimedBy *uint) *Archive {
|
||||||
|
return &Archive{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
CategoryID: item.CategoryID,
|
||||||
|
PhotoURL: item.PhotoURL,
|
||||||
|
Location: item.Location,
|
||||||
|
Description: item.Description,
|
||||||
|
DateFound: item.DateFound,
|
||||||
|
Status: item.Status,
|
||||||
|
ReporterName: item.ReporterName,
|
||||||
|
ReporterContact: item.ReporterContact,
|
||||||
|
ArchivedReason: reason,
|
||||||
|
ClaimedBy: claimedBy,
|
||||||
|
ArchivedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
99
internal/models/audit_log.go
Normal file
99
internal/models/audit_log.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// internal/models/audit_log.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditLog represents an audit log entry
|
||||||
|
type AuditLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID *uint `json:"user_id"` // Nullable for system actions
|
||||||
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
Action string `gorm:"type:varchar(50);not null" json:"action"` // create, update, delete, verify, login, etc.
|
||||||
|
EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, user, etc.
|
||||||
|
EntityID *uint `json:"entity_id"`
|
||||||
|
Details string `gorm:"type:text" json:"details"`
|
||||||
|
IPAddress string `gorm:"type:varchar(50)" json:"ip_address"`
|
||||||
|
UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for AuditLog model
|
||||||
|
func (AuditLog) TableName() string {
|
||||||
|
return "audit_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action constants
|
||||||
|
const (
|
||||||
|
ActionCreate = "create"
|
||||||
|
ActionUpdate = "update"
|
||||||
|
ActionDelete = "delete"
|
||||||
|
ActionVerify = "verify"
|
||||||
|
ActionLogin = "login"
|
||||||
|
ActionLogout = "logout"
|
||||||
|
ActionBlock = "block"
|
||||||
|
ActionUnblock = "unblock"
|
||||||
|
ActionApprove = "approve"
|
||||||
|
ActionReject = "reject"
|
||||||
|
ActionExport = "export"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity type constants
|
||||||
|
const (
|
||||||
|
EntityItem = "item"
|
||||||
|
EntityLostItem = "lost_item"
|
||||||
|
EntityClaim = "claim"
|
||||||
|
EntityUser = "user"
|
||||||
|
EntityCategory = "category"
|
||||||
|
EntityArchive = "archive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditLogResponse represents audit log data for API responses
|
||||||
|
type AuditLogResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserName string `json:"user_name,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID *uint `json:"entity_id"`
|
||||||
|
Details string `json:"details"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts AuditLog to AuditLogResponse
|
||||||
|
func (a *AuditLog) ToResponse() AuditLogResponse {
|
||||||
|
userName := "System"
|
||||||
|
if a.User != nil && a.User.ID != 0 {
|
||||||
|
userName = a.User.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuditLogResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
UserName: userName,
|
||||||
|
Action: a.Action,
|
||||||
|
EntityType: a.EntityType,
|
||||||
|
EntityID: a.EntityID,
|
||||||
|
Details: a.Details,
|
||||||
|
IPAddress: a.IPAddress,
|
||||||
|
CreatedAt: a.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAuditLog creates a new audit log entry
|
||||||
|
func CreateAuditLog(db *gorm.DB, userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
|
||||||
|
log := &AuditLog{
|
||||||
|
UserID: userID,
|
||||||
|
Action: action,
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityID: entityID,
|
||||||
|
Details: details,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Create(log).Error
|
||||||
|
}
|
||||||
49
internal/models/category.go
Normal file
49
internal/models/category.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// internal/models/category.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Category represents an item category
|
||||||
|
type Category struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"`
|
||||||
|
Description string `gorm:"type:text" json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
Items []Item `gorm:"foreignKey:CategoryID" json:"items,omitempty"`
|
||||||
|
LostItems []LostItem `gorm:"foreignKey:CategoryID" json:"lost_items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for Category model
|
||||||
|
func (Category) TableName() string {
|
||||||
|
return "categories"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryResponse represents category data for API responses
|
||||||
|
type CategoryResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ItemCount int64 `json:"item_count,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts Category to CategoryResponse
|
||||||
|
func (c *Category) ToResponse() CategoryResponse {
|
||||||
|
return CategoryResponse{
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
Slug: c.Slug,
|
||||||
|
Description: c.Description,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
50
internal/models/chat_message.go
Normal file
50
internal/models/chat_message.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
Message string `gorm:"type:text;not null" json:"message"`
|
||||||
|
Response string `gorm:"type:text;not null" json:"response"`
|
||||||
|
ContextData string `gorm:"type:json" json:"context_data"`
|
||||||
|
Intent string `gorm:"type:varchar(50)" json:"intent"`
|
||||||
|
ConfidenceScore float64 `gorm:"type:decimal(5,2);default:0.00" json:"confidence_score"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ChatMessage) TableName() string {
|
||||||
|
return "chat_messages"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intent types
|
||||||
|
const (
|
||||||
|
IntentSearchItem = "search_item"
|
||||||
|
IntentReportLost = "report_lost"
|
||||||
|
IntentClaimHelp = "claim_help"
|
||||||
|
IntentGeneral = "general"
|
||||||
|
IntentRecommendItem = "recommend_item"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatMessageResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Response string `json:"response"`
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
ConfidenceScore float64 `json:"confidence_score"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChatMessage) ToResponse() ChatMessageResponse {
|
||||||
|
return ChatMessageResponse{
|
||||||
|
ID: c.ID,
|
||||||
|
Message: c.Message,
|
||||||
|
Response: c.Response,
|
||||||
|
Intent: c.Intent,
|
||||||
|
ConfidenceScore: c.ConfidenceScore,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
235
internal/models/claim.go
Normal file
235
internal/models/claim.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claim represents a claim for a found item or a direct claim on a lost item
|
||||||
|
type Claim struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|
||||||
|
// ✅ UBAH: Jadi Pointer (*uint) agar bisa NULL
|
||||||
|
ItemID *uint `json:"item_id"`
|
||||||
|
// ✅ UBAH: Jadi Pointer (*Item) untuk handling relasi opsional
|
||||||
|
Item *Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
|
||||||
|
|
||||||
|
// ✅ BARU: Relasi ke LostItem (untuk Direct Claim)
|
||||||
|
LostItemID *uint `json:"lost_item_id"`
|
||||||
|
LostItem *LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"`
|
||||||
|
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"` // Ini adalah Finder (Penemu)
|
||||||
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
|
||||||
|
Description string `gorm:"type:text;not null" json:"description"`
|
||||||
|
ProofURL string `gorm:"type:varchar(255)" json:"proof_url"`
|
||||||
|
Contact string `gorm:"type:varchar(50);not null" json:"contact"`
|
||||||
|
Status string `gorm:"type:varchar(50);default:'pending'" json:"status"`
|
||||||
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
|
VerifiedAt *time.Time `json:"verified_at"`
|
||||||
|
VerifiedBy *uint `json:"verified_by"`
|
||||||
|
Verifier *User `gorm:"foreignKey:VerifiedBy" json:"verifier,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
Verification *ClaimVerification `gorm:"foreignKey:ClaimID" json:"verification,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Claim) TableName() string {
|
||||||
|
return "claims"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim status constants
|
||||||
|
const (
|
||||||
|
ClaimStatusPending = "pending"
|
||||||
|
ClaimStatusApproved = "approved"
|
||||||
|
ClaimStatusRejected = "rejected"
|
||||||
|
ClaimStatusWaitingOwner = "waiting_owner"
|
||||||
|
ClaimStatusVerified = "verified"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Claim) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if c.Status == "" {
|
||||||
|
c.Status = ClaimStatusPending
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods status check
|
||||||
|
func (c *Claim) IsPending() bool { return c.Status == ClaimStatusPending }
|
||||||
|
func (c *Claim) IsApproved() bool { return c.Status == ClaimStatusApproved }
|
||||||
|
func (c *Claim) IsRejected() bool { return c.Status == ClaimStatusRejected }
|
||||||
|
func (c *Claim) IsWaitingOwner() bool { return c.Status == ClaimStatusWaitingOwner }
|
||||||
|
func (c *Claim) IsVerified() bool { return c.Status == ClaimStatusVerified }
|
||||||
|
|
||||||
|
// Approve approves the claim
|
||||||
|
func (c *Claim) Approve(verifierID uint, notes string) {
|
||||||
|
c.Status = ClaimStatusApproved
|
||||||
|
c.VerifiedBy = &verifierID
|
||||||
|
now := time.Now()
|
||||||
|
c.VerifiedAt = &now
|
||||||
|
c.Notes = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject rejects the claim
|
||||||
|
func (c *Claim) Reject(verifierID uint, notes string) {
|
||||||
|
c.Status = ClaimStatusRejected
|
||||||
|
c.VerifiedBy = &verifierID
|
||||||
|
now := time.Now()
|
||||||
|
c.VerifiedAt = &now
|
||||||
|
c.Notes = notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimResponse represents claim data for API responses
|
||||||
|
type ClaimResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ItemID uint `json:"item_id"` // 0 jika Direct Claim
|
||||||
|
LostItemID uint `json:"lost_item_id"` // ✅ BARU: 0 jika Regular Claim
|
||||||
|
LostItemUserID *uint `json:"lost_item_user_id"`
|
||||||
|
ItemName string `json:"item_name"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ItemSecretDetails string `json:"item_secret_details"`
|
||||||
|
ProofURL string `json:"proof_url"`
|
||||||
|
Contact string `json:"contact"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
MatchPercentage *float64 `json:"match_percentage,omitempty"`
|
||||||
|
VerifiedAt *time.Time `json:"verified_at"`
|
||||||
|
VerifiedBy *uint `json:"verified_by"`
|
||||||
|
VerifierName string `json:"verifier_name,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Administrative Fields
|
||||||
|
BeritaAcaraNo string `json:"berita_acara_no,omitempty"`
|
||||||
|
BuktiSerahTerima string `json:"bukti_serah_terima,omitempty"`
|
||||||
|
CaseClosedAt *time.Time `json:"case_closed_at,omitempty"`
|
||||||
|
ReporterName string `json:"reporter_name"`
|
||||||
|
CaseClosedByName string `json:"case_closed_by_name,omitempty"`
|
||||||
|
Type string `json:"type"` // ✅ BARU: "regular" atau "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts Claim to ClaimResponse with SAFETY CHECKS
|
||||||
|
func (c *Claim) ToResponse() ClaimResponse {
|
||||||
|
// 1. Initialize Default Values
|
||||||
|
var itemID, lostItemID uint
|
||||||
|
itemName := "Unknown Item"
|
||||||
|
itemSecret := ""
|
||||||
|
beritaAcara := ""
|
||||||
|
buktiSerahTerima := ""
|
||||||
|
var caseClosedAt *time.Time
|
||||||
|
caseClosedByName := ""
|
||||||
|
reporterName := ""
|
||||||
|
claimType := "unknown"
|
||||||
|
|
||||||
|
// 2. LOGIC PENTING: Tentukan sumber data (Item vs LostItem)
|
||||||
|
|
||||||
|
// KASUS A: REGULAR CLAIM (Dari Barang Temuan)
|
||||||
|
if c.Item != nil {
|
||||||
|
claimType = "regular"
|
||||||
|
itemID = c.Item.ID
|
||||||
|
itemName = c.Item.Name
|
||||||
|
itemSecret = c.Item.SecretDetails
|
||||||
|
|
||||||
|
// Admin details hanya ada di Item Temuan
|
||||||
|
beritaAcara = c.Item.BeritaAcaraNo
|
||||||
|
buktiSerahTerima = c.Item.BuktiSerahTerima
|
||||||
|
caseClosedAt = c.Item.CaseClosedAt
|
||||||
|
reporterName = c.Item.ReporterName
|
||||||
|
|
||||||
|
if c.Item.CaseClosedBy_User != nil {
|
||||||
|
caseClosedByName = c.Item.CaseClosedBy_User.Name
|
||||||
|
}
|
||||||
|
} else if c.LostItem != nil {
|
||||||
|
// KASUS B: DIRECT CLAIM (Dari Barang Hilang)
|
||||||
|
claimType = "direct"
|
||||||
|
lostItemID = c.LostItem.ID
|
||||||
|
itemName = fmt.Sprintf("[DICARI] %s", c.LostItem.Name)
|
||||||
|
itemSecret = c.LostItem.Description // Gunakan deskripsi lost item sebagai rahasia
|
||||||
|
|
||||||
|
// Untuk direct claim, ReporterName adalah si User (Finder) yang membuat claim ini
|
||||||
|
if c.User.ID != 0 {
|
||||||
|
reporterName = c.User.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. User Info (Claimant)
|
||||||
|
userName := ""
|
||||||
|
if c.User.ID != 0 {
|
||||||
|
userName = c.User.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verifier Info
|
||||||
|
verifierName := ""
|
||||||
|
if c.Verifier != nil {
|
||||||
|
verifierName = c.Verifier.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Match Info
|
||||||
|
var matchPercentage *float64
|
||||||
|
if c.Verification != nil {
|
||||||
|
matchPercentage = &c.Verification.SimilarityScore
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClaimResponse{
|
||||||
|
ID: c.ID,
|
||||||
|
ItemID: itemID,
|
||||||
|
LostItemID: lostItemID, // ✅ Field Baru
|
||||||
|
ItemName: itemName,
|
||||||
|
UserID: c.UserID,
|
||||||
|
UserName: userName,
|
||||||
|
Description: c.Description,
|
||||||
|
ItemSecretDetails: itemSecret,
|
||||||
|
ProofURL: c.ProofURL,
|
||||||
|
Contact: c.Contact,
|
||||||
|
Status: c.Status,
|
||||||
|
Notes: c.Notes,
|
||||||
|
MatchPercentage: matchPercentage,
|
||||||
|
VerifiedAt: c.VerifiedAt,
|
||||||
|
VerifiedBy: c.VerifiedBy,
|
||||||
|
VerifierName: verifierName,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
|
||||||
|
BeritaAcaraNo: beritaAcara,
|
||||||
|
BuktiSerahTerima: buktiSerahTerima,
|
||||||
|
CaseClosedAt: caseClosedAt,
|
||||||
|
CaseClosedByName: caseClosedByName,
|
||||||
|
ReporterName: reporterName,
|
||||||
|
Type: claimType, // ✅ Field Baru
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimDetailResponse includes item description for verification
|
||||||
|
type ClaimDetailResponse struct {
|
||||||
|
ClaimResponse
|
||||||
|
ItemDescription string `json:"item_description"`
|
||||||
|
ItemSecretDetails string `json:"item_secret_details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToDetailResponse converts Claim to ClaimDetailResponse
|
||||||
|
func (c *Claim) ToDetailResponse() ClaimDetailResponse {
|
||||||
|
baseResponse := c.ToResponse()
|
||||||
|
|
||||||
|
itemDescription := ""
|
||||||
|
itemSecretDetails := ""
|
||||||
|
|
||||||
|
// ✅ Logic Aman: Cek Item dulu, kalau nil cek LostItem
|
||||||
|
if c.Item != nil {
|
||||||
|
itemDescription = c.Item.Description
|
||||||
|
itemSecretDetails = c.Item.SecretDetails
|
||||||
|
} else if c.LostItem != nil {
|
||||||
|
itemDescription = c.LostItem.Description
|
||||||
|
itemSecretDetails = c.LostItem.Description // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClaimDetailResponse{
|
||||||
|
ClaimResponse: baseResponse,
|
||||||
|
ItemDescription: itemDescription,
|
||||||
|
ItemSecretDetails: itemSecretDetails,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/models/claim_verification.go
Normal file
78
internal/models/claim_verification.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// internal/models/claim_verification.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaimVerification represents verification data for a claim
|
||||||
|
type ClaimVerification struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ClaimID uint `gorm:"not null;uniqueIndex" json:"claim_id"`
|
||||||
|
Claim Claim `gorm:"foreignKey:ClaimID" json:"claim,omitempty"`
|
||||||
|
SimilarityScore float64 `gorm:"type:decimal(5,2);default:0" json:"similarity_score"` // Percentage match (0-100)
|
||||||
|
MatchedKeywords string `gorm:"type:text" json:"matched_keywords"` // Keywords that matched
|
||||||
|
VerificationNotes string `gorm:"type:text" json:"verification_notes"` // Manager's notes
|
||||||
|
IsAutoMatched bool `gorm:"default:false" json:"is_auto_matched"` // Was it auto-matched?
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for ClaimVerification model
|
||||||
|
func (ClaimVerification) TableName() string {
|
||||||
|
return "claim_verifications"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHighMatch checks if similarity score is high (>= 70%)
|
||||||
|
func (cv *ClaimVerification) IsHighMatch() bool {
|
||||||
|
return cv.SimilarityScore >= 70.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMediumMatch checks if similarity score is medium (50-69%)
|
||||||
|
func (cv *ClaimVerification) IsMediumMatch() bool {
|
||||||
|
return cv.SimilarityScore >= 50.0 && cv.SimilarityScore < 70.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLowMatch checks if similarity score is low (< 50%)
|
||||||
|
func (cv *ClaimVerification) IsLowMatch() bool {
|
||||||
|
return cv.SimilarityScore < 50.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchLevel returns the match level as string
|
||||||
|
func (cv *ClaimVerification) GetMatchLevel() string {
|
||||||
|
if cv.IsHighMatch() {
|
||||||
|
return "high"
|
||||||
|
} else if cv.IsMediumMatch() {
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimVerificationResponse represents verification data for API responses
|
||||||
|
type ClaimVerificationResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ClaimID uint `json:"claim_id"`
|
||||||
|
SimilarityScore float64 `json:"similarity_score"`
|
||||||
|
MatchLevel string `json:"match_level"`
|
||||||
|
MatchedKeywords string `json:"matched_keywords"`
|
||||||
|
VerificationNotes string `json:"verification_notes"`
|
||||||
|
IsAutoMatched bool `json:"is_auto_matched"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts ClaimVerification to ClaimVerificationResponse
|
||||||
|
func (cv *ClaimVerification) ToResponse() ClaimVerificationResponse {
|
||||||
|
return ClaimVerificationResponse{
|
||||||
|
ID: cv.ID,
|
||||||
|
ClaimID: cv.ClaimID,
|
||||||
|
SimilarityScore: cv.SimilarityScore,
|
||||||
|
MatchLevel: cv.GetMatchLevel(),
|
||||||
|
MatchedKeywords: cv.MatchedKeywords,
|
||||||
|
VerificationNotes: cv.VerificationNotes,
|
||||||
|
IsAutoMatched: cv.IsAutoMatched,
|
||||||
|
CreatedAt: cv.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
214
internal/models/item.go
Normal file
214
internal/models/item.go
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
// internal/models/item.go - FIXED VERSION dengan SecretDetails
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item represents a found item
|
||||||
|
type Item struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
CategoryID uint `gorm:"not null" json:"category_id"`
|
||||||
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
|
PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"`
|
||||||
|
Location string `gorm:"type:varchar(200);not null" json:"location"`
|
||||||
|
Description string `gorm:"type:text;not null" json:"description"`
|
||||||
|
SecretDetails string `gorm:"type:text;column:secret_details" json:"secret_details,omitempty"`
|
||||||
|
DateFound time.Time `gorm:"not null" json:"date_found"`
|
||||||
|
Status string `gorm:"type:varchar(50);default:'unclaimed'" json:"status"`
|
||||||
|
ReporterID uint `gorm:"not null" json:"reporter_id"`
|
||||||
|
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
||||||
|
ReporterName string `gorm:"type:varchar(100);not null" json:"reporter_name"`
|
||||||
|
ReporterContact string `gorm:"type:varchar(50);not null" json:"reporter_contact"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
|
||||||
|
// ✅ NEW FIELDS FOR CASE CLOSE
|
||||||
|
BeritaAcaraNo string `gorm:"type:varchar(100)" json:"berita_acara_no"`
|
||||||
|
BuktiSerahTerima string `gorm:"type:varchar(255)" json:"bukti_serah_terima"`
|
||||||
|
CaseClosedAt *time.Time `json:"case_closed_at"`
|
||||||
|
CaseClosedBy *uint `json:"case_closed_by"`
|
||||||
|
CaseClosedBy_User *User `gorm:"foreignKey:CaseClosedBy" json:"case_closed_by_user,omitempty"`
|
||||||
|
CaseClosedNotes string `gorm:"type:text" json:"case_closed_notes"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
Claims []Claim `gorm:"foreignKey:ItemID" json:"claims,omitempty"`
|
||||||
|
MatchResults []MatchResult `gorm:"foreignKey:ItemID" json:"match_results,omitempty"`
|
||||||
|
RevisionLogs []RevisionLog `gorm:"foreignKey:ItemID" json:"revision_logs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for Item model
|
||||||
|
func (Item) TableName() string {
|
||||||
|
return "items"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status constants
|
||||||
|
const (
|
||||||
|
ItemStatusUnclaimed = "unclaimed"
|
||||||
|
ItemStatusPendingClaim = "pending_claim"
|
||||||
|
ItemStatusVerified = "verified"
|
||||||
|
ItemStatusCaseClosed = "case_closed"
|
||||||
|
ItemStatusExpired = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BeforeCreate hook to set expiration date
|
||||||
|
func (i *Item) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if i.Status == "" {
|
||||||
|
i.Status = ItemStatusUnclaimed
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.ExpiresAt == nil {
|
||||||
|
expiresAt := i.DateFound.AddDate(0, 0, 90)
|
||||||
|
i.ExpiresAt = &expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired checks if item has expired
|
||||||
|
func (i *Item) IsExpired() bool {
|
||||||
|
if i.ExpiresAt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().After(*i.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanBeClaimed checks if item can be claimed
|
||||||
|
func (i *Item) CanBeClaimed() bool {
|
||||||
|
return i.Status == ItemStatusUnclaimed && !i.IsExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanBeEdited checks if item can be edited
|
||||||
|
func (i *Item) CanBeEdited() bool {
|
||||||
|
return i.Status != ItemStatusCaseClosed && i.Status != ItemStatusExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemPublicResponse represents item data for public view (without sensitive info)
|
||||||
|
type ItemPublicResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReporterID uint `json:"reporter_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Item) GetDisplayStatus() string {
|
||||||
|
// Jika case closed atau expired, return as-is
|
||||||
|
if i.Status == ItemStatusCaseClosed || i.Status == ItemStatusExpired {
|
||||||
|
return i.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ LOGIKA BARU: Check claims
|
||||||
|
if len(i.Claims) > 0 {
|
||||||
|
// Check apakah ada claim yang approved/completed
|
||||||
|
for _, claim := range i.Claims {
|
||||||
|
if claim.Status == ClaimStatusApproved || claim.Status == ClaimStatusVerified {
|
||||||
|
return ItemStatusVerified // atau "completed"
|
||||||
|
}
|
||||||
|
if claim.Status == ClaimStatusPending || claim.Status == ClaimStatusWaitingOwner {
|
||||||
|
return ItemStatusPendingClaim // "Sedang Diklaim"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: return status dari database
|
||||||
|
return i.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToPublicResponse converts Item to ItemPublicResponse (hides description, reporter details)
|
||||||
|
func (i *Item) ToPublicResponse() ItemPublicResponse {
|
||||||
|
categoryName := ""
|
||||||
|
if i.Category.ID != 0 {
|
||||||
|
categoryName = i.Category.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return ItemPublicResponse{
|
||||||
|
ID: i.ID,
|
||||||
|
Name: i.Name,
|
||||||
|
Category: categoryName,
|
||||||
|
PhotoURL: i.PhotoURL,
|
||||||
|
Location: i.Location,
|
||||||
|
DateFound: i.DateFound,
|
||||||
|
Status: i.GetDisplayStatus(), // ✅ GANTI JADI GetDisplayStatus()
|
||||||
|
ReporterID: i.ReporterID,
|
||||||
|
CreatedAt: i.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemDetailResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SecretDetails string `json:"secret_details"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReporterName string `json:"reporter_name"`
|
||||||
|
ReporterContact string `json:"reporter_contact"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
|
||||||
|
// ✅ NEW FIELDS
|
||||||
|
BeritaAcaraNo string `json:"berita_acara_no,omitempty"`
|
||||||
|
BuktiSerahTerima string `json:"bukti_serah_terima,omitempty"`
|
||||||
|
CaseClosedAt *time.Time `json:"case_closed_at,omitempty"`
|
||||||
|
CaseClosedByName string `json:"case_closed_by_name,omitempty"`
|
||||||
|
CaseClosedNotes string `json:"case_closed_notes,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED ToDetailResponse - MAPPING LENGKAP
|
||||||
|
func (i *Item) ToDetailResponse() ItemDetailResponse {
|
||||||
|
categoryName := ""
|
||||||
|
if i.Category.ID != 0 {
|
||||||
|
categoryName = i.Category.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
reporterName := i.ReporterName
|
||||||
|
if reporterName == "" && i.Reporter.ID != 0 {
|
||||||
|
reporterName = i.Reporter.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
reporterContact := i.ReporterContact
|
||||||
|
if reporterContact == "" && i.Reporter.ID != 0 {
|
||||||
|
reporterContact = i.Reporter.Phone
|
||||||
|
}
|
||||||
|
|
||||||
|
caseClosedByName := ""
|
||||||
|
if i.CaseClosedBy_User != nil && i.CaseClosedBy_User.ID != 0 {
|
||||||
|
caseClosedByName = i.CaseClosedBy_User.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return ItemDetailResponse{
|
||||||
|
ID: i.ID,
|
||||||
|
Name: i.Name,
|
||||||
|
Category: categoryName,
|
||||||
|
PhotoURL: i.PhotoURL,
|
||||||
|
Location: i.Location,
|
||||||
|
Description: i.Description,
|
||||||
|
SecretDetails: i.SecretDetails,
|
||||||
|
DateFound: i.DateFound,
|
||||||
|
Status: i.GetDisplayStatus(), // ✅ GANTI JADI GetDisplayStatus()
|
||||||
|
ReporterName: reporterName,
|
||||||
|
ReporterContact: reporterContact,
|
||||||
|
ExpiresAt: i.ExpiresAt,
|
||||||
|
BeritaAcaraNo: i.BeritaAcaraNo,
|
||||||
|
BuktiSerahTerima: i.BuktiSerahTerima,
|
||||||
|
CaseClosedAt: i.CaseClosedAt,
|
||||||
|
CaseClosedByName: caseClosedByName,
|
||||||
|
CaseClosedNotes: i.CaseClosedNotes,
|
||||||
|
CreatedAt: i.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/models/lost_item.go
Normal file
111
internal/models/lost_item.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LostItem struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
CategoryID uint `gorm:"not null" json:"category_id"`
|
||||||
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||||
|
Color string `gorm:"type:varchar(50)" json:"color"`
|
||||||
|
Location string `gorm:"type:varchar(200)" json:"location"`
|
||||||
|
Description string `gorm:"type:text;not null" json:"description"`
|
||||||
|
DateLost time.Time `gorm:"not null" json:"date_lost"`
|
||||||
|
Status string `gorm:"type:varchar(50);default:'active'" json:"status"`
|
||||||
|
MatchedAt *time.Time `json:"matched_at"`
|
||||||
|
|
||||||
|
// NEW: Direct claim fields
|
||||||
|
DirectClaimID *uint `json:"direct_claim_id"`
|
||||||
|
DirectClaim *Claim `gorm:"foreignKey:DirectClaimID" json:"direct_claim,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
MatchResults []MatchResult `gorm:"foreignKey:LostItemID" json:"match_results,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (LostItem) TableName() string {
|
||||||
|
return "lost_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
LostItemStatusActive = "active"
|
||||||
|
LostItemStatusFound = "found"
|
||||||
|
LostItemStatusExpired = "expired"
|
||||||
|
LostItemStatusClosed = "closed"
|
||||||
|
LostItemStatusClaimed = "claimed" // NEW: Status ketika ada yang klaim langsung ke owner
|
||||||
|
LostItemStatusCompleted = "completed" // NEW: Status ketika owner confirm sudah terima barang
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *LostItem) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if l.Status == "" {
|
||||||
|
l.Status = LostItemStatusActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LostItem) IsActive() bool {
|
||||||
|
return l.Status == LostItemStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LostItem) IsClaimed() bool {
|
||||||
|
return l.Status == LostItemStatusClaimed
|
||||||
|
}
|
||||||
|
|
||||||
|
type LostItemResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CategoryID uint `json:"category_id"` // ✅ TAMBAHKAN INI
|
||||||
|
Category string `json:"category"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DateLost time.Time `json:"date_lost"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DirectClaimID *uint `json:"direct_claim_id,omitempty"`
|
||||||
|
DirectClaimStatus string `json:"direct_claim_status,omitempty"`
|
||||||
|
DirectClaim *ClaimResponse `json:"direct_claim,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LostItem) ToResponse() LostItemResponse {
|
||||||
|
userName := ""
|
||||||
|
if l.User.ID != 0 {
|
||||||
|
userName = l.User.Name
|
||||||
|
}
|
||||||
|
categoryName := ""
|
||||||
|
if l.Category.ID != 0 {
|
||||||
|
categoryName = l.Category.Name
|
||||||
|
}
|
||||||
|
directClaimStatus := ""
|
||||||
|
var directClaimResp *ClaimResponse
|
||||||
|
if l.DirectClaim != nil {
|
||||||
|
directClaimStatus = l.DirectClaim.Status
|
||||||
|
resp := l.DirectClaim.ToResponse()
|
||||||
|
directClaimResp = &resp
|
||||||
|
}
|
||||||
|
return LostItemResponse{
|
||||||
|
ID: l.ID,
|
||||||
|
UserID: l.UserID,
|
||||||
|
UserName: userName,
|
||||||
|
Name: l.Name,
|
||||||
|
CategoryID: l.CategoryID, // ✅ TAMBAHKAN INI
|
||||||
|
Category: categoryName,
|
||||||
|
Color: l.Color,
|
||||||
|
Location: l.Location,
|
||||||
|
Description: l.Description,
|
||||||
|
DateLost: l.DateLost,
|
||||||
|
Status: l.Status,
|
||||||
|
CreatedAt: l.CreatedAt,
|
||||||
|
DirectClaimID: l.DirectClaimID,
|
||||||
|
DirectClaimStatus: directClaimStatus,
|
||||||
|
DirectClaim: directClaimResp,
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/models/match_result.go
Normal file
129
internal/models/match_result.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
// internal/models/match_result.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchResult represents auto-matching result between lost item and found item
|
||||||
|
type MatchResult struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
LostItemID uint `gorm:"not null" json:"lost_item_id"`
|
||||||
|
LostItem LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"`
|
||||||
|
ItemID uint `gorm:"not null" json:"item_id"`
|
||||||
|
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
|
||||||
|
SimilarityScore float64 `gorm:"type:decimal(5,2)" json:"similarity_score"` // Percentage match (0-100)
|
||||||
|
MatchedFields string `gorm:"type:text" json:"matched_fields"` // JSON of matched fields
|
||||||
|
MatchedAt time.Time `gorm:"not null" json:"matched_at"`
|
||||||
|
IsNotified bool `gorm:"default:false" json:"is_notified"` // Was user notified?
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for MatchResult model
|
||||||
|
func (MatchResult) TableName() string {
|
||||||
|
return "match_results"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeCreate hook
|
||||||
|
func (mr *MatchResult) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if mr.MatchedAt.IsZero() {
|
||||||
|
mr.MatchedAt = time.Now()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHighMatch checks if similarity score is high (>= 70%)
|
||||||
|
func (mr *MatchResult) IsHighMatch() bool {
|
||||||
|
return mr.SimilarityScore >= 70.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMediumMatch checks if similarity score is medium (50-69%)
|
||||||
|
func (mr *MatchResult) IsMediumMatch() bool {
|
||||||
|
return mr.SimilarityScore >= 50.0 && mr.SimilarityScore < 70.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLowMatch checks if similarity score is low (< 50%)
|
||||||
|
func (mr *MatchResult) IsLowMatch() bool {
|
||||||
|
return mr.SimilarityScore < 50.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchLevel returns the match level as string
|
||||||
|
func (mr *MatchResult) GetMatchLevel() string {
|
||||||
|
if mr.IsHighMatch() {
|
||||||
|
return "high"
|
||||||
|
} else if mr.IsMediumMatch() {
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchResultResponse represents match result data for API responses
|
||||||
|
type MatchResultResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
LostItemID uint `json:"lost_item_id"`
|
||||||
|
LostItemName string `json:"lost_item_name"`
|
||||||
|
ItemID uint `json:"item_id"`
|
||||||
|
ItemName string `json:"item_name"`
|
||||||
|
ItemPhotoURL string `json:"item_photo_url"`
|
||||||
|
ItemLocation string `json:"item_location"`
|
||||||
|
ItemDateFound time.Time `json:"item_date_found"`
|
||||||
|
ItemStatus string `json:"item_status"`
|
||||||
|
SimilarityScore float64 `json:"similarity_score"`
|
||||||
|
MatchLevel string `json:"match_level"`
|
||||||
|
MatchedFields string `json:"matched_fields"`
|
||||||
|
MatchedAt time.Time `json:"matched_at"`
|
||||||
|
IsNotified bool `json:"is_notified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts MatchResult to MatchResultResponse
|
||||||
|
func (mr *MatchResult) ToResponse() MatchResultResponse {
|
||||||
|
lostItemName := ""
|
||||||
|
if mr.LostItem.ID != 0 {
|
||||||
|
lostItemName = mr.LostItem.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
itemName := ""
|
||||||
|
itemPhotoURL := ""
|
||||||
|
itemLocation := ""
|
||||||
|
itemDateFound := time.Time{}
|
||||||
|
itemStatus := ""
|
||||||
|
if mr.Item.ID != 0 {
|
||||||
|
itemName = mr.Item.Name
|
||||||
|
itemPhotoURL = mr.Item.PhotoURL
|
||||||
|
itemLocation = mr.Item.Location
|
||||||
|
itemDateFound = mr.Item.DateFound
|
||||||
|
itemStatus = mr.Item.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
return MatchResultResponse{
|
||||||
|
ID: mr.ID,
|
||||||
|
LostItemID: mr.LostItemID,
|
||||||
|
LostItemName: lostItemName,
|
||||||
|
ItemID: mr.ItemID,
|
||||||
|
ItemName: itemName,
|
||||||
|
ItemPhotoURL: itemPhotoURL,
|
||||||
|
ItemLocation: itemLocation,
|
||||||
|
ItemDateFound: itemDateFound,
|
||||||
|
ItemStatus: itemStatus,
|
||||||
|
SimilarityScore: mr.SimilarityScore,
|
||||||
|
MatchLevel: mr.GetMatchLevel(),
|
||||||
|
MatchedFields: mr.MatchedFields,
|
||||||
|
MatchedAt: mr.MatchedAt,
|
||||||
|
IsNotified: mr.IsNotified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemMatchResponse represents simplified item data for matching display
|
||||||
|
type ItemMatchResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Similarity float64 `json:"similarity"`
|
||||||
|
}
|
||||||
128
internal/models/notification.go
Normal file
128
internal/models/notification.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
// internal/models/notification.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification represents a notification for a user
|
||||||
|
type Notification struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
Type string `gorm:"type:varchar(50);not null" json:"type"` // match_found, claim_approved, claim_rejected, item_expired, etc.
|
||||||
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
|
Message string `gorm:"type:text;not null" json:"message"`
|
||||||
|
EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, match, etc.
|
||||||
|
EntityID *uint `json:"entity_id"`
|
||||||
|
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||||
|
ReadAt *time.Time `json:"read_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for Notification model
|
||||||
|
func (Notification) TableName() string {
|
||||||
|
return "notifications"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification type constants
|
||||||
|
const (
|
||||||
|
NotificationMatchFound = "match_found"
|
||||||
|
NotificationClaimApproved = "claim_approved"
|
||||||
|
NotificationClaimRejected = "claim_rejected"
|
||||||
|
NotificationItemExpired = "item_expired"
|
||||||
|
NotificationNewClaim = "new_claim"
|
||||||
|
NotificationItemReturned = "item_returned"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkAsRead marks the notification as read
|
||||||
|
func (n *Notification) MarkAsRead() {
|
||||||
|
n.IsRead = true
|
||||||
|
now := time.Now()
|
||||||
|
n.ReadAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationResponse represents notification data for API responses
|
||||||
|
type NotificationResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID *uint `json:"entity_id"`
|
||||||
|
IsRead bool `json:"is_read"`
|
||||||
|
ReadAt *time.Time `json:"read_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts Notification to NotificationResponse
|
||||||
|
func (n *Notification) ToResponse() NotificationResponse {
|
||||||
|
return NotificationResponse{
|
||||||
|
ID: n.ID,
|
||||||
|
Type: n.Type,
|
||||||
|
Title: n.Title,
|
||||||
|
Message: n.Message,
|
||||||
|
EntityType: n.EntityType,
|
||||||
|
EntityID: n.EntityID,
|
||||||
|
IsRead: n.IsRead,
|
||||||
|
ReadAt: n.ReadAt,
|
||||||
|
CreatedAt: n.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNotification creates a new notification
|
||||||
|
func CreateNotification(db *gorm.DB, userID uint, notifType, title, message, entityType string, entityID *uint) error {
|
||||||
|
notification := &Notification{
|
||||||
|
UserID: userID,
|
||||||
|
Type: notifType,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityID: entityID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Create(notification).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMatchNotification creates a notification for match found
|
||||||
|
func CreateMatchNotification(db *gorm.DB, userID uint, itemName string, matchID uint) error {
|
||||||
|
return CreateNotification(
|
||||||
|
db,
|
||||||
|
userID,
|
||||||
|
NotificationMatchFound,
|
||||||
|
"Barang yang Mirip Ditemukan!",
|
||||||
|
"Kami menemukan barang yang mirip dengan laporan kehilangan Anda: "+itemName,
|
||||||
|
"match",
|
||||||
|
&matchID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClaimApprovedNotification creates a notification for approved claim
|
||||||
|
func CreateClaimApprovedNotification(db *gorm.DB, userID uint, itemName string, claimID uint) error {
|
||||||
|
return CreateNotification(
|
||||||
|
db,
|
||||||
|
userID,
|
||||||
|
NotificationClaimApproved,
|
||||||
|
"Klaim Disetujui!",
|
||||||
|
"Klaim Anda untuk barang '"+itemName+"' telah disetujui. Silakan ambil barang di tempat yang ditentukan.",
|
||||||
|
"claim",
|
||||||
|
&claimID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClaimRejectedNotification creates a notification for rejected claim
|
||||||
|
func CreateClaimRejectedNotification(db *gorm.DB, userID uint, itemName, reason string, claimID uint) error {
|
||||||
|
return CreateNotification(
|
||||||
|
db,
|
||||||
|
userID,
|
||||||
|
NotificationClaimRejected,
|
||||||
|
"Klaim Ditolak",
|
||||||
|
"Klaim Anda untuk barang '"+itemName+"' ditolak. Alasan: "+reason,
|
||||||
|
"claim",
|
||||||
|
&claimID,
|
||||||
|
)
|
||||||
|
}
|
||||||
12
internal/models/permission.go
Normal file
12
internal/models/permission.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Permission struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Slug string `gorm:"unique;not null" json:"slug"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
73
internal/models/revision_log.go
Normal file
73
internal/models/revision_log.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// internal/models/revision_log.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RevisionLog represents revision history for item edits
|
||||||
|
type RevisionLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ItemID uint `gorm:"not null" json:"item_id"`
|
||||||
|
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"` // Who made the edit
|
||||||
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
|
FieldName string `gorm:"type:varchar(50);not null" json:"field_name"` // Which field was edited
|
||||||
|
OldValue string `gorm:"type:text" json:"old_value"`
|
||||||
|
NewValue string `gorm:"type:text" json:"new_value"`
|
||||||
|
Reason string `gorm:"type:text" json:"reason"` // Why was it edited
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for RevisionLog model
|
||||||
|
func (RevisionLog) TableName() string {
|
||||||
|
return "revision_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevisionLogResponse represents revision log data for API responses
|
||||||
|
type RevisionLogResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ItemID uint `json:"item_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
FieldName string `json:"field_name"`
|
||||||
|
OldValue string `json:"old_value"`
|
||||||
|
NewValue string `json:"new_value"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse converts RevisionLog to RevisionLogResponse
|
||||||
|
func (rl *RevisionLog) ToResponse() RevisionLogResponse {
|
||||||
|
userName := ""
|
||||||
|
if rl.User.ID != 0 {
|
||||||
|
userName = rl.User.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return RevisionLogResponse{
|
||||||
|
ID: rl.ID,
|
||||||
|
ItemID: rl.ItemID,
|
||||||
|
UserName: userName,
|
||||||
|
FieldName: rl.FieldName,
|
||||||
|
OldValue: rl.OldValue,
|
||||||
|
NewValue: rl.NewValue,
|
||||||
|
Reason: rl.Reason,
|
||||||
|
CreatedAt: rl.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRevisionLog creates a new revision log entry
|
||||||
|
func CreateRevisionLog(db *gorm.DB, itemID, userID uint, fieldName, oldValue, newValue, reason string) error {
|
||||||
|
log := &RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: fieldName,
|
||||||
|
OldValue: oldValue,
|
||||||
|
NewValue: newValue,
|
||||||
|
Reason: reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Create(log).Error
|
||||||
|
}
|
||||||
56
internal/models/role.go
Normal file
56
internal/models/role.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// internal/models/role.go
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role represents a user role in the system
|
||||||
|
type Role struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"`
|
||||||
|
Description string `gorm:"type:text" json:"description"`
|
||||||
|
|
||||||
|
// ✅ Tambahkan relasi Permissions
|
||||||
|
Permissions []Permission `gorm:"many2many:role_permissions;" json:"permissions,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Users []User `gorm:"foreignKey:RoleID" json:"users,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for Role model
|
||||||
|
func (Role) TableName() string {
|
||||||
|
return "roles"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role constants
|
||||||
|
const (
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleManager = "manager"
|
||||||
|
RoleUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetRoleID returns the ID for a given role name
|
||||||
|
func GetRoleID(db *gorm.DB, roleName string) (uint, error) {
|
||||||
|
var role Role
|
||||||
|
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return role.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidRole checks if a role name is valid
|
||||||
|
func IsValidRole(roleName string) bool {
|
||||||
|
validRoles := []string{RoleAdmin, RoleManager, RoleUser}
|
||||||
|
for _, r := range validRoles {
|
||||||
|
if r == roleName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
147
internal/models/user.go
Normal file
147
internal/models/user.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// internal/models/user.go - FIXED VERSION
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a user in the system
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
|
Email string `gorm:"type:varchar(100);uniqueIndex;not null" json:"email"`
|
||||||
|
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||||
|
NRP string `gorm:"type:varchar(20)" json:"nrp"` // ✅ Plain text
|
||||||
|
Phone string `gorm:"type:varchar(20)" json:"phone"` // ✅ Plain text
|
||||||
|
RoleID uint `gorm:"not null;default:3" json:"role_id"`
|
||||||
|
Role Role `gorm:"foreignKey:RoleID" json:"role,omitempty"`
|
||||||
|
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
|
||||||
|
LastLogin *time.Time `json:"last_login"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// User status constants
|
||||||
|
const (
|
||||||
|
UserStatusActive = "active"
|
||||||
|
UserStatusBlocked = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsActive checks if user is active
|
||||||
|
func (u *User) IsActive() bool {
|
||||||
|
return u.Status == UserStatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlocked checks if user is blocked
|
||||||
|
func (u *User) IsBlocked() bool {
|
||||||
|
return u.Status == UserStatusBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin checks if user is admin
|
||||||
|
func (u *User) IsAdmin() bool {
|
||||||
|
return u.Role.Name == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsManager checks if user is manager
|
||||||
|
func (u *User) IsManager() bool {
|
||||||
|
return u.Role.Name == "manager"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) HasPermission(permissionSlug string) bool {
|
||||||
|
// Jika Role atau Permissions belum di-load, return false (fail safe)
|
||||||
|
if u.Role.ID == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin (Role ID 1) biasanya bypass semua check, tapi sebaiknya tetap cek list
|
||||||
|
// untuk konsistensi database.
|
||||||
|
|
||||||
|
for _, perm := range u.Role.Permissions {
|
||||||
|
if perm.Slug == permissionSlug {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUser checks if user is regular user
|
||||||
|
func (u *User) IsUser() bool {
|
||||||
|
return u.Role.Name == "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserResponse represents user data for API responses
|
||||||
|
type UserResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
NRP string `json:"nrp,omitempty"` // ✅ Langsung dari database
|
||||||
|
Phone string `json:"phone,omitempty"` // ✅ Langsung dari database
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ToResponse converts User to UserResponse dengan DEKRIPSI
|
||||||
|
func (u *User) ToResponse() UserResponse {
|
||||||
|
response := UserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Name: u.Name,
|
||||||
|
Email: u.Email,
|
||||||
|
NRP: u.NRP, // ✅ Langsung assign
|
||||||
|
Phone: u.Phone, // ✅ Langsung assign
|
||||||
|
Status: u.Status,
|
||||||
|
LastLogin: u.LastLogin,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set role name
|
||||||
|
if u.Role.ID != 0 {
|
||||||
|
response.Role = u.Role.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ DEKRIPSI NRP
|
||||||
|
if u.NRP != "" {
|
||||||
|
decryptedNRP, err := utils.DecryptString(u.NRP)
|
||||||
|
if err == nil {
|
||||||
|
response.NRP = decryptedNRP
|
||||||
|
} else {
|
||||||
|
// Jika dekripsi gagal, kembalikan nilai asli (untuk backward compatibility)
|
||||||
|
response.NRP = u.NRP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ DEKRIPSI Phone
|
||||||
|
if u.Phone != "" {
|
||||||
|
decryptedPhone, err := utils.DecryptString(u.Phone)
|
||||||
|
if err == nil {
|
||||||
|
response.Phone = decryptedPhone
|
||||||
|
} else {
|
||||||
|
// Jika dekripsi gagal, kembalikan nilai asli (untuk backward compatibility)
|
||||||
|
response.Phone = u.Phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ToPublicResponse - untuk public access (hide sensitive data)
|
||||||
|
func (u *User) ToPublicResponse() UserResponse {
|
||||||
|
return UserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Name: u.Name,
|
||||||
|
Role: u.Role.Name,
|
||||||
|
Status: u.Status,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
// NRP & Phone tidak disertakan untuk security
|
||||||
|
}
|
||||||
|
}
|
||||||
93
internal/repositories/archive_repo.go
Normal file
93
internal/repositories/archive_repo.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// internal/repositories/archive_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArchiveRepository(db *gorm.DB) *ArchiveRepository {
|
||||||
|
return &ArchiveRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new archive record
|
||||||
|
func (r *ArchiveRepository) Create(archive *models.Archive) error {
|
||||||
|
return r.db.Create(archive).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds archive by ID
|
||||||
|
func (r *ArchiveRepository) FindByID(id uint) (*models.Archive, error) {
|
||||||
|
var archive models.Archive
|
||||||
|
err := r.db.Preload("Category").Preload("Claimer").Preload("Claimer.Role").First(&archive, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("archive not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &archive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all archived items with filters
|
||||||
|
func (r *ArchiveRepository) FindAll(page, limit int, reason, search string) ([]models.Archive, int64, error) {
|
||||||
|
var archives []models.Archive
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.Archive{})
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if reason != "" {
|
||||||
|
query = query.Where("archived_reason = ?", reason)
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
// GANTI ILIKE MENJADI LIKE
|
||||||
|
query = query.Where("name LIKE ? OR location LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Category").Preload("Claimer").Preload("Claimer.Role").
|
||||||
|
Order("archived_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&archives).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return archives, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByItemID finds archive by original item ID
|
||||||
|
func (r *ArchiveRepository) FindByItemID(itemID uint) (*models.Archive, error) {
|
||||||
|
var archive models.Archive
|
||||||
|
err := r.db.Where("item_id = ?", itemID).Preload("Category").Preload("Claimer").First(&archive).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("archive not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &archive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete permanently deletes an archive
|
||||||
|
func (r *ArchiveRepository) Delete(id uint) error {
|
||||||
|
return r.db.Unscoped().Delete(&models.Archive{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByReason counts archives by reason
|
||||||
|
func (r *ArchiveRepository) CountByReason(reason string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Archive{}).Where("archived_reason = ?", reason).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
105
internal/repositories/audit_log_repo.go
Normal file
105
internal/repositories/audit_log_repo.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// internal/repositories/audit_log_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLogRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditLogRepository(db *gorm.DB) *AuditLogRepository {
|
||||||
|
return &AuditLogRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new audit log
|
||||||
|
func (r *AuditLogRepository) Create(log *models.AuditLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds audit log by ID
|
||||||
|
func (r *AuditLogRepository) FindByID(id uint) (*models.AuditLog, error) {
|
||||||
|
var log models.AuditLog
|
||||||
|
err := r.db.Preload("User").Preload("User.Role").First(&log, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("audit log not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &log, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all audit logs with filters
|
||||||
|
func (r *AuditLogRepository) FindAll(page, limit int, action, entityType string, userID *uint) ([]models.AuditLog, int64, error) {
|
||||||
|
var logs []models.AuditLog
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.AuditLog{})
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if action != "" {
|
||||||
|
query = query.Where("action = ?", action)
|
||||||
|
}
|
||||||
|
if entityType != "" {
|
||||||
|
query = query.Where("entity_type = ?", entityType)
|
||||||
|
}
|
||||||
|
if userID != nil {
|
||||||
|
query = query.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("User").Preload("User.Role").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUser finds audit logs by user
|
||||||
|
func (r *AuditLogRepository) FindByUser(userID uint, page, limit int) ([]models.AuditLog, int64, error) {
|
||||||
|
return r.FindAll(page, limit, "", "", &userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByEntity finds audit logs by entity
|
||||||
|
func (r *AuditLogRepository) FindByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLog, int64, error) {
|
||||||
|
var logs []models.AuditLog
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.AuditLog{}).
|
||||||
|
Where("entity_type = ? AND entity_id = ?", entityType, entityID)
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("User").Preload("User.Role").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log creates a new audit log entry (helper method)
|
||||||
|
func (r *AuditLogRepository) Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
|
||||||
|
return models.CreateAuditLog(r.db, userID, action, entityType, entityID, details, ipAddress, userAgent)
|
||||||
|
}
|
||||||
102
internal/repositories/category_repo.go
Normal file
102
internal/repositories/category_repo.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// internal/repositories/category_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||||
|
return &CategoryRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new category
|
||||||
|
func (r *CategoryRepository) Create(category *models.Category) error {
|
||||||
|
return r.db.Create(category).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds category by ID
|
||||||
|
func (r *CategoryRepository) FindByID(id uint) (*models.Category, error) {
|
||||||
|
var category models.Category
|
||||||
|
err := r.db.First(&category, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("category not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBySlug finds category by slug
|
||||||
|
func (r *CategoryRepository) FindBySlug(slug string) (*models.Category, error) {
|
||||||
|
var category models.Category
|
||||||
|
err := r.db.Where("slug = ?", slug).First(&category).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("category not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all categories
|
||||||
|
func (r *CategoryRepository) FindAll() ([]models.Category, error) {
|
||||||
|
var categories []models.Category
|
||||||
|
err := r.db.Order("name ASC").Find(&categories).Error
|
||||||
|
return categories, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates category data
|
||||||
|
func (r *CategoryRepository) Update(category *models.Category) error {
|
||||||
|
return r.db.Save(category).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft deletes a category
|
||||||
|
func (r *CategoryRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Category{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryWithItemCount gets category with item count
|
||||||
|
func (r *CategoryRepository) GetCategoryWithItemCount(id uint) (*models.Category, int64, error) {
|
||||||
|
category, err := r.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := r.db.Model(&models.Item{}).Where("category_id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return category, count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllWithItemCount gets all categories with item counts
|
||||||
|
func (r *CategoryRepository) GetAllWithItemCount() ([]models.CategoryResponse, error) {
|
||||||
|
var categories []models.Category
|
||||||
|
if err := r.db.Order("name ASC").Find(&categories).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.CategoryResponse
|
||||||
|
for _, cat := range categories {
|
||||||
|
var count int64
|
||||||
|
if err := r.db.Model(&models.Item{}).Where("category_id = ?", cat.ID).Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := cat.ToResponse()
|
||||||
|
response.ItemCount = count
|
||||||
|
responses = append(responses, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
40
internal/repositories/chat_repo.go
Normal file
40
internal/repositories/chat_repo.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatRepository(db *gorm.DB) *ChatRepository {
|
||||||
|
return &ChatRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChatRepository) Create(chat *models.ChatMessage) error {
|
||||||
|
return r.db.Create(chat).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChatRepository) FindByUserID(userID uint, limit int) ([]models.ChatMessage, error) {
|
||||||
|
var chats []models.ChatMessage
|
||||||
|
err := r.db.Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&chats).Error
|
||||||
|
return chats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChatRepository) GetUserChatHistory(userID uint, limit int) ([]models.ChatMessage, error) {
|
||||||
|
var chats []models.ChatMessage
|
||||||
|
err := r.db.Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&chats).Error
|
||||||
|
return chats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ChatRepository) DeleteUserHistory(userID uint) error {
|
||||||
|
return r.db.Where("user_id = ?", userID).Delete(&models.ChatMessage{}).Error
|
||||||
|
}
|
||||||
166
internal/repositories/claim_repo.go
Normal file
166
internal/repositories/claim_repo.go
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
// internal/repositories/claim_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaimRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaimRepository(db *gorm.DB) *ClaimRepository {
|
||||||
|
return &ClaimRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new claim
|
||||||
|
func (r *ClaimRepository) Create(claim *models.Claim) error {
|
||||||
|
return r.db.Create(claim).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds claim by ID
|
||||||
|
func (r *ClaimRepository) FindByID(id uint) (*models.Claim, error) {
|
||||||
|
var claim models.Claim
|
||||||
|
err := r.db.
|
||||||
|
Preload("Item").
|
||||||
|
Preload("Item.Category").
|
||||||
|
Preload("Item.CaseClosedBy_User"). // ✅ ADD THIS
|
||||||
|
Preload("Item.CaseClosedBy_User.Role"). // ✅ ADD THIS
|
||||||
|
Preload("User").
|
||||||
|
Preload("User.Role").
|
||||||
|
Preload("Verifier").
|
||||||
|
Preload("Verifier.Role").
|
||||||
|
Preload("Verification").
|
||||||
|
First(&claim, id).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("claim not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &claim, nil
|
||||||
|
}
|
||||||
|
// FindAll returns all claims with filters
|
||||||
|
func (r *ClaimRepository) FindAll(page, limit int, status string, itemID, userID *uint) ([]models.Claim, int64, error) {
|
||||||
|
var claims []models.Claim
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.Claim{})
|
||||||
|
|
||||||
|
// Apply filters... (biarkan kode filter yang ada)
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if itemID != nil {
|
||||||
|
query = query.Where("item_id = ?", *itemID)
|
||||||
|
}
|
||||||
|
if userID != nil {
|
||||||
|
query = query.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total...
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// ✅ PERBAIKAN: Tambahkan Preload yang hilang di sini
|
||||||
|
err := query.
|
||||||
|
Preload("Item").
|
||||||
|
Preload("Item.Category").
|
||||||
|
Preload("Item.CaseClosedBy_User"). // <--- TAMBAHKAN INI
|
||||||
|
Preload("Item.CaseClosedBy_User.Role"). // <--- TAMBAHKAN INI
|
||||||
|
Preload("User").
|
||||||
|
Preload("User.Role").
|
||||||
|
Preload("Verifier").
|
||||||
|
Preload("Verifier.Role").
|
||||||
|
Preload("Verification").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&claims).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates claim data
|
||||||
|
func (r *ClaimRepository) Update(claim *models.Claim) error {
|
||||||
|
return r.db.Save(claim).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft deletes a claim
|
||||||
|
func (r *ClaimRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Claim{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckExistingClaim checks if user already claimed an item
|
||||||
|
func (r *ClaimRepository) CheckExistingClaim(userID, itemID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Claim{}).
|
||||||
|
Where("user_id = ? AND item_id = ? AND status != ?", userID, itemID, models.ClaimStatusRejected).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByItem finds claims for an item
|
||||||
|
func (r *ClaimRepository) FindByItem(itemID uint) ([]models.Claim, error) {
|
||||||
|
var claims []models.Claim
|
||||||
|
err := r.db.Where("item_id = ?", itemID).
|
||||||
|
Preload("User").Preload("User.Role").
|
||||||
|
Preload("Verification").
|
||||||
|
Order("created_at DESC").Find(&claims).Error
|
||||||
|
return claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUser finds claims by user
|
||||||
|
func (r *ClaimRepository) FindByUser(userID uint, page, limit int) ([]models.Claim, int64, error) {
|
||||||
|
var claims []models.Claim
|
||||||
|
var total int64
|
||||||
|
query := r.db.Model(&models.Claim{}).Where("user_id = ?", userID)
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.
|
||||||
|
Preload("Item").
|
||||||
|
Preload("Item.Category").
|
||||||
|
Preload("LostItem"). // ← TAMBAHKAN INI!
|
||||||
|
Preload("User").
|
||||||
|
Preload("User.Role").
|
||||||
|
Preload("Verification").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&claims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return claims, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByStatus counts claims by status
|
||||||
|
func (r *ClaimRepository) CountByStatus(status string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Claim{}).Where("status = ?", status).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPendingClaims finds all pending claims
|
||||||
|
func (r *ClaimRepository) FindPendingClaims(page, limit int) ([]models.Claim, int64, error) {
|
||||||
|
return r.FindAll(page, limit, models.ClaimStatusPending, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClaimRepository) CountAll() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Claim{}).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
67
internal/repositories/claim_verification_repo.go
Normal file
67
internal/repositories/claim_verification_repo.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// internal/repositories/claim_verification_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaimVerificationRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaimVerificationRepository(db *gorm.DB) *ClaimVerificationRepository {
|
||||||
|
return &ClaimVerificationRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new claim verification
|
||||||
|
func (r *ClaimVerificationRepository) Create(verification *models.ClaimVerification) error {
|
||||||
|
return r.db.Create(verification).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds claim verification by ID
|
||||||
|
func (r *ClaimVerificationRepository) FindByID(id uint) (*models.ClaimVerification, error) {
|
||||||
|
var verification models.ClaimVerification
|
||||||
|
err := r.db.Preload("Claim").First(&verification, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("claim verification not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &verification, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByClaimID finds claim verification by claim ID
|
||||||
|
func (r *ClaimVerificationRepository) FindByClaimID(claimID uint) (*models.ClaimVerification, error) {
|
||||||
|
var verification models.ClaimVerification
|
||||||
|
err := r.db.Where("claim_id = ?", claimID).Preload("Claim").First(&verification).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil // Return nil if not found (not an error)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &verification, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates claim verification
|
||||||
|
func (r *ClaimVerificationRepository) Update(verification *models.ClaimVerification) error {
|
||||||
|
return r.db.Save(verification).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a claim verification
|
||||||
|
func (r *ClaimVerificationRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.ClaimVerification{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindHighMatches finds high match verifications (>= 70%)
|
||||||
|
func (r *ClaimVerificationRepository) FindHighMatches() ([]models.ClaimVerification, error) {
|
||||||
|
var verifications []models.ClaimVerification
|
||||||
|
err := r.db.Where("similarity_score >= ?", 70.0).
|
||||||
|
Preload("Claim").Preload("Claim.Item").Preload("Claim.User").
|
||||||
|
Order("similarity_score DESC").Find(&verifications).Error
|
||||||
|
return verifications, err
|
||||||
|
}
|
||||||
254
internal/repositories/item_repo.go
Normal file
254
internal/repositories/item_repo.go
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
// internal/repositories/item_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ItemRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewItemRepository(db *gorm.DB) *ItemRepository {
|
||||||
|
return &ItemRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new item
|
||||||
|
func (r *ItemRepository) Create(item *models.Item) error {
|
||||||
|
return r.db.Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds item by ID
|
||||||
|
func (r *ItemRepository) FindByID(id uint) (*models.Item, error) {
|
||||||
|
var item models.Item
|
||||||
|
err := r.db.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Reporter").
|
||||||
|
Preload("Reporter.Role").
|
||||||
|
Preload("CaseClosedBy_User").
|
||||||
|
Preload("CaseClosedBy_User.Role").
|
||||||
|
Preload("Claims", "deleted_at IS NULL"). // ✅ TAMBAH INI!
|
||||||
|
First(&item, id).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("item not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ IMPLEMENTASI PROCEDURE 1: Archive Expired Items
|
||||||
|
// CallArchiveExpiredProcedure memanggil SP sp_archive_expired_items
|
||||||
|
func (r *ItemRepository) CallArchiveExpiredProcedure() (int, error) {
|
||||||
|
var archivedCount int
|
||||||
|
|
||||||
|
// Menggunakan transaksi untuk eksekusi procedure
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Eksekusi Procedure dengan variabel output session MySQL (@count)
|
||||||
|
if err := tx.Exec("CALL sp_archive_expired_items(@count)").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ambil nilai dari variabel output
|
||||||
|
// Kita menggunakan Raw SQL karena GORM tidak support OUT param secara native di semua driver
|
||||||
|
type Result struct {
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
var res Result
|
||||||
|
if err := tx.Raw("SELECT @count as count").Scan(&res).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
archivedCount = res.Count
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return archivedCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ IMPLEMENTASI PROCEDURE 2: Dashboard Stats
|
||||||
|
// GetDashboardStatsSP memanggil SP sp_get_dashboard_stats
|
||||||
|
func (r *ItemRepository) GetDashboardStatsSP() (map[string]int64, error) {
|
||||||
|
stats := make(map[string]int64)
|
||||||
|
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Eksekusi Procedure dengan 4 variabel output
|
||||||
|
query := "CALL sp_get_dashboard_stats(@total, @unclaimed, @verified, @pending)"
|
||||||
|
if err := tx.Exec(query).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Select nilai variabel tersebut
|
||||||
|
type Result struct {
|
||||||
|
Total int64
|
||||||
|
Unclaimed int64
|
||||||
|
Verified int64
|
||||||
|
Pending int64
|
||||||
|
}
|
||||||
|
var res Result
|
||||||
|
querySelect := "SELECT @total as total, @unclaimed as unclaimed, @verified as verified, @pending as pending"
|
||||||
|
|
||||||
|
if err := tx.Raw(querySelect).Scan(&res).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats["total_items"] = res.Total
|
||||||
|
stats["unclaimed_items"] = res.Unclaimed
|
||||||
|
stats["verified_items"] = res.Verified
|
||||||
|
stats["pending_claims"] = res.Pending
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all items with filters
|
||||||
|
// internal/repositories/item_repo.go
|
||||||
|
|
||||||
|
func (r *ItemRepository) FindAll(page, limit int, status, category, search string) ([]models.Item, int64, error) {
|
||||||
|
var items []models.Item
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.Item{})
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
if status == "!expired" {
|
||||||
|
query = query.Where("status NOT IN ?", []string{models.ItemStatusExpired, models.ItemStatusCaseClosed})
|
||||||
|
} else {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if category != "" {
|
||||||
|
query = query.Joins("JOIN categories ON categories.id = items.category_id").Where("categories.slug = ?", category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
query = query.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// ✅ FIX: Tambahkan Preload Claims untuk hitung status dinamis
|
||||||
|
err := query.
|
||||||
|
Preload("Category").
|
||||||
|
Preload("Reporter").
|
||||||
|
Preload("Reporter.Role").
|
||||||
|
Preload("Claims", "deleted_at IS NULL"). // ✅ TAMBAH INI!
|
||||||
|
Order("date_found DESC").
|
||||||
|
Offset(offset).
|
||||||
|
Limit(limit).
|
||||||
|
Find(&items).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates item data
|
||||||
|
func (r *ItemRepository) Update(item *models.Item) error {
|
||||||
|
return r.db.Save(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates item status
|
||||||
|
func (r *ItemRepository) UpdateStatus(id uint, status string) error {
|
||||||
|
return r.db.Model(&models.Item{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft deletes an item
|
||||||
|
func (r *ItemRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Item{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindExpired finds expired items
|
||||||
|
func (r *ItemRepository) FindExpired() ([]models.Item, error) {
|
||||||
|
var items []models.Item
|
||||||
|
now := time.Now()
|
||||||
|
err := r.db.Where("expires_at <= ? AND status = ?", now, models.ItemStatusUnclaimed).
|
||||||
|
Preload("Category").Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveItem moves item to archive
|
||||||
|
func (r *ItemRepository) ArchiveItem(item *models.Item, reason string, claimedBy *uint) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Create archive record
|
||||||
|
archive := models.CreateFromItem(item, reason, claimedBy)
|
||||||
|
if err := tx.Create(archive).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update item status
|
||||||
|
if err := tx.Model(item).Updates(map[string]interface{}{
|
||||||
|
"status": models.ItemStatusExpired,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByStatus counts items by status
|
||||||
|
func (r *ItemRepository) CountByStatus(status string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Item{}).Where("status = ?", status).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByReporter finds items by reporter ID
|
||||||
|
func (r *ItemRepository) FindByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
|
||||||
|
var items []models.Item
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.Item{}).Where("reporter_id = ?", reporterID)
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Category").Order("date_found DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchForMatching searches items for matching with lost items
|
||||||
|
func (r *ItemRepository) SearchForMatching(categoryID uint, name, color string) ([]models.Item, error) {
|
||||||
|
var items []models.Item
|
||||||
|
|
||||||
|
query := r.db.Where("status = ? AND category_id = ?", models.ItemStatusUnclaimed, categoryID)
|
||||||
|
|
||||||
|
if name != "" {
|
||||||
|
query = query.Where("name ILIKE ?", "%"+name+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.Preload("Category").Order("date_found DESC").Limit(10).Find(&items).Error
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ItemRepository) CountAll() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Item{}).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
156
internal/repositories/lost_item_repo.go
Normal file
156
internal/repositories/lost_item_repo.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// internal/repositories/lost_item_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LostItemRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLostItemRepository(db *gorm.DB) *LostItemRepository {
|
||||||
|
return &LostItemRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new lost item report
|
||||||
|
func (r *LostItemRepository) Create(lostItem *models.LostItem) error {
|
||||||
|
return r.db.Create(lostItem).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds lost item by ID
|
||||||
|
func (r *LostItemRepository) FindByID(id uint) (*models.LostItem, error) {
|
||||||
|
var lostItem models.LostItem
|
||||||
|
err := r.db.Preload("Category").Preload("User").Preload("User.Role").First(&lostItem, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("lost item not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &lostItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ NEW: FindByMatchedItemID (Dibutuhkan untuk VerifyClaim)
|
||||||
|
// Mencari lost item yang sudah di-match dengan item tertentu
|
||||||
|
func (r *LostItemRepository) FindByMatchedItemID(itemID uint) (*models.LostItem, error) {
|
||||||
|
var lostItem models.LostItem
|
||||||
|
// Asumsi: Anda punya kolom 'matched_item_id' atau logika matching tersimpan
|
||||||
|
// Jika logika matching ada di tabel 'match_results', query ini mungkin perlu disesuaikan.
|
||||||
|
// Namun, untuk struktur simple, kita cari berdasarkan relasi match
|
||||||
|
|
||||||
|
// Jika Anda menggunakan tabel terpisah (MatchResult), method ini mungkin tidak direct di sini,
|
||||||
|
// tapi jika LostItem punya field MatchedItemID:
|
||||||
|
err := r.db.Where("matched_item_id = ?", itemID).First(&lostItem).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &lostItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all lost items with filters
|
||||||
|
func (r *LostItemRepository) FindAll(page, limit int, status, category, search string, userID *uint) ([]models.LostItem, int64, error) {
|
||||||
|
var lostItems []models.LostItem
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.LostItem{})
|
||||||
|
|
||||||
|
// Filter by user if specified
|
||||||
|
if userID != nil {
|
||||||
|
query = query.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if category != "" {
|
||||||
|
query = query.Joins("JOIN categories ON categories.id = lost_items.category_id").Where("categories.slug = ?", category)
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
// ✅ FIX: Ganti ILIKE (Postgres) ke LIKE (MySQL Compatible)
|
||||||
|
// Jika pakai Postgres, ILIKE boleh dipakai. Jika MySQL, harus LIKE.
|
||||||
|
query = query.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Category").Preload("User").Preload("User.Role").
|
||||||
|
Order("date_lost DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&lostItems).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return lostItems, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates lost item data
|
||||||
|
func (r *LostItemRepository) Update(lostItem *models.LostItem) error {
|
||||||
|
return r.db.Save(lostItem).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus updates lost item status
|
||||||
|
func (r *LostItemRepository) UpdateStatus(id uint, status string) error {
|
||||||
|
return r.db.Model(&models.LostItem{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ NEW: UpdateStatusByUserAndCategory (Dibutuhkan untuk CloseCase)
|
||||||
|
// Mengubah status lost item milik user tertentu di kategori tertentu
|
||||||
|
// Berguna saat item 'Found' berubah jadi 'Closed'
|
||||||
|
func (r *LostItemRepository) UpdateStatusByUserAndCategory(userID, categoryID uint, oldStatus, newStatus string) error {
|
||||||
|
return r.db.Model(&models.LostItem{}).
|
||||||
|
Where("user_id = ? AND category_id = ? AND status = ?", userID, categoryID, oldStatus).
|
||||||
|
Update("status", newStatus).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft deletes a lost item
|
||||||
|
func (r *LostItemRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.LostItem{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByUser finds lost items by user ID
|
||||||
|
func (r *LostItemRepository) FindByUser(userID uint, page, limit int) ([]models.LostItem, int64, error) {
|
||||||
|
var lostItems []models.LostItem
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.LostItem{}).Where("user_id = ?", userID)
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Category").Order("date_lost DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&lostItems).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return lostItems, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByStatus counts lost items by status
|
||||||
|
func (r *LostItemRepository) CountByStatus(status string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.LostItem{}).Where("status = ?", status).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindActiveForMatching finds active lost items for matching
|
||||||
|
func (r *LostItemRepository) FindActiveForMatching(categoryID uint) ([]models.LostItem, error) {
|
||||||
|
var lostItems []models.LostItem
|
||||||
|
err := r.db.Where("status = ? AND category_id = ?", models.LostItemStatusActive, categoryID).
|
||||||
|
Preload("User").Find(&lostItems).Error
|
||||||
|
return lostItems, err
|
||||||
|
}
|
||||||
125
internal/repositories/match_result_repo.go
Normal file
125
internal/repositories/match_result_repo.go
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
// internal/repositories/match_result_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchResultRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatchResultRepository(db *gorm.DB) *MatchResultRepository {
|
||||||
|
return &MatchResultRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new match result
|
||||||
|
func (r *MatchResultRepository) Create(match *models.MatchResult) error {
|
||||||
|
return r.db.Create(match).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds match result by ID
|
||||||
|
func (r *MatchResultRepository) FindByID(id uint) (*models.MatchResult, error) {
|
||||||
|
var match models.MatchResult
|
||||||
|
err := r.db.Preload("LostItem").Preload("LostItem.User").
|
||||||
|
Preload("Item").Preload("Item.Category").
|
||||||
|
First(&match, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("match result not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &match, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all match results with filters
|
||||||
|
func (r *MatchResultRepository) FindAll(page, limit int, lostItemID, itemID *uint) ([]models.MatchResult, int64, error) {
|
||||||
|
var matches []models.MatchResult
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.MatchResult{})
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if lostItemID != nil {
|
||||||
|
query = query.Where("lost_item_id = ?", *lostItemID)
|
||||||
|
}
|
||||||
|
if itemID != nil {
|
||||||
|
query = query.Where("item_id = ?", *itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("LostItem").Preload("LostItem.User").
|
||||||
|
Preload("Item").Preload("Item.Category").
|
||||||
|
Order("similarity_score DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&matches).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByLostItem finds match results for a lost item
|
||||||
|
func (r *MatchResultRepository) FindByLostItem(lostItemID uint) ([]models.MatchResult, error) {
|
||||||
|
var matches []models.MatchResult
|
||||||
|
err := r.db.Where("lost_item_id = ?", lostItemID).
|
||||||
|
Preload("Item").Preload("Item.Category").
|
||||||
|
Order("similarity_score DESC").Find(&matches).Error
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByItem finds match results for an item
|
||||||
|
func (r *MatchResultRepository) FindByItem(itemID uint) ([]models.MatchResult, error) {
|
||||||
|
var matches []models.MatchResult
|
||||||
|
err := r.db.Where("item_id = ?", itemID).
|
||||||
|
Preload("LostItem").Preload("LostItem.User").
|
||||||
|
Order("similarity_score DESC").Find(&matches).Error
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates match result
|
||||||
|
func (r *MatchResultRepository) Update(match *models.MatchResult) error {
|
||||||
|
return r.db.Save(match).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsNotified marks match result as notified
|
||||||
|
func (r *MatchResultRepository) MarkAsNotified(id uint) error {
|
||||||
|
return r.db.Model(&models.MatchResult{}).Where("id = ?", id).Update("is_notified", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindUnnotifiedMatches finds match results that haven't been notified
|
||||||
|
func (r *MatchResultRepository) FindUnnotifiedMatches() ([]models.MatchResult, error) {
|
||||||
|
var matches []models.MatchResult
|
||||||
|
err := r.db.Where("is_notified = ?", false).
|
||||||
|
Preload("LostItem").Preload("LostItem.User").
|
||||||
|
Preload("Item").Preload("Item.Category").
|
||||||
|
Order("matched_at ASC").Find(&matches).Error
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a match result
|
||||||
|
func (r *MatchResultRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.MatchResult{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckExistingMatch checks if a match already exists
|
||||||
|
func (r *MatchResultRepository) CheckExistingMatch(lostItemID, itemID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.MatchResult{}).
|
||||||
|
Where("lost_item_id = ? AND item_id = ?", lostItemID, itemID).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
104
internal/repositories/notification_repo.go
Normal file
104
internal/repositories/notification_repo.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// internal/repositories/notification_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotificationRepository(db *gorm.DB) *NotificationRepository {
|
||||||
|
return &NotificationRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new notification
|
||||||
|
func (r *NotificationRepository) Create(notification *models.Notification) error {
|
||||||
|
return r.db.Create(notification).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds notification by ID
|
||||||
|
func (r *NotificationRepository) FindByID(id uint) (*models.Notification, error) {
|
||||||
|
var notification models.Notification
|
||||||
|
err := r.db.Preload("User").First(¬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)
|
||||||
|
}
|
||||||
93
internal/repositories/revision_log_repo.go
Normal file
93
internal/repositories/revision_log_repo.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// internal/repositories/revision_log_repo.go
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RevisionLogRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRevisionLogRepository(db *gorm.DB) *RevisionLogRepository {
|
||||||
|
return &RevisionLogRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new revision log
|
||||||
|
func (r *RevisionLogRepository) Create(log *models.RevisionLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds revision log by ID
|
||||||
|
func (r *RevisionLogRepository) FindByID(id uint) (*models.RevisionLog, error) {
|
||||||
|
var log models.RevisionLog
|
||||||
|
err := r.db.Preload("Item").Preload("User").Preload("User.Role").First(&log, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("revision log not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &log, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByItem finds revision logs for an item
|
||||||
|
func (r *RevisionLogRepository) FindByItem(itemID uint, page, limit int) ([]models.RevisionLog, int64, error) {
|
||||||
|
var logs []models.RevisionLog
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.RevisionLog{}).Where("item_id = ?", itemID)
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("User").Preload("User.Role").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAll returns all revision logs with filters
|
||||||
|
func (r *RevisionLogRepository) FindAll(page, limit int, userID *uint) ([]models.RevisionLog, int64, error) {
|
||||||
|
var logs []models.RevisionLog
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&models.RevisionLog{})
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if userID != nil {
|
||||||
|
query = query.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Preload("Item").Preload("User").Preload("User.Role").
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log creates a new revision log entry (helper method)
|
||||||
|
func (r *RevisionLogRepository) Log(itemID, userID uint, fieldName, oldValue, newValue, reason string) error {
|
||||||
|
return models.CreateRevisionLog(r.db, itemID, userID, fieldName, oldValue, newValue, reason)
|
||||||
|
}
|
||||||
90
internal/repositories/role_repo.go
Normal file
90
internal/repositories/role_repo.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoleRepository(db *gorm.DB) *RoleRepository {
|
||||||
|
return &RoleRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAllWithPermissions returns all roles with their permissions (for Role Management)
|
||||||
|
func (r *RoleRepository) FindAllWithPermissions() ([]models.Role, error) {
|
||||||
|
var roles []models.Role
|
||||||
|
err := r.db.Preload("Permissions").Find(&roles).Error
|
||||||
|
return roles, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAllPermissions gets list of all available permissions
|
||||||
|
func (r *RoleRepository) FindAllPermissions() ([]models.Permission, error) {
|
||||||
|
var permissions []models.Permission
|
||||||
|
err := r.db.Find(&permissions).Error
|
||||||
|
return permissions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new role
|
||||||
|
func (r *RoleRepository) Create(role *models.Role) error {
|
||||||
|
return r.db.Create(role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID finds role by ID with permissions loaded
|
||||||
|
func (r *RoleRepository) FindByID(id uint) (*models.Role, error) {
|
||||||
|
var role models.Role
|
||||||
|
err := r.db.Preload("Permissions").First(&role, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("role not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ [PERBAIKAN] Menambahkan kembali method FindByName yang hilang
|
||||||
|
// FindByName finds role by name (Required by AuthService)
|
||||||
|
func (r *RoleRepository) FindByName(name string) (*models.Role, error) {
|
||||||
|
var role models.Role
|
||||||
|
err := r.db.Where("name = ?", name).First(&role).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("role not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePermissions syncs permissions for a role (Update logic)
|
||||||
|
func (r *RoleRepository) UpdatePermissions(role *models.Role, permissionIDs []uint) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Update basic info (name/desc)
|
||||||
|
if err := tx.Save(role).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch permission objects based on IDs
|
||||||
|
var permissions []models.Permission
|
||||||
|
if len(permissionIDs) > 0 {
|
||||||
|
if err := tx.Where("id IN ?", permissionIDs).Find(&permissions).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Replace associations (Hapus yang lama, set yang baru)
|
||||||
|
return tx.Model(role).Association("Permissions").Replace(permissions)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a role
|
||||||
|
func (r *RoleRepository) Delete(id uint) error {
|
||||||
|
// Hapus relasi di role_permissions dulu (GORM biasanya handle ini via foreign key constraint, tapi untuk aman bisa manual)
|
||||||
|
// Kita gunakan Unscoped atau Select clause jika perlu, tapi standard delete object sudah cukup jika constraint DB benar.
|
||||||
|
return r.db.Select("Permissions").Delete(&models.Role{ID: id}).Error
|
||||||
|
}
|
||||||
41
internal/repositories/transaction.go
Normal file
41
internal/repositories/transaction.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransactionManager struct untuk handle manual transaction
|
||||||
|
type TransactionManager struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTransactionManager(db *gorm.DB) *TransactionManager {
|
||||||
|
return &TransactionManager{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin memulai transaksi database
|
||||||
|
func (m *TransactionManager) Begin() *gorm.DB {
|
||||||
|
return m.db.Begin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback membatalkan transaksi jika terjadi error
|
||||||
|
func (m *TransactionManager) Rollback(tx *gorm.DB) {
|
||||||
|
// Rollback hanya jika transaksi belum di-commit/rollback
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
log.Printf("⚠️ Transaction panicked: %v", r)
|
||||||
|
} else if tx.Error != nil {
|
||||||
|
// Jika tx sudah error, rollback
|
||||||
|
tx.Rollback()
|
||||||
|
} else {
|
||||||
|
// Rollback aman
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit menyimpan perubahan permanen ke database
|
||||||
|
func (m *TransactionManager) Commit(tx *gorm.DB) error {
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
160
internal/repositories/user_repo.go
Normal file
160
internal/repositories/user_repo.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
// internal/repositories/user_repo.go - FIXED for ENCRYPTED NRP
|
||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(user *models.User) error {
|
||||||
|
return r.db.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
// ✅ Tambahkan .Preload("Role.Permissions")
|
||||||
|
err := r.db.Preload("Role").Preload("Role.Permissions").First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
// ✅ UPDATE: Preload Role DAN Role.Permissions
|
||||||
|
err := r.db.
|
||||||
|
Preload("Role").
|
||||||
|
Preload("Role.Permissions"). // Memuat daftar hak akses
|
||||||
|
Where("email = ?", email).
|
||||||
|
First(&user).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED: FindByNRP now accepts ENCRYPTED NRP
|
||||||
|
func (r *UserRepository) FindByNRP(nrp string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
// ✅ UPDATE: Preload Role DAN Role.Permissions
|
||||||
|
err := r.db.
|
||||||
|
Preload("Role").
|
||||||
|
Preload("Role.Permissions"). // Memuat daftar hak akses
|
||||||
|
Where("nrp = ?", nrp).
|
||||||
|
First(&user).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil // Return nil jika tidak ditemukan (bukan error sistem)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
func (r *UserRepository) FindAll(page, limit int) ([]models.User, int64, error) {
|
||||||
|
var users []models.User
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
if err := r.db.Model(&models.User{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := r.db.
|
||||||
|
Preload("Role"). // Cukup Role saja untuk list view
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&users).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, total, nil
|
||||||
|
}
|
||||||
|
func (r *UserRepository) Update(user *models.User) error {
|
||||||
|
return r.db.Save(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateRole(userID, roleID uint) error {
|
||||||
|
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("role_id", roleID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateStatus(userID uint, status string) error {
|
||||||
|
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("status", status).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.User{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) BlockUser(id uint) error {
|
||||||
|
return r.UpdateStatus(id, "blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UnblockUser(id uint) error {
|
||||||
|
return r.UpdateStatus(id, "active")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) CountByRole(roleID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.User{}).Where("role_id = ?", roleID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetUserStats(userID uint) (map[string]interface{}, error) {
|
||||||
|
var stats map[string]interface{} = make(map[string]interface{})
|
||||||
|
|
||||||
|
// Items reported
|
||||||
|
var itemCount int64
|
||||||
|
if err := r.db.Model(&models.Item{}).
|
||||||
|
Where("reporter_id = ?", userID).Count(&itemCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["items_reported"] = itemCount
|
||||||
|
|
||||||
|
// Lost items
|
||||||
|
var lostItemCount int64
|
||||||
|
if err := r.db.Model(&models.LostItem{}).
|
||||||
|
Where("user_id = ?", userID).Count(&lostItemCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["lost_items_reported"] = lostItemCount
|
||||||
|
|
||||||
|
// Claims
|
||||||
|
var claimCount int64
|
||||||
|
if err := r.db.Model(&models.Claim{}).
|
||||||
|
Where("user_id = ?", userID).Count(&claimCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["claims_made"] = claimCount
|
||||||
|
|
||||||
|
// Approved claims
|
||||||
|
var approvedClaimCount int64
|
||||||
|
if err := r.db.Model(&models.Claim{}).
|
||||||
|
Where("user_id = ? AND status = ?", userID, models.ClaimStatusApproved).
|
||||||
|
Count(&approvedClaimCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["claims_approved"] = approvedClaimCount
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
294
internal/routes/routes.go
Normal file
294
internal/routes/routes.go
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/controllers"
|
||||||
|
"lost-and-found/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupRoutes configures all application routes
|
||||||
|
func SetupRoutes(router *gin.Engine, db *gorm.DB, logger *zap.Logger) {
|
||||||
|
// Initialize controllers
|
||||||
|
authController := controllers.NewAuthController(db, logger)
|
||||||
|
userController := controllers.NewUserController(db)
|
||||||
|
itemController := controllers.NewItemController(db)
|
||||||
|
lostItemController := controllers.NewLostItemController(db)
|
||||||
|
claimController := controllers.NewClaimController(db)
|
||||||
|
matchController := controllers.NewMatchController(db)
|
||||||
|
categoryController := controllers.NewCategoryController(db)
|
||||||
|
archiveController := controllers.NewArchiveController(db)
|
||||||
|
adminController := controllers.NewAdminController(db)
|
||||||
|
reportController := controllers.NewReportController(db)
|
||||||
|
uploadController := controllers.NewUploadController(db)
|
||||||
|
managerController := controllers.NewManagerController(db)
|
||||||
|
roleController := controllers.NewRoleController(db)
|
||||||
|
notificationController := controllers.NewNotificationController(db)
|
||||||
|
aiController := controllers.NewAIController(db)
|
||||||
|
|
||||||
|
// API group
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
// ==========================================
|
||||||
|
// 1. Public Routes (No Auth)
|
||||||
|
// ==========================================
|
||||||
|
api.POST("/register", authController.Register)
|
||||||
|
api.POST("/login", authController.Login)
|
||||||
|
api.POST("/refresh-token", authController.RefreshToken)
|
||||||
|
|
||||||
|
api.GET("/categories", categoryController.GetAllCategories)
|
||||||
|
api.GET("/categories/:id", categoryController.GetCategoryByID)
|
||||||
|
|
||||||
|
// Optional Auth for Items (Public can view, Manager sees details)
|
||||||
|
itemsGroup := api.Group("/items")
|
||||||
|
itemsGroup.Use(middleware.OptionalJWTMiddleware(db))
|
||||||
|
{
|
||||||
|
itemsGroup.GET("", itemController.GetAllItems)
|
||||||
|
itemsGroup.GET("/:id", itemController.GetItemByID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. Authenticated Routes (Basic User Access)
|
||||||
|
// ==========================================
|
||||||
|
// Middleware: Cek Token Valid & User Active
|
||||||
|
authenticated := api.Group("")
|
||||||
|
authenticated.Use(middleware.JWTMiddleware(db))
|
||||||
|
{
|
||||||
|
|
||||||
|
authenticated.POST("/ai/chat", aiController.Chat)
|
||||||
|
authenticated.GET("/ai/history", aiController.GetHistory)
|
||||||
|
authenticated.DELETE("/ai/history", aiController.ClearHistory)
|
||||||
|
// Profile (Siapapun yang login bisa akses ini)
|
||||||
|
authenticated.GET("/me", authController.GetMe)
|
||||||
|
authenticated.GET("/user/profile", userController.GetProfile)
|
||||||
|
authenticated.PUT("/user/profile", userController.UpdateProfile)
|
||||||
|
authenticated.POST("/user/change-password", userController.ChangePassword)
|
||||||
|
authenticated.GET("/user/stats", userController.GetStats)
|
||||||
|
|
||||||
|
authenticated.POST("/lost-items/:id/direct-claim", lostItemController.DirectClaimToOwner)
|
||||||
|
authenticated.POST("/user/lost-items/:id/direct-claim", lostItemController.DirectClaimToOwner)
|
||||||
|
authenticated.POST("/claims/:id/respond", claimController.UserApproveClaim)
|
||||||
|
authenticated.POST("/claims/:id/complete", claimController.UserConfirmCompletion)
|
||||||
|
|
||||||
|
// --- User Features (Permission: item:create) ---
|
||||||
|
authenticated.POST("/items",
|
||||||
|
middleware.RequirePermission("item:create"),
|
||||||
|
itemController.CreateItem)
|
||||||
|
|
||||||
|
authenticated.GET("/user/items",
|
||||||
|
middleware.RequirePermission("item:read"),
|
||||||
|
itemController.GetItemsByReporter)
|
||||||
|
|
||||||
|
// --- Lost Items (Permission: item:create) ---
|
||||||
|
// Asumsi: Hak akses lapor kehilangan sama dengan lapor temuan
|
||||||
|
authenticated.POST("/lost-items",
|
||||||
|
middleware.RequirePermission("item:create"),
|
||||||
|
lostItemController.CreateLostItem)
|
||||||
|
|
||||||
|
authenticated.GET("/user/lost-items", lostItemController.GetLostItemsByUser)
|
||||||
|
authenticated.GET("/lost-items", lostItemController.GetAllLostItems)
|
||||||
|
authenticated.GET("/lost-items/:id", lostItemController.GetLostItemByID)
|
||||||
|
authenticated.PUT("/lost-items/:id", lostItemController.UpdateLostItem)
|
||||||
|
authenticated.PATCH("/lost-items/:id/status", lostItemController.UpdateLostItemStatus)
|
||||||
|
|
||||||
|
// ✅ FIX: Delete Lost Item menggunakan controller yang benar
|
||||||
|
authenticated.DELETE("/lost-items/:id",
|
||||||
|
middleware.RequirePermission("lost_item:delete"), // Permission baru
|
||||||
|
lostItemController.DeleteLostItem)
|
||||||
|
|
||||||
|
// --- Claims (Permission: claim:create) ---
|
||||||
|
authenticated.POST("/claims",
|
||||||
|
middleware.RequirePermission("claim:create"),
|
||||||
|
middleware.IdempotencyMiddleware(), // Prevent double submit
|
||||||
|
claimController.CreateClaim)
|
||||||
|
|
||||||
|
authenticated.GET("/user/claims",
|
||||||
|
middleware.RequirePermission("claim:read"),
|
||||||
|
claimController.GetClaimsByUser)
|
||||||
|
|
||||||
|
// ✅ FIX: Tambahkan endpoint update dan delete claim user
|
||||||
|
authenticated.PUT("/claims/:id",
|
||||||
|
middleware.RequirePermission("claim:create"), // User boleh edit claim sendiri
|
||||||
|
claimController.UpdateClaim)
|
||||||
|
|
||||||
|
authenticated.DELETE("/claims/:id",
|
||||||
|
middleware.RequirePermission("claim:create"), // User boleh delete claim sendiri
|
||||||
|
claimController.DeleteClaim)
|
||||||
|
|
||||||
|
|
||||||
|
// --- Matching ---
|
||||||
|
authenticated.GET("/lost-items/:id/matches", matchController.GetMatchesForLostItem)
|
||||||
|
authenticated.POST("/lost-items/:id/find-similar", matchController.FindSimilarItems)
|
||||||
|
// --- Notifications ---
|
||||||
|
authenticated.GET("/notifications", notificationController.GetUserNotifications)
|
||||||
|
authenticated.PATCH("/notifications/:id/read", notificationController.MarkAsRead)
|
||||||
|
authenticated.PATCH("/notifications/read-all", notificationController.MarkAllAsRead)
|
||||||
|
|
||||||
|
authenticated.POST("/user/claims/:id/respond", claimController.UserApproveClaim)
|
||||||
|
authenticated.POST("/user/claims/:id/complete", claimController.UserConfirmCompletion)
|
||||||
|
|
||||||
|
// --- Uploads ---
|
||||||
|
upload := authenticated.Group("/upload")
|
||||||
|
{
|
||||||
|
upload.POST("/item-image", uploadController.UploadItemImage)
|
||||||
|
upload.POST("/claim-proof", uploadController.UploadClaimProof)
|
||||||
|
upload.POST("/multiple", uploadController.UploadMultipleImages)
|
||||||
|
upload.DELETE("/delete", uploadController.DeleteImage)
|
||||||
|
upload.GET("/info", uploadController.GetImageInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. Protected Management Routes (Permission Based)
|
||||||
|
// ==========================================
|
||||||
|
// Area ini menggantikan Group Manager/Admin yang lama.
|
||||||
|
// Sekarang dikelompokkan berdasarkan FITUR, bukan ROLE.
|
||||||
|
management := api.Group("")
|
||||||
|
management.Use(middleware.JWTMiddleware(db))
|
||||||
|
{
|
||||||
|
// --- ITEM MANAGEMENT (Permission: item:update, item:delete) ---
|
||||||
|
management.PUT("/items/:id",
|
||||||
|
middleware.RequirePermission("item:update"),
|
||||||
|
itemController.UpdateItem)
|
||||||
|
|
||||||
|
management.PATCH("/items/:id/status",
|
||||||
|
middleware.RequirePermission("item:update"),
|
||||||
|
itemController.UpdateItemStatus)
|
||||||
|
|
||||||
|
management.DELETE("/items/:id",
|
||||||
|
middleware.RequirePermission("item:delete"),
|
||||||
|
itemController.DeleteItem)
|
||||||
|
|
||||||
|
management.GET("/items/:id/revisions",
|
||||||
|
middleware.RequirePermission("item:verify"),
|
||||||
|
itemController.GetItemRevisionHistory)
|
||||||
|
|
||||||
|
management.GET("/items/:id/matches",
|
||||||
|
middleware.RequirePermission("item:verify"),
|
||||||
|
matchController.GetMatchesForItem)
|
||||||
|
|
||||||
|
// --- CLAIM VERIFICATION (Permission: claim:approve, claim:reject) ---
|
||||||
|
management.GET("/claims",
|
||||||
|
middleware.RequirePermission("claim:read"), // Manager view all claims
|
||||||
|
claimController.GetAllClaims)
|
||||||
|
|
||||||
|
management.GET("/claims/:id",
|
||||||
|
middleware.RequirePermission("claim:read"),
|
||||||
|
claimController.GetClaimByID)
|
||||||
|
|
||||||
|
management.POST("/claims/:id/verify",
|
||||||
|
middleware.RequirePermission("claim:approve"),
|
||||||
|
middleware.IdempotencyMiddleware(),
|
||||||
|
claimController.VerifyClaim)
|
||||||
|
|
||||||
|
management.GET("/claims/:id/verification",
|
||||||
|
middleware.RequirePermission("claim:read"),
|
||||||
|
claimController.GetClaimVerification)
|
||||||
|
|
||||||
|
// ✅ FIX: Ganti endpoint CloseClaim -> CloseCase
|
||||||
|
management.POST("/claims/:id/close",
|
||||||
|
middleware.RequirePermission("claim:approve"),
|
||||||
|
claimController.CloseCase)
|
||||||
|
|
||||||
|
// ✅ FIX: Ganti endpoint ReopenClaim -> ReopenCase
|
||||||
|
management.POST("/claims/:id/reopen",
|
||||||
|
middleware.RequirePermission("claim:approve"),
|
||||||
|
claimController.ReopenCase)
|
||||||
|
|
||||||
|
// ✅ FIX: Ganti endpoint CancelApproval -> CancelClaimApproval
|
||||||
|
management.POST("/claims/:id/cancel-approval",
|
||||||
|
middleware.RequirePermission("claim:approve"),
|
||||||
|
claimController.CancelClaimApproval)
|
||||||
|
|
||||||
|
// --- ARCHIVES (Permission: item:read - for historical data) ---
|
||||||
|
management.GET("/archives",
|
||||||
|
middleware.RequirePermission("item:read"),
|
||||||
|
archiveController.GetAllArchives)
|
||||||
|
|
||||||
|
management.GET("/archives/:id",
|
||||||
|
middleware.RequirePermission("item:read"),
|
||||||
|
archiveController.GetArchiveByID)
|
||||||
|
|
||||||
|
management.GET("/archives/stats",
|
||||||
|
middleware.RequirePermission("item:read"),
|
||||||
|
archiveController.GetArchiveStats)
|
||||||
|
|
||||||
|
// --- REPORTS (Permission: report:export) ---
|
||||||
|
management.POST("/reports/export",
|
||||||
|
middleware.RequirePermission("report:export"),
|
||||||
|
reportController.ExportReport)
|
||||||
|
|
||||||
|
// --- DASHBOARD (Permission: user:read - as proxy for dashboard access) ---
|
||||||
|
management.GET("/manager/dashboard",
|
||||||
|
middleware.RequirePermission("item:verify"),
|
||||||
|
managerController.GetDashboardStats)
|
||||||
|
|
||||||
|
management.GET("/admin/dashboard",
|
||||||
|
middleware.RequirePermission("user:read"),
|
||||||
|
adminController.GetDashboardStats)
|
||||||
|
|
||||||
|
// --- USER MANAGEMENT (Permission: user:read, user:update, user:block) ---
|
||||||
|
// Biasa dilakukan oleh Admin
|
||||||
|
management.GET("/admin/users",
|
||||||
|
middleware.RequirePermission("user:read"),
|
||||||
|
userController.GetAllUsers)
|
||||||
|
|
||||||
|
management.GET("/admin/users/:id",
|
||||||
|
middleware.RequirePermission("user:read"),
|
||||||
|
userController.GetUserByID)
|
||||||
|
|
||||||
|
management.PATCH("/admin/users/:id/role",
|
||||||
|
middleware.RequirePermission("user:update"),
|
||||||
|
userController.UpdateUserRole)
|
||||||
|
|
||||||
|
management.POST("/admin/users/:id/block",
|
||||||
|
middleware.RequirePermission("user:block"),
|
||||||
|
userController.BlockUser)
|
||||||
|
|
||||||
|
management.POST("/admin/users/:id/unblock",
|
||||||
|
middleware.RequirePermission("user:block"),
|
||||||
|
userController.UnblockUser)
|
||||||
|
|
||||||
|
management.DELETE("/admin/users/:id",
|
||||||
|
middleware.RequirePermission("user:block"), // Menggunakan permission block untuk delete
|
||||||
|
userController.DeleteUser)
|
||||||
|
|
||||||
|
// --- AUDIT LOGS (Permission: user:read - biasanya Admin) ---
|
||||||
|
management.GET("/admin/audit-logs",
|
||||||
|
middleware.RequirePermission("user:read"),
|
||||||
|
adminController.GetAuditLogs)
|
||||||
|
|
||||||
|
// --- CATEGORY MANAGEMENT (Permission: item:update - Admin Only usually) ---
|
||||||
|
// Note: Anda mungkin perlu menambahkan permission 'category:create' di seed.sql
|
||||||
|
// Untuk sekarang kita gunakan 'item:update' atau 'user:update' sebagai proxy
|
||||||
|
management.POST("/categories",
|
||||||
|
middleware.RequirePermission("user:update"),
|
||||||
|
categoryController.CreateCategory)
|
||||||
|
|
||||||
|
management.PUT("/categories/:id",
|
||||||
|
middleware.RequirePermission("user:update"),
|
||||||
|
categoryController.UpdateCategory)
|
||||||
|
|
||||||
|
management.DELETE("/categories/:id",
|
||||||
|
middleware.RequirePermission("user:update"),
|
||||||
|
categoryController.DeleteCategory)
|
||||||
|
|
||||||
|
// ✅ Endpoint baru untuk test Procedure SQL
|
||||||
|
management.POST("/admin/archive/trigger",
|
||||||
|
middleware.RequireRole("admin"), // Hanya admin
|
||||||
|
adminController.TriggerAutoArchive)
|
||||||
|
|
||||||
|
management.GET("/admin/dashboard/fast",
|
||||||
|
middleware.RequireRole("admin", "manager"),
|
||||||
|
adminController.GetFastDashboardStats)
|
||||||
|
|
||||||
|
management.GET("/admin/roles", middleware.RequireRole("admin"), roleController.GetRoles)
|
||||||
|
management.GET("/admin/permissions", middleware.RequireRole("admin"), roleController.GetPermissions)
|
||||||
|
management.POST("/admin/roles", middleware.RequireRole("admin"), roleController.CreateRole)
|
||||||
|
management.PUT("/admin/roles/:id", middleware.RequireRole("admin"), roleController.UpdateRole)
|
||||||
|
management.DELETE("/admin/roles/:id", middleware.RequireRole("admin"), roleController.DeleteRole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
300
internal/services/ai_service.go
Normal file
300
internal/services/ai_service.go
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AIService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
chatRepo *repositories.ChatRepository
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
lostItemRepo *repositories.LostItemRepository
|
||||||
|
groqAPIKey string
|
||||||
|
groqModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAIService(db *gorm.DB) *AIService {
|
||||||
|
model := os.Getenv("GROQ_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
model = "llama-3.3-70b-versatile" // Default model
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AIService{
|
||||||
|
db: db,
|
||||||
|
chatRepo: repositories.NewChatRepository(db),
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
||||||
|
groqAPIKey: os.Getenv("GROQ_API_KEY"),
|
||||||
|
groqModel: model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatRequest struct {
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groq API Request Structure
|
||||||
|
type GroqRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []GroqMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
TopP float64 `json:"top_p,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroqMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groq API Response Structure
|
||||||
|
type GroqResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) ProcessChat(userID uint, message string) (*models.ChatMessage, error) {
|
||||||
|
// Build context from user data
|
||||||
|
context, err := s.buildUserContext(userID, message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect intent
|
||||||
|
intent := s.detectIntent(message)
|
||||||
|
|
||||||
|
// Build prompt with context
|
||||||
|
systemPrompt := s.buildSystemPrompt()
|
||||||
|
userPrompt := s.buildUserPrompt(message, context, intent)
|
||||||
|
|
||||||
|
// Call Groq API
|
||||||
|
response, err := s.callGroqAPI(systemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
chat := &models.ChatMessage{
|
||||||
|
UserID: userID,
|
||||||
|
Message: message,
|
||||||
|
Response: response,
|
||||||
|
Intent: intent,
|
||||||
|
ConfidenceScore: 85.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.chatRepo.Create(chat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return chat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) buildUserContext(userID uint, message string) (string, error) {
|
||||||
|
var context strings.Builder
|
||||||
|
|
||||||
|
// Get user's lost items
|
||||||
|
lostItems, _, _ := s.lostItemRepo.FindByUser(userID, 1, 5)
|
||||||
|
if len(lostItems) > 0 {
|
||||||
|
context.WriteString("\n📋 Barang yang dilaporkan hilang:\n")
|
||||||
|
for _, item := range lostItems {
|
||||||
|
context.WriteString(fmt.Sprintf("- %s (%s) - Status: %s\n",
|
||||||
|
item.Name, item.Category.Name, item.Status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for relevant found items if user is looking for something
|
||||||
|
if strings.Contains(strings.ToLower(message), "cari") ||
|
||||||
|
strings.Contains(strings.ToLower(message), "temukan") {
|
||||||
|
items, _, _ := s.itemRepo.FindAll(1, 5, "unclaimed", "", message)
|
||||||
|
if len(items) > 0 {
|
||||||
|
context.WriteString("\n🔍 Barang ditemukan yang relevan:\n")
|
||||||
|
for _, item := range items {
|
||||||
|
context.WriteString(fmt.Sprintf("- ID: %d, %s (%s) - Lokasi: %s\n",
|
||||||
|
item.ID, item.Name, item.Category.Name, item.Location))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) detectIntent(message string) string {
|
||||||
|
msgLower := strings.ToLower(message)
|
||||||
|
|
||||||
|
searchKeywords := []string{"cari", "temukan", "ada", "lihat", "ditemukan"}
|
||||||
|
reportKeywords := []string{"hilang", "kehilangan", "lapor", "laporkan"}
|
||||||
|
claimKeywords := []string{"klaim", "ambil", "punya saya", "milik saya"}
|
||||||
|
|
||||||
|
for _, kw := range searchKeywords {
|
||||||
|
if strings.Contains(msgLower, kw) {
|
||||||
|
return models.IntentSearchItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kw := range reportKeywords {
|
||||||
|
if strings.Contains(msgLower, kw) {
|
||||||
|
return models.IntentReportLost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kw := range claimKeywords {
|
||||||
|
if strings.Contains(msgLower, kw) {
|
||||||
|
return models.IntentClaimHelp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.IntentGeneral
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) buildSystemPrompt() string {
|
||||||
|
return `Kamu adalah asisten AI untuk sistem Lost & Found kampus bernama "FindItBot".
|
||||||
|
|
||||||
|
Tugasmu adalah membantu mahasiswa dan staff dengan:
|
||||||
|
1. 🔍 Mencari barang yang hilang/ditemukan
|
||||||
|
2. 📝 Memandu proses pelaporan barang hilang
|
||||||
|
3. ✅ Menjelaskan proses klaim barang
|
||||||
|
4. ❓ Menjawab pertanyaan umum tentang sistem
|
||||||
|
|
||||||
|
Aturan penting:
|
||||||
|
- Jawab dengan ramah, profesional, dan membantu
|
||||||
|
- Gunakan Bahasa Indonesia yang jelas
|
||||||
|
- Jika ada data barang yang relevan, sebutkan ID dan detailnya
|
||||||
|
- Untuk pelaporan, tanyakan: nama barang, kategori, lokasi, tanggal hilang, deskripsi
|
||||||
|
- Untuk klaim, jelaskan proses verifikasi yang diperlukan
|
||||||
|
- Gunakan emoji yang sesuai untuk memperjelas informasi
|
||||||
|
- Prioritaskan informasi dari konteks yang diberikan
|
||||||
|
|
||||||
|
Contoh respons yang baik:
|
||||||
|
"🔍 Saya menemukan 2 barang yang mungkin cocok:
|
||||||
|
1. ID: 123 - Dompet Kulit (Kategori: Wallet) - Ditemukan di Perpustakaan
|
||||||
|
2. ID: 124 - Dompet Hitam (Kategori: Wallet) - Ditemukan di Kantin
|
||||||
|
|
||||||
|
Apakah salah satu dari ini milik Anda? Anda bisa klaim dengan menyebutkan ID barangnya."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) buildUserPrompt(message, context, intent string) string {
|
||||||
|
var prompt strings.Builder
|
||||||
|
|
||||||
|
if context != "" {
|
||||||
|
prompt.WriteString("KONTEKS PENGGUNA:\n")
|
||||||
|
prompt.WriteString(context)
|
||||||
|
prompt.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.WriteString(fmt.Sprintf("INTENT TERDETEKSI: %s\n\n", intent))
|
||||||
|
prompt.WriteString(fmt.Sprintf("PERTANYAAN: %s\n\n", message))
|
||||||
|
prompt.WriteString("Berikan respons yang membantu berdasarkan konteks di atas.")
|
||||||
|
|
||||||
|
return prompt.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) callGroqAPI(systemPrompt, userPrompt string) (string, error) {
|
||||||
|
if s.groqAPIKey == "" {
|
||||||
|
return "", errors.New("GROQ_API_KEY not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
url := "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
|
||||||
|
reqBody := GroqRequest{
|
||||||
|
Model: s.groqModel,
|
||||||
|
Messages: []GroqMessage{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: systemPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: userPrompt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Temperature: 0.7,
|
||||||
|
MaxTokens: 1024,
|
||||||
|
TopP: 0.95,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.groqAPIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to call Groq API: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("Groq API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var groqResp GroqResponse
|
||||||
|
if err := json.Unmarshal(body, &groqResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groqResp.Choices) == 0 {
|
||||||
|
return "", errors.New("no response from Groq API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return groqResp.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) GetChatHistory(userID uint, limit int) ([]models.ChatMessageResponse, error) {
|
||||||
|
chats, err := s.chatRepo.GetUserChatHistory(userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.ChatMessageResponse
|
||||||
|
for _, chat := range chats {
|
||||||
|
responses = append(responses, chat.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AIService) ClearChatHistory(userID uint) error {
|
||||||
|
return s.chatRepo.DeleteUserHistory(userID)
|
||||||
|
}
|
||||||
68
internal/services/archive_service.go
Normal file
68
internal/services/archive_service.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// internal/services/archive_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArchiveService struct {
|
||||||
|
archiveRepo *repositories.ArchiveRepository
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArchiveService(db *gorm.DB) *ArchiveService {
|
||||||
|
return &ArchiveService{
|
||||||
|
archiveRepo: repositories.NewArchiveRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllArchives gets all archived items
|
||||||
|
func (s *ArchiveService) GetAllArchives(page, limit int, reason, search string) ([]models.ArchiveResponse, int64, error) {
|
||||||
|
archives, total, err := s.archiveRepo.FindAll(page, limit, reason, search)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.ArchiveResponse
|
||||||
|
for _, archive := range archives {
|
||||||
|
responses = append(responses, archive.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveByID gets archive by ID
|
||||||
|
func (s *ArchiveService) GetArchiveByID(id uint) (*models.Archive, error) {
|
||||||
|
return s.archiveRepo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveByItemID gets archive by original item ID
|
||||||
|
func (s *ArchiveService) GetArchiveByItemID(itemID uint) (*models.Archive, error) {
|
||||||
|
return s.archiveRepo.FindByItemID(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArchiveStats gets archive statistics
|
||||||
|
func (s *ArchiveService) GetArchiveStats() (map[string]interface{}, error) {
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Count by reason
|
||||||
|
expiredCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonExpired)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["expired"] = expiredCount
|
||||||
|
|
||||||
|
caseClosedCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonCaseClosed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["case_closed"] = caseClosedCount
|
||||||
|
|
||||||
|
stats["total"] = expiredCount + caseClosedCount
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
69
internal/services/audit_service.go
Normal file
69
internal/services/audit_service.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// internal/services/audit_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditService struct {
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditService(db *gorm.DB) *AuditService {
|
||||||
|
return &AuditService{
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllAuditLogs gets all audit logs
|
||||||
|
func (s *AuditService) GetAllAuditLogs(page, limit int, action, entityType string, userID *uint) ([]models.AuditLogResponse, int64, error) {
|
||||||
|
logs, total, err := s.auditLogRepo.FindAll(page, limit, action, entityType, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.AuditLogResponse
|
||||||
|
for _, log := range logs {
|
||||||
|
responses = append(responses, log.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogsByUser gets audit logs by user
|
||||||
|
func (s *AuditService) GetAuditLogsByUser(userID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
|
||||||
|
logs, total, err := s.auditLogRepo.FindByUser(userID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.AuditLogResponse
|
||||||
|
for _, log := range logs {
|
||||||
|
responses = append(responses, log.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogsByEntity gets audit logs by entity
|
||||||
|
func (s *AuditService) GetAuditLogsByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
|
||||||
|
logs, total, err := s.auditLogRepo.FindByEntity(entityType, entityID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.AuditLogResponse
|
||||||
|
for _, log := range logs {
|
||||||
|
responses = append(responses, log.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAction creates a new audit log entry
|
||||||
|
func (s *AuditService) LogAction(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
|
||||||
|
return s.auditLogRepo.Log(userID, action, entityType, entityID, details, ipAddress, userAgent)
|
||||||
|
}
|
||||||
286
internal/services/auth_service.go
Normal file
286
internal/services/auth_service.go
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/config"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 1. Definisi Interface (Contract) ---
|
||||||
|
// Interface ini mendefinisikan method apa saja yang dibutuhkan oleh Service dari Repository.
|
||||||
|
// Dengan ini, kita bisa menukar Repository asli dengan Mock Repository saat testing.
|
||||||
|
|
||||||
|
type IUserRepository interface {
|
||||||
|
FindByEmail(email string) (*models.User, error)
|
||||||
|
FindByNRP(nrp string) (*models.User, error)
|
||||||
|
FindByID(id uint) (*models.User, error)
|
||||||
|
Create(user *models.User) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type IRoleRepository interface {
|
||||||
|
FindByName(name string) (*models.Role, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IAuditLogRepository interface {
|
||||||
|
Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Struct AuthService dengan Dependency Injection ---
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo IUserRepository // Menggunakan Interface, bukan struct konkret *repositories.UserRepository
|
||||||
|
roleRepo IRoleRepository // Menggunakan Interface
|
||||||
|
auditLogRepo IAuditLogRepository // Menggunakan Interface
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService menginisialisasi service dengan repository asli (untuk Production)
|
||||||
|
func NewAuthService(db *gorm.DB, logger *zap.Logger) *AuthService {
|
||||||
|
// ✅ Inisialisasi Enkripsi (Bonus Keamanan)
|
||||||
|
if err := utils.InitEncryption(); err != nil {
|
||||||
|
logger.Fatal("Failed to initialize encryption", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthService{
|
||||||
|
// Struct repository asli secara otomatis memenuhi interface (duck typing)
|
||||||
|
userRepo: repositories.NewUserRepository(db),
|
||||||
|
roleRepo: repositories.NewRoleRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Struct Request & Response ---
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
NRP string `json:"nrp"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User models.UserResponse `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Implementasi Method Service ---
|
||||||
|
|
||||||
|
// Register menangani pendaftaran user baru
|
||||||
|
func (s *AuthService) Register(req RegisterRequest, ipAddress, userAgent string) (*AuthResponse, error) {
|
||||||
|
s.logger.Info("Registration attempt",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.String("name", req.Name),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cek apakah email sudah terdaftar
|
||||||
|
existingUser, _ := s.userRepo.FindByEmail(req.Email)
|
||||||
|
if existingUser != nil {
|
||||||
|
s.logger.Warn("Registration failed: email already exists",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
return nil, errors.New("email already registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek apakah NRP sudah terdaftar (jika ada)
|
||||||
|
if req.NRP != "" {
|
||||||
|
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
|
||||||
|
if existingNRP != nil {
|
||||||
|
s.logger.Warn("Registration failed: NRP already exists",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
)
|
||||||
|
return nil, errors.New("NRP already registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := utils.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to hash password",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, errors.New("failed to hash password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ambil role default "user"
|
||||||
|
userRole, err := s.roleRepo.FindByName(models.RoleUser)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to get user role", zap.Error(err))
|
||||||
|
return nil, errors.New("failed to get user role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buat objek User
|
||||||
|
user := &models.User{
|
||||||
|
Name: req.Name,
|
||||||
|
Email: req.Email,
|
||||||
|
Password: hashedPassword,
|
||||||
|
NRP: req.NRP, // Disimpan plain text (atau terenkripsi jika model mendukung hook)
|
||||||
|
Phone: req.Phone, // Disimpan plain text
|
||||||
|
RoleID: userRole.ID,
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpan ke database
|
||||||
|
if err := s.userRepo.Create(user); err != nil {
|
||||||
|
s.logger.Error("Failed to create user",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, errors.New("failed to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muat ulang user untuk mendapatkan relasi Role yang lengkap
|
||||||
|
user, err = s.userRepo.FindByID(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to load user",
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT Token
|
||||||
|
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate token",
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, errors.New("failed to generate token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log Audit
|
||||||
|
s.auditLogRepo.Log(&user.ID, models.ActionCreate, models.EntityUser, &user.ID,
|
||||||
|
"User registered", ipAddress, userAgent)
|
||||||
|
|
||||||
|
s.logger.Info("Registration successful",
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.String("email", user.Email),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: user.ToResponse(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login menangani autentikasi user
|
||||||
|
func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) {
|
||||||
|
s.logger.Info("Login attempt",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cari user berdasarkan email
|
||||||
|
user, err := s.userRepo.FindByEmail(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Login failed: user not found",
|
||||||
|
zap.String("email", req.Email),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
return nil, errors.New("invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek apakah akun diblokir
|
||||||
|
if user.IsBlocked() {
|
||||||
|
s.logger.Warn("Login failed: account blocked",
|
||||||
|
zap.String("email", user.Email),
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
return nil, errors.New("account is blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifikasi password
|
||||||
|
passwordMatch := utils.CheckPasswordHash(req.Password, user.Password)
|
||||||
|
if !passwordMatch {
|
||||||
|
s.logger.Warn("Login failed: incorrect password",
|
||||||
|
zap.String("email", user.Email),
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
return nil, errors.New("invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pastikan Role ter-load (Reload jika perlu)
|
||||||
|
if user.Role.ID == 0 {
|
||||||
|
user, err = s.userRepo.FindByID(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to load user data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT Token
|
||||||
|
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to generate token",
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return nil, errors.New("failed to generate token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log Audit
|
||||||
|
s.auditLogRepo.Log(&user.ID, models.ActionLogin, models.EntityUser, &user.ID,
|
||||||
|
"User logged in", ipAddress, userAgent)
|
||||||
|
|
||||||
|
s.logger.Info("Login successful",
|
||||||
|
zap.Uint("user_id", user.ID),
|
||||||
|
zap.String("email", user.Email),
|
||||||
|
zap.String("ip_address", ipAddress),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
User: user.ToResponse(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken memvalidasi token JWT dan mengembalikan user terkait
|
||||||
|
func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) {
|
||||||
|
claims, err := config.ValidateToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Token validation failed", zap.Error(err))
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.FindByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsBlocked() {
|
||||||
|
return nil, errors.New("account is blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken memperbarui token JWT yang sudah ada
|
||||||
|
func (s *AuthService) RefreshToken(oldToken string) (string, error) {
|
||||||
|
s.logger.Info("Token refresh attempt")
|
||||||
|
|
||||||
|
newToken, err := config.RefreshToken(oldToken)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Token refresh failed", zap.Error(err))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Token refreshed successfully")
|
||||||
|
return newToken, nil
|
||||||
|
}
|
||||||
148
internal/services/category_service.go
Normal file
148
internal/services/category_service.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// internal/services/category_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoryService struct {
|
||||||
|
categoryRepo *repositories.CategoryRepository
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoryService(db *gorm.DB) *CategoryService {
|
||||||
|
return &CategoryService{
|
||||||
|
categoryRepo: repositories.NewCategoryRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCategoryRequest represents category creation data
|
||||||
|
type CreateCategoryRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategoryRequest represents category update data
|
||||||
|
type UpdateCategoryRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllCategories gets all categories
|
||||||
|
func (s *CategoryService) GetAllCategories() ([]models.CategoryResponse, error) {
|
||||||
|
return s.categoryRepo.GetAllWithItemCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryByID gets category by ID
|
||||||
|
func (s *CategoryService) GetCategoryByID(id uint) (*models.Category, error) {
|
||||||
|
return s.categoryRepo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryBySlug gets category by slug
|
||||||
|
func (s *CategoryService) GetCategoryBySlug(slug string) (*models.Category, error) {
|
||||||
|
return s.categoryRepo.FindBySlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCategory creates a new category (admin only)
|
||||||
|
func (s *CategoryService) CreateCategory(adminID uint, req CreateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
|
||||||
|
// Generate slug from name
|
||||||
|
slug := s.generateSlug(req.Name)
|
||||||
|
|
||||||
|
// Check if slug already exists
|
||||||
|
existing, _ := s.categoryRepo.FindBySlug(slug)
|
||||||
|
if existing != nil {
|
||||||
|
return nil, errors.New("category with similar name already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
category := &models.Category{
|
||||||
|
Name: req.Name,
|
||||||
|
Slug: slug,
|
||||||
|
Description: req.Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.categoryRepo.Create(category); err != nil {
|
||||||
|
return nil, errors.New("failed to create category")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionCreate, models.EntityCategory, &category.ID,
|
||||||
|
"Category created: "+category.Name, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategory updates a category (admin only)
|
||||||
|
func (s *CategoryService) UpdateCategory(adminID, categoryID uint, req UpdateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
|
||||||
|
category, err := s.categoryRepo.FindByID(categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if req.Name != "" {
|
||||||
|
category.Name = req.Name
|
||||||
|
category.Slug = s.generateSlug(req.Name)
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
category.Description = req.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.categoryRepo.Update(category); err != nil {
|
||||||
|
return nil, errors.New("failed to update category")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityCategory, &categoryID,
|
||||||
|
"Category updated: "+category.Name, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return category, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCategory deletes a category (admin only)
|
||||||
|
func (s *CategoryService) DeleteCategory(adminID, categoryID uint, ipAddress, userAgent string) error {
|
||||||
|
category, err := s.categoryRepo.FindByID(categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if category has items
|
||||||
|
_, count, err := s.categoryRepo.GetCategoryWithItemCount(categoryID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("cannot delete category with existing items")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.categoryRepo.Delete(categoryID); err != nil {
|
||||||
|
return errors.New("failed to delete category")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityCategory, &categoryID,
|
||||||
|
"Category deleted: "+category.Name, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSlug generates URL-friendly slug from name
|
||||||
|
func (s *CategoryService) generateSlug(name string) string {
|
||||||
|
slug := strings.ToLower(name)
|
||||||
|
slug = strings.ReplaceAll(slug, " ", "_")
|
||||||
|
slug = strings.ReplaceAll(slug, "/", "_")
|
||||||
|
// Remove special characters
|
||||||
|
validChars := "abcdefghijklmnopqrstuvwxyz0123456789_"
|
||||||
|
result := ""
|
||||||
|
for _, char := range slug {
|
||||||
|
if strings.ContainsRune(validChars, char) {
|
||||||
|
result += string(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
1202
internal/services/claim_service.go
Normal file
1202
internal/services/claim_service.go
Normal file
File diff suppressed because it is too large
Load Diff
276
internal/services/dashboard_service.go
Normal file
276
internal/services/dashboard_service.go
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
// internal/services/dashboard_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ KRITERIA BASDAT: Menggunakan VIEWS dari enhancement.sql
|
||||||
|
type DashboardService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
itemRepo *repositories.ItemRepository // ✅ Tambahkan ini
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboardService(db *gorm.DB) *DashboardService {
|
||||||
|
return &DashboardService{
|
||||||
|
db: db,
|
||||||
|
itemRepo: repositories.NewItemRepository(db), // ✅ Inisialisasi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardStats menggunakan VIEW vw_dashboard_stats
|
||||||
|
type DashboardStats struct {
|
||||||
|
TotalUnclaimed int64 `json:"total_unclaimed"`
|
||||||
|
TotalVerified int64 `json:"total_verified"`
|
||||||
|
TotalLostReports int64 `json:"total_lost_reports"`
|
||||||
|
PendingClaims int64 `json:"pending_claims"`
|
||||||
|
UnnotifiedMatches int64 `json:"unnotified_matches"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDashboardStats - ✅ MENGGUNAKAN VIEW
|
||||||
|
func (s *DashboardService) GetDashboardStats() (*DashboardStats, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var stats DashboardStats
|
||||||
|
|
||||||
|
// ✅ QUERY VIEW vw_dashboard_stats (dari enhancement.sql)
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("vw_dashboard_stats").
|
||||||
|
Select("*").
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemDetail menggunakan VIEW vw_items_detail
|
||||||
|
type ItemDetail struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
CategorySlug string `json:"category_slug"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReporterName string `json:"reporter_name"`
|
||||||
|
ReporterContact string `json:"reporter_contact"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
ReporterUserName string `json:"reporter_user_name"`
|
||||||
|
ReporterEmail string `json:"reporter_email"`
|
||||||
|
DaysUntilExpire int `json:"days_until_expire"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemsWithDetails - ✅ MENGGUNAKAN VIEW
|
||||||
|
func (s *DashboardService) GetItemsWithDetails(page, limit int, status string) ([]ItemDetail, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var items []ItemDetail
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Table("vw_items_detail")
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
err := query.Offset(offset).Limit(limit).Scan(&items).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimDetail menggunakan VIEW vw_claims_detail
|
||||||
|
type ClaimDetail struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ItemName string `json:"item_name"`
|
||||||
|
CategoryName string `json:"category_name"`
|
||||||
|
ClaimantName string `json:"claimant_name"`
|
||||||
|
ClaimantEmail string `json:"claimant_email"`
|
||||||
|
ClaimantPhone string `json:"claimant_phone"`
|
||||||
|
ClaimDescription string `json:"claim_description"`
|
||||||
|
Contact string `json:"contact"`
|
||||||
|
SimilarityScore *float64 `json:"similarity_score"`
|
||||||
|
VerifiedAt *time.Time `json:"verified_at"`
|
||||||
|
VerifiedByName string `json:"verified_by_name"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClaimsWithDetails - ✅ MENGGUNAKAN VIEW
|
||||||
|
func (s *DashboardService) GetClaimsWithDetails(status string) ([]ClaimDetail, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var claims []ClaimDetail
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Table("vw_claims_detail")
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.Order("created_at DESC").Scan(&claims).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchDetail menggunakan VIEW vw_match_results_detail
|
||||||
|
type MatchDetail struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
LostItemName string `json:"lost_item_name"`
|
||||||
|
LostByUserID uint `json:"lost_by_user_id"`
|
||||||
|
LostByUserName string `json:"lost_by_user_name"`
|
||||||
|
LostByEmail string `json:"lost_by_email"`
|
||||||
|
FoundItemName string `json:"found_item_name"`
|
||||||
|
FoundByName string `json:"found_by_name"`
|
||||||
|
SimilarityScore float64 `json:"similarity_score"`
|
||||||
|
IsNotified bool `json:"is_notified"`
|
||||||
|
MatchedAt time.Time `json:"matched_at"`
|
||||||
|
FoundItemID uint `json:"found_item_id"`
|
||||||
|
LostItemID uint `json:"lost_item_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchesWithDetails - ✅ MENGGUNAKAN VIEW
|
||||||
|
func (s *DashboardService) GetMatchesWithDetails(minScore float64) ([]MatchDetail, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var matches []MatchDetail
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Table("vw_match_results_detail")
|
||||||
|
|
||||||
|
if minScore > 0 {
|
||||||
|
query = query.Where("similarity_score >= ?", minScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.Limit(100).Scan(&matches).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryStats menggunakan VIEW vw_category_stats
|
||||||
|
type CategoryStats struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
TotalItems int `json:"total_items"`
|
||||||
|
UnclaimedItems int `json:"unclaimed_items"`
|
||||||
|
VerifiedItems int `json:"verified_items"`
|
||||||
|
TotalLostReports int `json:"total_lost_reports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCategoryStats - ✅ MENGGUNAKAN VIEW
|
||||||
|
func (s *DashboardService) GetCategoryStats() ([]CategoryStats, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var stats []CategoryStats
|
||||||
|
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("vw_category_stats").
|
||||||
|
Order("total_items DESC").
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserActivity menggunakan VIEW vw_user_activity
|
||||||
|
type UserActivity struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
ItemsReported int `json:"items_reported"`
|
||||||
|
LostItemsReported int `json:"lost_items_reported"`
|
||||||
|
ClaimsMade int `json:"claims_made"`
|
||||||
|
ClaimsApproved int `json:"claims_approved"`
|
||||||
|
MemberSince time.Time `json:"member_since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserActivity - ✅ MENGGUNAKAN VIEW
|
||||||
|
func (s *DashboardService) GetUserActivity(limit int) ([]UserActivity, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var activities []UserActivity
|
||||||
|
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("vw_user_activity").
|
||||||
|
Order("items_reported DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Scan(&activities).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardService) GetStatsFromSP() (map[string]int64, error) {
|
||||||
|
return s.itemRepo.GetDashboardStatsSP()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentActivity menggunakan VIEW vw_recent_activities
|
||||||
|
type RecentActivity struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID *uint `json:"entity_id"`
|
||||||
|
Details string `json:"details"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
UserEmail string `json:"user_email"`
|
||||||
|
UserRole string `json:"user_role"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentActivities - ✅ MENGGUNAKAN VIEW (limited to 100)
|
||||||
|
func (s *DashboardService) GetRecentActivities() ([]RecentActivity, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var activities []RecentActivity
|
||||||
|
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Table("vw_recent_activities").
|
||||||
|
Scan(&activities).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
352
internal/services/etl_service.go
Normal file
352
internal/services/etl_service.go
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
// internal/services/etl_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ KRITERIA BASDAT: ETL Implementation (15%)
|
||||||
|
type ETLService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewETLService(db *gorm.DB, logger *zap.Logger) *ETLService {
|
||||||
|
return &ETLService{
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETLResult represents ETL operation result
|
||||||
|
type ETLResult struct {
|
||||||
|
TotalRecords int `json:"total_records"`
|
||||||
|
SuccessRecords int `json:"success_records"`
|
||||||
|
FailedRecords int `json:"failed_records"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
TransformedData map[string]interface{} `json:"transformed_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ EXTRACT - Extract data from CSV
|
||||||
|
func (s *ETLService) ExtractFromCSV(filepath string) ([]map[string]string, error) {
|
||||||
|
s.logger.Info("Starting EXTRACT phase", zap.String("file", filepath))
|
||||||
|
|
||||||
|
file, err := os.Open(filepath)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to open CSV file", zap.Error(err))
|
||||||
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := csv.NewReader(file)
|
||||||
|
headers, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read headers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []map[string]string
|
||||||
|
lineNumber := 1
|
||||||
|
|
||||||
|
for {
|
||||||
|
record, err := reader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Skipping invalid line", zap.Int("line", lineNumber), zap.Error(err))
|
||||||
|
lineNumber++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make(map[string]string)
|
||||||
|
for i, value := range record {
|
||||||
|
if i < len(headers) {
|
||||||
|
data[headers[i]] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records = append(records, data)
|
||||||
|
lineNumber++
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("EXTRACT completed", zap.Int("records", len(records)))
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ TRANSFORM - Transform and validate data
|
||||||
|
func (s *ETLService) TransformItemData(records []map[string]string) ([]models.Item, []string) {
|
||||||
|
s.logger.Info("Starting TRANSFORM phase", zap.Int("records", len(records)))
|
||||||
|
|
||||||
|
var items []models.Item
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
// Worker Pool Pattern for concurrent transformation
|
||||||
|
const numWorkers = 5
|
||||||
|
recordsChan := make(chan map[string]string, len(records))
|
||||||
|
resultsChan := make(chan struct {
|
||||||
|
item *models.Item
|
||||||
|
error string
|
||||||
|
}, len(records))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for record := range recordsChan {
|
||||||
|
item, err := s.transformSingleItem(record)
|
||||||
|
if err != nil {
|
||||||
|
resultsChan <- struct {
|
||||||
|
item *models.Item
|
||||||
|
error string
|
||||||
|
}{nil, err.Error()}
|
||||||
|
} else {
|
||||||
|
resultsChan <- struct {
|
||||||
|
item *models.Item
|
||||||
|
error string
|
||||||
|
}{item, ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send records to workers
|
||||||
|
go func() {
|
||||||
|
for _, record := range records {
|
||||||
|
recordsChan <- record
|
||||||
|
}
|
||||||
|
close(recordsChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for workers to finish
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultsChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
for result := range resultsChan {
|
||||||
|
if result.error != "" {
|
||||||
|
errors = append(errors, result.error)
|
||||||
|
} else if result.item != nil {
|
||||||
|
items = append(items, *result.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("TRANSFORM completed",
|
||||||
|
zap.Int("success", len(items)),
|
||||||
|
zap.Int("failed", len(errors)))
|
||||||
|
|
||||||
|
return items, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ETLService) transformSingleItem(record map[string]string) (*models.Item, error) {
|
||||||
|
// Validate required fields
|
||||||
|
required := []string{"name", "category_id", "location", "description", "date_found", "reporter_name", "reporter_contact"}
|
||||||
|
for _, field := range required {
|
||||||
|
if record[field] == "" {
|
||||||
|
return nil, fmt.Errorf("missing required field: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse category_id
|
||||||
|
categoryID, err := strconv.ParseUint(record["category_id"], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid category_id: %s", record["category_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date_found
|
||||||
|
dateFound, err := time.Parse("2006-01-02", record["date_found"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid date_found format: %s", record["date_found"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reporter_id
|
||||||
|
reporterID, err := strconv.ParseUint(record["reporter_id"], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid reporter_id: %s", record["reporter_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create item
|
||||||
|
item := &models.Item{
|
||||||
|
Name: record["name"],
|
||||||
|
CategoryID: uint(categoryID),
|
||||||
|
PhotoURL: record["photo_url"],
|
||||||
|
Location: record["location"],
|
||||||
|
Description: record["description"],
|
||||||
|
DateFound: dateFound,
|
||||||
|
Status: models.ItemStatusUnclaimed,
|
||||||
|
ReporterID: uint(reporterID),
|
||||||
|
ReporterName: record["reporter_name"],
|
||||||
|
ReporterContact: record["reporter_contact"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ LOAD - Load transformed data to database with TRANSACTION
|
||||||
|
func (s *ETLService) LoadItems(items []models.Item) (*ETLResult, error) {
|
||||||
|
s.logger.Info("Starting LOAD phase", zap.Int("items", len(items)))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
result := &ETLResult{
|
||||||
|
TotalRecords: len(items),
|
||||||
|
Errors: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert with transaction
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, item := range items {
|
||||||
|
if err := tx.Create(&item).Error; err != nil {
|
||||||
|
result.FailedRecords++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("Failed to insert %s: %v", item.Name, err))
|
||||||
|
s.logger.Warn("Failed to insert item",
|
||||||
|
zap.String("name", item.Name),
|
||||||
|
zap.Error(err))
|
||||||
|
} else {
|
||||||
|
result.SuccessRecords++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If more than 50% failed, rollback
|
||||||
|
if result.FailedRecords > result.TotalRecords/2 {
|
||||||
|
return fmt.Errorf("too many failures (%d/%d), rolling back transaction",
|
||||||
|
result.FailedRecords, result.TotalRecords)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
result.Duration = time.Since(startTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("LOAD failed", zap.Error(err))
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("LOAD completed",
|
||||||
|
zap.Int("success", result.SuccessRecords),
|
||||||
|
zap.Int("failed", result.FailedRecords),
|
||||||
|
zap.Duration("duration", result.Duration))
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Full ETL Pipeline
|
||||||
|
func (s *ETLService) RunETLPipeline(csvPath string) (*ETLResult, error) {
|
||||||
|
s.logger.Info("Starting FULL ETL Pipeline", zap.String("source", csvPath))
|
||||||
|
|
||||||
|
// EXTRACT
|
||||||
|
records, err := s.ExtractFromCSV(csvPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRANSFORM
|
||||||
|
items, transformErrors := s.TransformItemData(records)
|
||||||
|
|
||||||
|
// LOAD
|
||||||
|
result, err := s.LoadItems(items)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add transform errors to result
|
||||||
|
result.Errors = append(result.Errors, transformErrors...)
|
||||||
|
|
||||||
|
s.logger.Info("ETL Pipeline completed",
|
||||||
|
zap.Int("total", result.TotalRecords),
|
||||||
|
zap.Int("success", result.SuccessRecords),
|
||||||
|
zap.Int("failed", result.FailedRecords))
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Export data (Reverse ETL)
|
||||||
|
func (s *ETLService) ExportToCSV(filepath string, query string) error {
|
||||||
|
s.logger.Info("Exporting data to CSV", zap.String("file", filepath))
|
||||||
|
|
||||||
|
var items []models.Item
|
||||||
|
if err := s.db.Raw(query).Scan(&items).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to query data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
writer := csv.NewWriter(file)
|
||||||
|
defer writer.Flush()
|
||||||
|
|
||||||
|
// Write headers
|
||||||
|
headers := []string{"id", "name", "category_id", "location", "description", "date_found", "status"}
|
||||||
|
if err := writer.Write(headers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data
|
||||||
|
for _, item := range items {
|
||||||
|
record := []string{
|
||||||
|
strconv.Itoa(int(item.ID)),
|
||||||
|
item.Name,
|
||||||
|
strconv.Itoa(int(item.CategoryID)),
|
||||||
|
item.Location,
|
||||||
|
item.Description,
|
||||||
|
item.DateFound.Format("2006-01-02"),
|
||||||
|
item.Status,
|
||||||
|
}
|
||||||
|
if err := writer.Write(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Export completed", zap.Int("records", len(items)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Data Synchronization between databases
|
||||||
|
func (s *ETLService) SyncToExternalDB(externalDB *gorm.DB) error {
|
||||||
|
s.logger.Info("Starting database synchronization")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get all items from source
|
||||||
|
var items []models.Item
|
||||||
|
if err := s.db.WithContext(ctx).Find(&items).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to external DB with transaction
|
||||||
|
return externalDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, item := range items {
|
||||||
|
// Upsert logic
|
||||||
|
if err := tx.Save(&item).Error; err != nil {
|
||||||
|
s.logger.Warn("Failed to sync item",
|
||||||
|
zap.Uint("id", item.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
267
internal/services/export_service.go
Normal file
267
internal/services/export_service.go
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
// internal/services/export_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"time"
|
||||||
|
"log"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportService struct {
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
archiveRepo *repositories.ArchiveRepository
|
||||||
|
claimRepo *repositories.ClaimRepository
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExportService(db *gorm.DB) *ExportService {
|
||||||
|
return &ExportService{
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
archiveRepo: repositories.NewArchiveRepository(db),
|
||||||
|
claimRepo: repositories.NewClaimRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportRequest represents export request data
|
||||||
|
type ExportRequest struct {
|
||||||
|
Type string `json:"type"` // items, archives, claims, audit_logs
|
||||||
|
Format string `json:"format"` // pdf, excel
|
||||||
|
StartDate *time.Time `json:"start_date"`
|
||||||
|
EndDate *time.Time `json:"end_date"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportItemsToPDF exports items to PDF
|
||||||
|
func (s *ExportService) ExportItemsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
||||||
|
|
||||||
|
log.Printf("DEBUG: Starting PDF export - Type: %s, Status: %s", req.Type, req.Status)
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR: Failed to fetch items: %v", err) // ← Tambahkan ini
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("DEBUG: Found %d items", len(items))
|
||||||
|
|
||||||
|
// Filter by date range if provided
|
||||||
|
var filteredItems []models.Item
|
||||||
|
for _, item := range items {
|
||||||
|
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredItems = append(filteredItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filteredItems) == 0 {
|
||||||
|
return nil, errors.New("no data in specified date range")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PDF
|
||||||
|
pdf := utils.NewPDFExporter()
|
||||||
|
pdf.AddTitle("Laporan Barang Ditemukan")
|
||||||
|
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
|
||||||
|
formatDate(req.StartDate),
|
||||||
|
formatDate(req.EndDate)))
|
||||||
|
pdf.AddNewLine()
|
||||||
|
|
||||||
|
// Add table
|
||||||
|
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Tanggal Ditemukan", "Status"}
|
||||||
|
var data [][]string
|
||||||
|
for i, item := range filteredItems {
|
||||||
|
data = append(data, []string{
|
||||||
|
fmt.Sprintf("%d", i+1),
|
||||||
|
item.Name,
|
||||||
|
item.Category.Name,
|
||||||
|
item.Location,
|
||||||
|
item.DateFound.Format("02 Jan 2006"),
|
||||||
|
item.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pdf.AddTable(headers, data)
|
||||||
|
|
||||||
|
// Add footer
|
||||||
|
pdf.AddNewLine()
|
||||||
|
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredItems)))
|
||||||
|
pdf.AddText(fmt.Sprintf("Dicetak pada: %s", time.Now().Format("02 January 2006 15:04")))
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
||||||
|
fmt.Sprintf("Exported items report (PDF, %d items)", len(filteredItems)),
|
||||||
|
ipAddress, userAgent)
|
||||||
|
|
||||||
|
return pdf.Output(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportItemsToExcel exports items to Excel
|
||||||
|
func (s *ExportService) ExportItemsToExcel(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
||||||
|
// Get items
|
||||||
|
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by date range if provided
|
||||||
|
var filteredItems []models.Item
|
||||||
|
for _, item := range items {
|
||||||
|
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredItems = append(filteredItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate Excel
|
||||||
|
excel := utils.NewExcelExporter()
|
||||||
|
excel.SetSheetName("Barang Ditemukan")
|
||||||
|
|
||||||
|
// Add headers
|
||||||
|
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Deskripsi",
|
||||||
|
"Tanggal Ditemukan", "Status", "Pelapor", "Kontak"}
|
||||||
|
excel.AddRow(headers)
|
||||||
|
|
||||||
|
// Add data
|
||||||
|
for i, item := range filteredItems {
|
||||||
|
excel.AddRow([]string{
|
||||||
|
fmt.Sprintf("%d", i+1),
|
||||||
|
item.Name,
|
||||||
|
item.Category.Name,
|
||||||
|
item.Location,
|
||||||
|
item.Description,
|
||||||
|
item.DateFound.Format("02 Jan 2006"),
|
||||||
|
item.Status,
|
||||||
|
item.ReporterName,
|
||||||
|
item.ReporterContact,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-size columns
|
||||||
|
excel.AutoSizeColumns(len(headers))
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
||||||
|
fmt.Sprintf("Exported items report (Excel, %d items)", len(filteredItems)),
|
||||||
|
ipAddress, userAgent)
|
||||||
|
|
||||||
|
return excel.Output()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportArchivesToPDF exports archives to PDF
|
||||||
|
func (s *ExportService) ExportArchivesToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
||||||
|
archives, _, err := s.archiveRepo.FindAll(1, 10000, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by date range
|
||||||
|
var filteredArchives []models.Archive
|
||||||
|
for _, archive := range archives {
|
||||||
|
if req.StartDate != nil && archive.ArchivedAt.Before(*req.StartDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if req.EndDate != nil && archive.ArchivedAt.After(*req.EndDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredArchives = append(filteredArchives, archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf := utils.NewPDFExporter()
|
||||||
|
pdf.AddTitle("Laporan Barang yang Diarsipkan")
|
||||||
|
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
|
||||||
|
formatDate(req.StartDate),
|
||||||
|
formatDate(req.EndDate)))
|
||||||
|
pdf.AddNewLine()
|
||||||
|
|
||||||
|
headers := []string{"No", "Nama Barang", "Kategori", "Alasan Arsip", "Tanggal Arsip"}
|
||||||
|
var data [][]string
|
||||||
|
for i, archive := range filteredArchives {
|
||||||
|
data = append(data, []string{
|
||||||
|
fmt.Sprintf("%d", i+1),
|
||||||
|
archive.Name,
|
||||||
|
archive.Category.Name,
|
||||||
|
archive.ArchivedReason,
|
||||||
|
archive.ArchivedAt.Format("02 Jan 2006"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pdf.AddTable(headers, data)
|
||||||
|
|
||||||
|
pdf.AddNewLine()
|
||||||
|
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredArchives)))
|
||||||
|
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
||||||
|
fmt.Sprintf("Exported archives report (PDF, %d items)", len(filteredArchives)),
|
||||||
|
ipAddress, userAgent)
|
||||||
|
|
||||||
|
return pdf.Output(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportClaimsToPDF exports claims to PDF
|
||||||
|
func (s *ExportService) ExportClaimsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
||||||
|
claims, _, err := s.claimRepo.FindAll(1, 10000, req.Status, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by date range
|
||||||
|
var filteredClaims []models.Claim
|
||||||
|
for _, claim := range claims {
|
||||||
|
if req.StartDate != nil && claim.CreatedAt.Before(*req.StartDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if req.EndDate != nil && claim.CreatedAt.After(*req.EndDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredClaims = append(filteredClaims, claim)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf := utils.NewPDFExporter()
|
||||||
|
pdf.AddTitle("Laporan Klaim Barang")
|
||||||
|
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
|
||||||
|
formatDate(req.StartDate),
|
||||||
|
formatDate(req.EndDate)))
|
||||||
|
pdf.AddNewLine()
|
||||||
|
|
||||||
|
headers := []string{"No", "Barang", "Pengklaim", "Status", "Tanggal Klaim"}
|
||||||
|
var data [][]string
|
||||||
|
for i, claim := range filteredClaims {
|
||||||
|
data = append(data, []string{
|
||||||
|
fmt.Sprintf("%d", i+1),
|
||||||
|
claim.Item.Name,
|
||||||
|
claim.User.Name,
|
||||||
|
claim.Status,
|
||||||
|
claim.CreatedAt.Format("02 Jan 2006"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pdf.AddTable(headers, data)
|
||||||
|
|
||||||
|
pdf.AddNewLine()
|
||||||
|
pdf.AddText(fmt.Sprintf("Total: %d klaim", len(filteredClaims)))
|
||||||
|
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
||||||
|
fmt.Sprintf("Exported claims report (PDF, %d claims)", len(filteredClaims)),
|
||||||
|
ipAddress, userAgent)
|
||||||
|
|
||||||
|
return pdf.Output(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format date
|
||||||
|
func formatDate(date *time.Time) string {
|
||||||
|
if date == nil {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
return date.Format("02 Jan 2006")
|
||||||
|
}
|
||||||
615
internal/services/item_service.go
Normal file
615
internal/services/item_service.go
Normal file
@ -0,0 +1,615 @@
|
|||||||
|
// internal/services/item_service.go - FIXED VERSION
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"time"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ItemService struct {
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
categoryRepo *repositories.CategoryRepository
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
revisionRepo *repositories.RevisionLogRepository
|
||||||
|
db *gorm.DB
|
||||||
|
lostItemRepo *repositories.LostItemRepository // FIXED: pointer type
|
||||||
|
notificationRepo *repositories.NotificationRepository // FIXED: pointer type
|
||||||
|
matchRepo *repositories.MatchResultRepository // TAMBAHAN
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewItemService(db *gorm.DB) *ItemService {
|
||||||
|
itemRepo := repositories.NewItemRepository(db)
|
||||||
|
lostItemRepo := repositories.NewLostItemRepository(db)
|
||||||
|
notifRepo := repositories.NewNotificationRepository(db)
|
||||||
|
matchRepo := repositories.NewMatchResultRepository(db)
|
||||||
|
|
||||||
|
return &ItemService{
|
||||||
|
db: db,
|
||||||
|
itemRepo: itemRepo,
|
||||||
|
categoryRepo: repositories.NewCategoryRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
revisionRepo: repositories.NewRevisionLogRepository(db),
|
||||||
|
lostItemRepo: lostItemRepo,
|
||||||
|
notificationRepo: notifRepo,
|
||||||
|
matchRepo: matchRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ItemService) CreateFoundItemLinked(reporterID uint, req CreateFoundItemLinkedRequest, ipAddress, userAgent string) (*models.Item, error) {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return nil, tx.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Buat Item Awal (Status Default: unclaimed)
|
||||||
|
item := &models.Item{
|
||||||
|
Name: req.Name,
|
||||||
|
CategoryID: req.CategoryID,
|
||||||
|
Location: req.Location,
|
||||||
|
DateFound: time.Now(),
|
||||||
|
Description: req.Description,
|
||||||
|
ReporterID: reporterID,
|
||||||
|
ReporterName: req.ReporterName,
|
||||||
|
ReporterContact: req.ReporterContact,
|
||||||
|
Status: models.ItemStatusUnclaimed, // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PhotoURL != "" {
|
||||||
|
item.PhotoURL = req.PhotoURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(item).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Logika "Langsung ke Pemilik"
|
||||||
|
// Pastikan req.LostItemID & req.IsDirectToOwner terisi (Cek tag JSON struct!)
|
||||||
|
if req.IsDirectToOwner && req.LostItemID != 0 {
|
||||||
|
var lostItem models.LostItem
|
||||||
|
// Gunakan Preload agar data User tidak nil saat dipakai nanti
|
||||||
|
if err := tx.Preload("User").First(&lostItem, req.LostItemID).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, errors.New("Laporan kehilangan tidak ditemukan")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A. Update Status Lost Item jadi "claimed"
|
||||||
|
if err := tx.Model(&lostItem).Update("status", "claimed").Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. Update Status Item Temuan jadi "waiting_owner"
|
||||||
|
if err := tx.Model(item).Update("status", "waiting_owner").Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ PENTING: Update struct di memori supaya response API benar
|
||||||
|
itemID := item.ID
|
||||||
|
|
||||||
|
newClaim := &models.Claim{
|
||||||
|
ItemID: &itemID, // PENTING: Harus pointer
|
||||||
|
UserID: lostItem.UserID,
|
||||||
|
Description: "Auto-match: Ditemukan dan diserahkan langsung ke pemilik.",
|
||||||
|
Contact: lostItem.User.Phone,
|
||||||
|
Status: models.ClaimStatusWaitingOwner,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(newClaim).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
type CreateItemRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
CategoryID uint `json:"category_id" binding:"required"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location" binding:"required"`
|
||||||
|
Description string `json:"description" binding:"required"`
|
||||||
|
SecretDetails string `json:"secret_details" binding:"required"`
|
||||||
|
DateFound time.Time `json:"date_found" binding:"required"`
|
||||||
|
ReporterName string `json:"reporter_name" binding:"required"`
|
||||||
|
ReporterContact string `json:"reporter_contact" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateItemRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
CategoryID uint `json:"category_id"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SecretDetails string `json:"secret_details"`
|
||||||
|
DateFound time.Time `json:"date_found"`
|
||||||
|
ReporterName string `json:"reporter_name"`
|
||||||
|
ReporterContact string `json:"reporter_contact"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFoundItemLinkedRequest struct {
|
||||||
|
CreateItemRequest
|
||||||
|
LostItemID uint `json:"lost_item_id" binding:"required"`
|
||||||
|
IsDirectToOwner bool `json:"is_direct_to_owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllItems gets all items with CONTEXT TIMEOUT
|
||||||
|
func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
txRepo := repositories.NewItemRepository(s.db.WithContext(ctx))
|
||||||
|
items, total, err := txRepo.FindAll(page, limit, status, category, search)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, 0, errors.New("request timeout: query took too long")
|
||||||
|
}
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.ItemPublicResponse
|
||||||
|
for _, item := range items {
|
||||||
|
responses = append(responses, item.ToPublicResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ItemService) RunAutoArchive(ipAddress, userAgent string) (int, error) {
|
||||||
|
// Panggil Repository
|
||||||
|
count, err := s.itemRepo.CallArchiveExpiredProcedure()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log Audit jika ada yang diarsip
|
||||||
|
if count > 0 {
|
||||||
|
details := fmt.Sprintf("Auto-archived %d expired items using Stored Procedure", count)
|
||||||
|
// Gunakan ID 0 atau nil untuk system action
|
||||||
|
s.auditLogRepo.Log(nil, "auto_archive", "system", nil, details, ipAddress, userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED: CreateItem - NOW INCLUDES SecretDetails
|
||||||
|
func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var item *models.Item
|
||||||
|
|
||||||
|
// ✅ TRANSACTION untuk create item + audit log
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Verify category exists
|
||||||
|
var category models.Category
|
||||||
|
if err := tx.Where("id = ? AND deleted_at IS NULL", req.CategoryID).
|
||||||
|
First(&category).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("invalid category")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to verify category: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create item - ✅ SEKARANG INCLUDE SecretDetails
|
||||||
|
item = &models.Item{
|
||||||
|
Name: req.Name,
|
||||||
|
CategoryID: req.CategoryID,
|
||||||
|
PhotoURL: req.PhotoURL,
|
||||||
|
Location: req.Location,
|
||||||
|
Description: req.Description,
|
||||||
|
SecretDetails: req.SecretDetails, // ✅ TAMBAHKAN INI
|
||||||
|
DateFound: req.DateFound,
|
||||||
|
Status: models.ItemStatusUnclaimed,
|
||||||
|
ReporterID: reporterID,
|
||||||
|
ReporterName: req.ReporterName,
|
||||||
|
ReporterContact: req.ReporterContact,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(item).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create audit log
|
||||||
|
auditLog := &models.AuditLog{
|
||||||
|
UserID: &reporterID,
|
||||||
|
Action: models.ActionCreate,
|
||||||
|
EntityType: models.EntityItem,
|
||||||
|
EntityID: &item.ID,
|
||||||
|
Details: fmt.Sprintf("Item created: %s", item.Name),
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
if err := tx.Create(auditLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create audit log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ FIXED: UpdateItem - NOW HANDLES SecretDetails
|
||||||
|
// internal/services/item_service.go
|
||||||
|
|
||||||
|
// UpdateItem updates an item with transaction, locking, and status change support
|
||||||
|
func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
|
||||||
|
// ✅ Tambahkan logging
|
||||||
|
log.Printf("🔍 UpdateItem Request:")
|
||||||
|
log.Printf(" ItemID: %d", itemID)
|
||||||
|
log.Printf(" Name: %s", req.Name)
|
||||||
|
log.Printf(" CategoryID: %d", req.CategoryID)
|
||||||
|
log.Printf(" Status: %s", req.Status)
|
||||||
|
log.Printf(" Location: %s", req.Location)
|
||||||
|
log.Printf(" Description: %s", req.Description)
|
||||||
|
log.Printf(" SecretDetails: %s", req.SecretDetails)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var updatedItem *models.Item
|
||||||
|
|
||||||
|
// ✅ TRANSACTION + LOCKING untuk update item
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var item models.Item
|
||||||
|
|
||||||
|
// 1. Ambil data User beserta Role-nya (FIXED: Preload Role)
|
||||||
|
var user models.User
|
||||||
|
if err := tx.Preload("Role").First(&user, userID).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lock item untuk mencegah race condition saat update
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Preload("Category").
|
||||||
|
Where("id = ? AND deleted_at IS NULL", itemID).
|
||||||
|
First(&item).Error; err != nil {
|
||||||
|
return errors.New("item not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cek Permission: Izinkan jika User adalah Owner ATAU Admin/Manager
|
||||||
|
isOwner := item.ReporterID == userID
|
||||||
|
isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager"
|
||||||
|
|
||||||
|
if !isOwner && !isManagerOrAdmin {
|
||||||
|
return errors.New("unauthorized to edit this item")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Validasi Status Item
|
||||||
|
// - Case Closed tetap permanen (tidak bisa diedit siapapun)
|
||||||
|
if item.Status == models.ItemStatusCaseClosed {
|
||||||
|
return errors.New("cannot edit item with status: " + item.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// - Expired hanya bisa diedit oleh Manager/Admin
|
||||||
|
if !isManagerOrAdmin && item.IsExpired() {
|
||||||
|
return errors.New("cannot edit expired item")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track changes for revision log
|
||||||
|
revisionCreated := false
|
||||||
|
|
||||||
|
// --- Update Fields Logics ---
|
||||||
|
|
||||||
|
if req.Name != "" && req.Name != item.Name {
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "name",
|
||||||
|
OldValue: item.Name,
|
||||||
|
NewValue: req.Name,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log: %w", err)
|
||||||
|
}
|
||||||
|
item.Name = req.Name
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CategoryID != 0 && req.CategoryID != item.CategoryID {
|
||||||
|
// Verify new category exists
|
||||||
|
var newCategory models.Category
|
||||||
|
if err := tx.Where("id = ?", req.CategoryID).First(&newCategory).Error; err != nil {
|
||||||
|
return errors.New("invalid category")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldCatName := item.Category.Name
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "category",
|
||||||
|
OldValue: oldCatName,
|
||||||
|
NewValue: newCategory.Name,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log: %w", err)
|
||||||
|
}
|
||||||
|
item.CategoryID = req.CategoryID
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Location != "" && req.Location != item.Location {
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "location",
|
||||||
|
OldValue: item.Location,
|
||||||
|
NewValue: req.Location,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log: %w", err)
|
||||||
|
}
|
||||||
|
item.Location = req.Location
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Description != "" && req.Description != item.Description {
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "description",
|
||||||
|
OldValue: item.Description,
|
||||||
|
NewValue: req.Description,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log: %w", err)
|
||||||
|
}
|
||||||
|
item.Description = req.Description
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SecretDetails update
|
||||||
|
if req.SecretDetails != "" && req.SecretDetails != item.SecretDetails {
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "secret_details",
|
||||||
|
OldValue: item.SecretDetails,
|
||||||
|
NewValue: req.SecretDetails,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log: %w", err)
|
||||||
|
}
|
||||||
|
item.SecretDetails = req.SecretDetails
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PhotoURL != "" && req.PhotoURL != item.PhotoURL {
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "photo_url",
|
||||||
|
OldValue: item.PhotoURL,
|
||||||
|
NewValue: req.PhotoURL,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log: %w", err)
|
||||||
|
}
|
||||||
|
item.PhotoURL = req.PhotoURL
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ NEW: Handle Status Update (Khusus Manager/Admin)
|
||||||
|
if req.Status != "" && req.Status != item.Status {
|
||||||
|
// Validasi status yang diperbolehkan untuk di-set manual
|
||||||
|
validStatuses := map[string]bool{
|
||||||
|
models.ItemStatusUnclaimed: true,
|
||||||
|
models.ItemStatusVerified: true,
|
||||||
|
models.ItemStatusExpired: true,
|
||||||
|
models.ItemStatusCaseClosed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validStatuses[req.Status] {
|
||||||
|
return errors.New("invalid status value")
|
||||||
|
}
|
||||||
|
|
||||||
|
revLog := &models.RevisionLog{
|
||||||
|
ItemID: itemID,
|
||||||
|
UserID: userID,
|
||||||
|
FieldName: "status",
|
||||||
|
OldValue: item.Status,
|
||||||
|
NewValue: req.Status,
|
||||||
|
Reason: req.Reason,
|
||||||
|
}
|
||||||
|
if err := tx.Create(revLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create revision log for status: %w", err)
|
||||||
|
}
|
||||||
|
item.Status = req.Status
|
||||||
|
revisionCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !revisionCreated {
|
||||||
|
return errors.New("no changes detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated item
|
||||||
|
if err := tx.Save(&item).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to update item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log
|
||||||
|
auditLog := &models.AuditLog{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: models.ActionUpdate,
|
||||||
|
EntityType: models.EntityItem,
|
||||||
|
EntityID: &itemID,
|
||||||
|
Details: fmt.Sprintf("Item updated: %s (Reason: %s)", item.Name, req.Reason),
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
if err := tx.Create(auditLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create audit log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedItem = &item
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemStatus updates item status with TRANSACTION
|
||||||
|
func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Lock item
|
||||||
|
var item models.Item
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ?", itemID).
|
||||||
|
First(&item).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to lock item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
if err := tx.Model(&item).Update("status", status).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to update status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log
|
||||||
|
auditLog := &models.AuditLog{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: models.ActionUpdate,
|
||||||
|
EntityType: models.EntityItem,
|
||||||
|
EntityID: &itemID,
|
||||||
|
Details: fmt.Sprintf("Item status updated to: %s", status),
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
if err := tx.Create(auditLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create audit log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteItem deletes an item with TRANSACTION
|
||||||
|
func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Ambil data User yang sedang request (untuk cek Role)
|
||||||
|
var user models.User
|
||||||
|
if err := tx.Preload("Role").First(&user, userID).Error; err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lock item
|
||||||
|
var item models.Item
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ?", itemID).
|
||||||
|
First(&item).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to lock item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PERMISSION CHECK: Izinkan jika Owner ATAU Manager/Admin
|
||||||
|
isOwner := item.ReporterID == userID
|
||||||
|
isManagerOrAdmin := user.Role.Name == "admin" || user.Role.Name == "manager"
|
||||||
|
|
||||||
|
if !isOwner && !isManagerOrAdmin {
|
||||||
|
return errors.New("unauthorized to delete this item")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ✅ (BARU) Validasi Active Claims - PENGGANTI TRIGGER trg_items_before_delete
|
||||||
|
// Cek apakah ada claims dengan status 'pending' untuk item ini
|
||||||
|
var activeClaims int64
|
||||||
|
if err := tx.Model(&models.Claim{}).
|
||||||
|
Where("item_id = ? AND status IN ? AND deleted_at IS NULL",
|
||||||
|
itemID, []string{models.ClaimStatusPending, models.ClaimStatusWaitingOwner}).
|
||||||
|
Count(&activeClaims).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check active claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if activeClaims > 0 {
|
||||||
|
return errors.New("cannot delete item with active claims (pending or waiting owner)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed {
|
||||||
|
return errors.New("cannot delete item with status: " + item.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Delete(&item).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to delete item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Create audit log
|
||||||
|
auditLog := &models.AuditLog{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: models.ActionDelete,
|
||||||
|
EntityType: models.EntityItem,
|
||||||
|
EntityID: &itemID,
|
||||||
|
Details: fmt.Sprintf("Item deleted: %s", item.Name),
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
if err := tx.Create(auditLog).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create audit log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemsByReporter gets items by reporter with CONTEXT
|
||||||
|
func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
txRepo := repositories.NewItemRepository(s.db.WithContext(ctx))
|
||||||
|
return txRepo.FindByReporter(reporterID, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemRevisionHistory gets revision history with CONTEXT
|
||||||
|
func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
txRepo := repositories.NewRevisionLogRepository(s.db.WithContext(ctx))
|
||||||
|
logs, total, err := txRepo.FindByItem(itemID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, 0, errors.New("request timeout")
|
||||||
|
}
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.RevisionLogResponse
|
||||||
|
for _, log := range logs {
|
||||||
|
responses = append(responses, log.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
362
internal/services/lost_item_service.go
Normal file
362
internal/services/lost_item_service.go
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"time"
|
||||||
|
"fmt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LostItemService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
lostItemRepo *repositories.LostItemRepository
|
||||||
|
categoryRepo *repositories.CategoryRepository
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
userRepo *repositories.UserRepository
|
||||||
|
notificationRepo *repositories.NotificationRepository
|
||||||
|
claimRepo *repositories.ClaimRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLostItemService(db *gorm.DB) *LostItemService {
|
||||||
|
return &LostItemService{
|
||||||
|
db: db,
|
||||||
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
||||||
|
categoryRepo: repositories.NewCategoryRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
userRepo: repositories.NewUserRepository(db),
|
||||||
|
notificationRepo: repositories.NewNotificationRepository(db),
|
||||||
|
claimRepo: repositories.NewClaimRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateLostItemRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
CategoryID uint `json:"category_id" binding:"required"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description" binding:"required"`
|
||||||
|
DateLost time.Time `json:"date_lost" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLostItemRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
CategoryID uint `json:"category_id"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DateLost time.Time `json:"date_lost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateLostItemClaimRequest struct {
|
||||||
|
Description string `json:"description" binding:"required"`
|
||||||
|
Contact string `json:"contact" binding:"required"`
|
||||||
|
ProofURL string `json:"proof_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) GetAllLostItems(page, limit int, status, category, search string, userID *uint) ([]models.LostItemResponse, int64, error) {
|
||||||
|
lostItems, total, err := s.lostItemRepo.FindAll(page, limit, status, category, search, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.LostItemResponse
|
||||||
|
for _, lostItem := range lostItems {
|
||||||
|
response := lostItem.ToResponse()
|
||||||
|
|
||||||
|
if lostItem.DirectClaimID != nil {
|
||||||
|
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
|
||||||
|
if err == nil {
|
||||||
|
response.DirectClaimStatus = claim.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responses = append(responses, response)
|
||||||
|
}
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) GetLostItemByID(id uint) (*models.LostItem, error) {
|
||||||
|
lostItem, err := s.lostItemRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if lostItem.DirectClaimID != nil {
|
||||||
|
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
|
||||||
|
if err == nil {
|
||||||
|
lostItem.DirectClaim = claim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lostItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) CreateLostItem(userID uint, req CreateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
|
||||||
|
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
|
||||||
|
return nil, errors.New("invalid category")
|
||||||
|
}
|
||||||
|
|
||||||
|
lostItem := &models.LostItem{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
CategoryID: req.CategoryID,
|
||||||
|
Color: req.Color,
|
||||||
|
Location: req.Location,
|
||||||
|
Description: req.Description,
|
||||||
|
DateLost: req.DateLost,
|
||||||
|
Status: models.LostItemStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.lostItemRepo.Create(lostItem); err != nil {
|
||||||
|
return nil, errors.New("failed to create lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityLostItem, &lostItem.ID,
|
||||||
|
"Lost item report created: "+lostItem.Name, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return s.lostItemRepo.FindByID(lostItem.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) UpdateLostItem(userID, lostItemID uint, req UpdateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
|
||||||
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek authorization
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner := lostItem.UserID == userID
|
||||||
|
isAdminOrManager := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
|
||||||
|
|
||||||
|
if !isOwner && !isAdminOrManager {
|
||||||
|
return nil, errors.New("unauthorized to update this lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAdminOrManager && !lostItem.IsActive() {
|
||||||
|
return nil, errors.New("cannot update non-active lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ UPDATE: Prioritaskan category_id, update terlebih dahulu
|
||||||
|
if req.CategoryID != 0 {
|
||||||
|
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
|
||||||
|
return nil, errors.New("invalid category")
|
||||||
|
}
|
||||||
|
// LOG untuk debug
|
||||||
|
fmt.Printf("📝 Updating category from %d to %d\n", lostItem.CategoryID, req.CategoryID)
|
||||||
|
lostItem.CategoryID = req.CategoryID
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
lostItem.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.Color != "" {
|
||||||
|
lostItem.Color = req.Color
|
||||||
|
}
|
||||||
|
if req.Location != "" {
|
||||||
|
lostItem.Location = req.Location
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
lostItem.Description = req.Description
|
||||||
|
}
|
||||||
|
if !req.DateLost.IsZero() {
|
||||||
|
lostItem.DateLost = req.DateLost
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ PASTIKAN update tersimpan
|
||||||
|
if err := s.lostItemRepo.Update(lostItem); err != nil {
|
||||||
|
fmt.Printf("❌ Error updating lost item: %v\n", err)
|
||||||
|
return nil, errors.New("failed to update lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
actionDesc := fmt.Sprintf("Lost item report updated: %s", lostItem.Name)
|
||||||
|
if !isOwner {
|
||||||
|
actionDesc = fmt.Sprintf("Lost item report updated by %s: %s", user.Role.Name, lostItem.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
|
||||||
|
actionDesc, ipAddress, userAgent)
|
||||||
|
|
||||||
|
// ✅ RELOAD dari database untuk memastikan data terbaru
|
||||||
|
return s.lostItemRepo.FindByID(lostItem.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) UpdateLostItemStatus(userID, lostItemID uint, status string, ipAddress, userAgent string) error {
|
||||||
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAMBAHKAN: Cek admin/manager permission
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner := lostItem.UserID == userID
|
||||||
|
isAdminOrManager := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
|
||||||
|
|
||||||
|
if !isOwner && !isAdminOrManager {
|
||||||
|
return errors.New("unauthorized to update this lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.lostItemRepo.UpdateStatus(lostItemID, status); err != nil {
|
||||||
|
return errors.New("failed to update lost item status")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
|
||||||
|
"Lost item status updated to: "+status, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) DeleteLostItem(userID, lostItemID uint, ipAddress, userAgent string) error {
|
||||||
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner := lostItem.UserID == userID
|
||||||
|
isManagerOrAdmin := user.Role.Name == models.RoleAdmin || user.Role.Name == models.RoleManager
|
||||||
|
|
||||||
|
if !isOwner && !isManagerOrAdmin {
|
||||||
|
return errors.New("unauthorized to delete this lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.lostItemRepo.Delete(lostItemID); err != nil {
|
||||||
|
return errors.New("failed to delete lost item report")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityLostItem, &lostItemID, "Lost item report deleted: "+lostItem.Name, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) GetLostItemsByUser(userID uint, page, limit int) ([]models.LostItemResponse, int64, error) {
|
||||||
|
lostItems, total, err := s.lostItemRepo.FindByUser(userID, page, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.LostItemResponse
|
||||||
|
for _, lostItem := range lostItems {
|
||||||
|
response := lostItem.ToResponse()
|
||||||
|
|
||||||
|
// ✅ PERBAIKAN: Load data claim lengkap jika ada direct claim
|
||||||
|
if lostItem.DirectClaimID != nil {
|
||||||
|
claim, err := s.claimRepo.FindByID(*lostItem.DirectClaimID)
|
||||||
|
if err == nil {
|
||||||
|
// Load relasi user penemu agar namanya muncul di frontend
|
||||||
|
s.db.Preload("User").First(claim, claim.ID)
|
||||||
|
|
||||||
|
response.DirectClaimStatus = claim.Status
|
||||||
|
|
||||||
|
// Konversi claim ke response dan tempelkan ke lost item response
|
||||||
|
claimResp := claim.ToResponse()
|
||||||
|
response.DirectClaim = &claimResp // Pastikan struct LostItemResponse punya field ini
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responses = append(responses, response)
|
||||||
|
}
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LostItemService) DirectClaimToOwner(
|
||||||
|
finderUserID uint,
|
||||||
|
lostItemID uint,
|
||||||
|
req CreateLostItemClaimRequest,
|
||||||
|
ipAddress string, // Tambahkan ini agar audit log valid
|
||||||
|
userAgent string, // Tambahkan ini agar audit log valid
|
||||||
|
) (*models.Claim, error) {
|
||||||
|
var createdClaim *models.Claim
|
||||||
|
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Get Lost Item
|
||||||
|
var lostItem models.LostItem
|
||||||
|
if err := tx.First(&lostItem, lostItemID).Error; err != nil {
|
||||||
|
return errors.New("lost item not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validasi
|
||||||
|
if lostItem.Status != models.LostItemStatusActive {
|
||||||
|
return errors.New("lost item is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lostItem.UserID == finderUserID {
|
||||||
|
return errors.New("cannot claim your own lost item")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lostItem.DirectClaimID != nil {
|
||||||
|
return errors.New("this lost item already has a pending claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create Claim (LOGIC OPSI A)
|
||||||
|
// Kita set ItemID nil, tapi isi LostItemID
|
||||||
|
claim := models.Claim{
|
||||||
|
ItemID: nil, // PENTING: Set nil (jangan 0)
|
||||||
|
LostItemID: &lostItemID, // PENTING: Link ke LostItem
|
||||||
|
UserID: finderUserID,
|
||||||
|
Description: req.Description,
|
||||||
|
Contact: req.Contact,
|
||||||
|
ProofURL: req.ProofURL,
|
||||||
|
Status: models.ClaimStatusWaitingOwner,
|
||||||
|
Notes: fmt.Sprintf("Direct claim for lost item #%d", lostItemID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&claim).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createdClaim = &claim
|
||||||
|
|
||||||
|
// 4. Update Lost Item Status
|
||||||
|
// Status diganti agar tidak muncul di pencarian publik sementara waktu
|
||||||
|
// Pastikan constant ini ada di models, atau gunakan string "pending_verification"
|
||||||
|
lostItem.Status = "pending_verification"
|
||||||
|
lostItem.DirectClaimID = &claim.ID
|
||||||
|
|
||||||
|
if err := tx.Save(&lostItem).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Notification to Owner
|
||||||
|
s.notificationRepo.Notify(
|
||||||
|
lostItem.UserID,
|
||||||
|
"direct_claim_received",
|
||||||
|
"🎯 Barang Anda Ditemukan?",
|
||||||
|
fmt.Sprintf("Seseorang mengklaim menemukan '%s'. Silakan verifikasi klaim ini.", lostItem.Name),
|
||||||
|
"claim", // Entity Type arahkan ke claim
|
||||||
|
&claim.ID, // Entity ID arahkan ke claim ID agar owner bisa klik detail claim
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. Audit Log
|
||||||
|
s.auditLogRepo.Log(
|
||||||
|
&finderUserID,
|
||||||
|
"create_direct_claim",
|
||||||
|
"claim",
|
||||||
|
&claim.ID,
|
||||||
|
fmt.Sprintf("Direct claim created for lost item #%d", lostItemID),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdClaim, nil
|
||||||
|
}
|
||||||
205
internal/services/match_service.go
Normal file
205
internal/services/match_service.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
// internal/services/match_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchService struct {
|
||||||
|
db *gorm.DB // Tambahkan ini
|
||||||
|
matchRepo *repositories.MatchResultRepository
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
lostItemRepo *repositories.LostItemRepository
|
||||||
|
notificationRepo *repositories.NotificationRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatchService(db *gorm.DB) *MatchService {
|
||||||
|
return &MatchService{
|
||||||
|
db: db, // Tambahkan ini
|
||||||
|
matchRepo: repositories.NewMatchResultRepository(db),
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
||||||
|
notificationRepo: repositories.NewNotificationRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchedField represents a matched field between items
|
||||||
|
type MatchedField struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
LostValue string `json:"lost_value"`
|
||||||
|
FoundValue string `json:"found_value"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSimilarItems finds similar items for a lost item report
|
||||||
|
func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) {
|
||||||
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for items in same category
|
||||||
|
items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []models.MatchResultResponse
|
||||||
|
for _, item := range items {
|
||||||
|
// Calculate similarity
|
||||||
|
score, matchedFields := s.calculateSimilarity(lostItem, &item)
|
||||||
|
|
||||||
|
// Only include if score is reasonable (>= 30%)
|
||||||
|
if score >= 30.0 {
|
||||||
|
// Check if match already exists
|
||||||
|
exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID)
|
||||||
|
if !exists {
|
||||||
|
// Create match result
|
||||||
|
matchedFieldsJSON, _ := json.Marshal(matchedFields)
|
||||||
|
match := &models.MatchResult{
|
||||||
|
LostItemID: lostItemID,
|
||||||
|
ItemID: item.ID,
|
||||||
|
SimilarityScore: score,
|
||||||
|
MatchedFields: string(matchedFieldsJSON),
|
||||||
|
IsNotified: false,
|
||||||
|
}
|
||||||
|
s.matchRepo.Create(match)
|
||||||
|
|
||||||
|
// Reload with relations
|
||||||
|
match, _ = s.matchRepo.FindByID(match.ID)
|
||||||
|
results = append(results, match.ToResponse())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchesForLostItem gets all matches for a lost item
|
||||||
|
func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) {
|
||||||
|
matches, err := s.matchRepo.FindByLostItem(lostItemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.MatchResultResponse
|
||||||
|
for _, match := range matches {
|
||||||
|
responses = append(responses, match.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatchesForItem gets all matches for an item
|
||||||
|
func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) {
|
||||||
|
matches, err := s.matchRepo.FindByItem(itemID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.MatchResultResponse
|
||||||
|
for _, match := range matches {
|
||||||
|
responses = append(responses, match.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMatchNewItem automatically matches a new item with lost items
|
||||||
|
func (s *MatchService) AutoMatchNewItem(itemID uint) error {
|
||||||
|
item, err := s.itemRepo.FindByID(itemID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find active lost items in same category
|
||||||
|
lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lostItem := range lostItems {
|
||||||
|
// Calculate similarity
|
||||||
|
score, matchedFields := s.calculateSimilarity(&lostItem, item)
|
||||||
|
|
||||||
|
// Create match if score is high enough (>= 50% for auto-match)
|
||||||
|
if score >= 50.0 {
|
||||||
|
// Check if match already exists
|
||||||
|
exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID)
|
||||||
|
if !exists {
|
||||||
|
matchedFieldsJSON, _ := json.Marshal(matchedFields)
|
||||||
|
match := &models.MatchResult{
|
||||||
|
LostItemID: lostItem.ID,
|
||||||
|
ItemID: itemID,
|
||||||
|
SimilarityScore: score,
|
||||||
|
MatchedFields: string(matchedFieldsJSON),
|
||||||
|
IsNotified: false,
|
||||||
|
}
|
||||||
|
s.matchRepo.Create(match)
|
||||||
|
|
||||||
|
// Send notification to lost item owner - PERBAIKAN DI SINI
|
||||||
|
models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID)
|
||||||
|
s.matchRepo.MarkAsNotified(match.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateSimilarity calculates similarity between lost item and found item
|
||||||
|
func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) {
|
||||||
|
var matchedFields []MatchedField
|
||||||
|
|
||||||
|
// 1. Hard Filter: Kategori HARUS sama.
|
||||||
|
if lostItem.CategoryID != item.CategoryID {
|
||||||
|
return 0.0, matchedFields
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A. Hitung Kecocokan Nama (50%) ---
|
||||||
|
nameSim := utils.CalculateStringSimilarity(lostItem.Name, item.Name)
|
||||||
|
nameScore := nameSim * 100.0
|
||||||
|
|
||||||
|
if nameScore > 40.0 {
|
||||||
|
matchedFields = append(matchedFields, MatchedField{
|
||||||
|
Field: "name",
|
||||||
|
LostValue: lostItem.Name,
|
||||||
|
FoundValue: item.Name,
|
||||||
|
Score: nameScore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B. Hitung Kecocokan Secret Details / Deskripsi (50%) ---
|
||||||
|
// Target: Utamakan Secret Details penemu, fallback ke Description
|
||||||
|
targetText := item.SecretDetails
|
||||||
|
foundValueType := "Secret Details"
|
||||||
|
|
||||||
|
if targetText == "" {
|
||||||
|
targetText = item.Description
|
||||||
|
foundValueType = "Description"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandingkan Deskripsi User vs Target Penemu
|
||||||
|
descSim := utils.CalculateStringSimilarity(lostItem.Description, targetText)
|
||||||
|
descScore := descSim * 100.0
|
||||||
|
|
||||||
|
if descScore > 40.0 {
|
||||||
|
matchedFields = append(matchedFields, MatchedField{
|
||||||
|
Field: "secret_details",
|
||||||
|
LostValue: "User Description",
|
||||||
|
FoundValue: foundValueType,
|
||||||
|
Score: descScore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C. Hitung Skor Akhir ---
|
||||||
|
// Rumus: (Skor Nama * 0.5) + (Skor Deskripsi * 0.5)
|
||||||
|
finalScore := (nameScore * 0.5) + (descScore * 0.5)
|
||||||
|
|
||||||
|
return finalScore, matchedFields
|
||||||
|
}
|
||||||
116
internal/services/notification_service.go
Normal file
116
internal/services/notification_service.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
// internal/services/notification_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationService struct {
|
||||||
|
db *gorm.DB // Tambahkan ini
|
||||||
|
notificationRepo *repositories.NotificationRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotificationService(db *gorm.DB) *NotificationService {
|
||||||
|
return &NotificationService{
|
||||||
|
db: db, // Tambahkan ini
|
||||||
|
notificationRepo: repositories.NewNotificationRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GetUserNotifications gets notifications for a user
|
||||||
|
func (s *NotificationService) GetUserNotifications(userID uint, page, limit int, onlyUnread bool) ([]models.NotificationResponse, int64, error) {
|
||||||
|
notifications, total, err := s.notificationRepo.FindByUser(userID, page, limit, onlyUnread)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.NotificationResponse
|
||||||
|
for _, notification := range notifications {
|
||||||
|
responses = append(responses, notification.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationByID gets notification by ID
|
||||||
|
func (s *NotificationService) GetNotificationByID(userID, notificationID uint) (*models.Notification, error) {
|
||||||
|
notification, err := s.notificationRepo.FindByID(notificationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if notification.UserID != userID {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsRead marks a notification as read
|
||||||
|
func (s *NotificationService) MarkAsRead(userID, notificationID uint) error {
|
||||||
|
notification, err := s.notificationRepo.FindByID(notificationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if notification.UserID != userID {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.notificationRepo.MarkAsRead(notificationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllAsRead marks all notifications as read for a user
|
||||||
|
func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
||||||
|
return s.notificationRepo.MarkAllAsRead(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNotification deletes a notification
|
||||||
|
func (s *NotificationService) DeleteNotification(userID, notificationID uint) error {
|
||||||
|
notification, err := s.notificationRepo.FindByID(notificationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if notification.UserID != userID {
|
||||||
|
return errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.notificationRepo.Delete(notificationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllNotifications deletes all notifications for a user
|
||||||
|
func (s *NotificationService) DeleteAllNotifications(userID uint) error {
|
||||||
|
return s.notificationRepo.DeleteAllForUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnread counts unread notifications for a user
|
||||||
|
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
|
||||||
|
return s.notificationRepo.CountUnread(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNotification creates a new notification
|
||||||
|
func (s *NotificationService) CreateNotification(userID uint, notifType, title, message, entityType string, entityID *uint) error {
|
||||||
|
return s.notificationRepo.Notify(userID, notifType, title, message, entityType, entityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMatchNotification sends notification when a match is found
|
||||||
|
func (s *NotificationService) SendMatchNotification(userID uint, itemName string, matchID uint) error {
|
||||||
|
return models.CreateMatchNotification(s.db, userID, itemName, matchID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendClaimApprovedNotification sends notification when claim is approved
|
||||||
|
func (s *NotificationService) SendClaimApprovedNotification(userID uint, itemName string, claimID uint) error {
|
||||||
|
return models.CreateClaimApprovedNotification(s.db, userID, itemName, claimID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendClaimRejectedNotification sends notification when claim is rejected
|
||||||
|
func (s *NotificationService) SendClaimRejectedNotification(userID uint, itemName, reason string, claimID uint) error {
|
||||||
|
return models.CreateClaimRejectedNotification(s.db, userID, itemName, reason, claimID)
|
||||||
|
}
|
||||||
112
internal/services/role_service.go
Normal file
112
internal/services/role_service.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleService struct {
|
||||||
|
roleRepo *repositories.RoleRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoleService(db *gorm.DB) *RoleService {
|
||||||
|
return &RoleService{
|
||||||
|
roleRepo: repositories.NewRoleRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structs for Requests
|
||||||
|
type CreateRoleRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PermissionIDs []uint `json:"permission_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRoleRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PermissionIDs []uint `json:"permission_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllRoles returns all roles with permissions
|
||||||
|
func (s *RoleService) GetAllRoles() ([]models.Role, error) {
|
||||||
|
return s.roleRepo.FindAllWithPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllPermissions returns list of all permissions
|
||||||
|
func (s *RoleService) GetAllPermissions() ([]models.Permission, error) {
|
||||||
|
return s.roleRepo.FindAllPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole creates a new role and assigns permissions
|
||||||
|
func (s *RoleService) CreateRole(req CreateRoleRequest) (*models.Role, error) {
|
||||||
|
// Check if role name already exists
|
||||||
|
existing, _ := s.roleRepo.FindByName(req.Name)
|
||||||
|
if existing != nil {
|
||||||
|
return nil, errors.New("role name already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := &models.Role{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create Role
|
||||||
|
if err := s.roleRepo.Create(role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Assign Permissions
|
||||||
|
if len(req.PermissionIDs) > 0 {
|
||||||
|
if err := s.roleRepo.UpdatePermissions(role, req.PermissionIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload to return complete object
|
||||||
|
return s.roleRepo.FindByID(role.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole updates role details and permissions
|
||||||
|
func (s *RoleService) UpdateRole(id uint, req UpdateRoleRequest) (*models.Role, error) {
|
||||||
|
role, err := s.roleRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protect core roles from name changes
|
||||||
|
if (role.Name == "admin" || role.Name == "user" || role.Name == "manager") && req.Name != role.Name {
|
||||||
|
return nil, errors.New("cannot change name of system roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if req.Name != "" {
|
||||||
|
role.Name = req.Name
|
||||||
|
}
|
||||||
|
role.Description = req.Description
|
||||||
|
|
||||||
|
// Update Permissions
|
||||||
|
if err := s.roleRepo.UpdatePermissions(role, req.PermissionIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.roleRepo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole deletes a role
|
||||||
|
func (s *RoleService) DeleteRole(id uint) error {
|
||||||
|
role, err := s.roleRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting core system roles
|
||||||
|
if role.Name == "admin" || role.Name == "user" || role.Name == "manager" {
|
||||||
|
return errors.New("cannot delete core system roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.roleRepo.Delete(id)
|
||||||
|
}
|
||||||
234
internal/services/user_service.go
Normal file
234
internal/services/user_service.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// internal/services/user_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserService struct {
|
||||||
|
db *gorm.DB // ✅ Tambahkan ini
|
||||||
|
userRepo *repositories.UserRepository
|
||||||
|
roleRepo *repositories.RoleRepository
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(db *gorm.DB) *UserService {
|
||||||
|
return &UserService{
|
||||||
|
db: db, // ✅ Tambahkan ini
|
||||||
|
userRepo: repositories.NewUserRepository(db),
|
||||||
|
roleRepo: repositories.NewRoleRepository(db),
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfileRequest represents profile update data
|
||||||
|
type UpdateProfileRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
NRP string `json:"nrp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePasswordRequest represents password change data
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile gets user profile
|
||||||
|
func (s *UserService) GetProfile(userID uint) (*models.User, error) {
|
||||||
|
return s.userRepo.FindByID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile - TANPA enkripsi
|
||||||
|
func (s *UserService) UpdateProfile(userID uint, req UpdateProfileRequest, ipAddress, userAgent string) (*models.User, error) {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if req.Name != "" {
|
||||||
|
user.Name = req.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Update phone TANPA enkripsi
|
||||||
|
if req.Phone != "" {
|
||||||
|
user.Phone = req.Phone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Update NRP TANPA enkripsi
|
||||||
|
if req.NRP != "" {
|
||||||
|
// Check if NRP already exists for another user
|
||||||
|
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
|
||||||
|
if existingNRP != nil && existingNRP.ID != userID {
|
||||||
|
return nil, errors.New("NRP already used by another user")
|
||||||
|
}
|
||||||
|
user.NRP = req.NRP
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return nil, errors.New("failed to update profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
|
||||||
|
"Profile updated", ipAddress, userAgent)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword changes user password
|
||||||
|
func (s *UserService) ChangePassword(userID uint, req ChangePasswordRequest, ipAddress, userAgent string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
|
||||||
|
return errors.New("invalid old password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
hashedPassword, err := utils.HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to hash password")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = hashedPassword
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return errors.New("failed to change password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
|
||||||
|
"Password changed", ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserStats gets user statistics
|
||||||
|
func (s *UserService) GetUserStats(userID uint) (map[string]interface{}, error) {
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
|
// ✅ Count items reported by user
|
||||||
|
var itemCount int64
|
||||||
|
if err := s.db.Model(&models.Item{}). // ← Ganti dari s.userRepo.db
|
||||||
|
Where("reporter_id = ? AND deleted_at IS NULL", userID).
|
||||||
|
Count(&itemCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["items_reported"] = itemCount
|
||||||
|
|
||||||
|
// ✅ Count lost items reported
|
||||||
|
var lostItemCount int64
|
||||||
|
if err := s.db.Model(&models.LostItem{}). // ← Ganti dari s.userRepo.db
|
||||||
|
Where("user_id = ? AND deleted_at IS NULL", userID).
|
||||||
|
Count(&lostItemCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["lost_items_reported"] = lostItemCount
|
||||||
|
|
||||||
|
// ✅ Count claims made
|
||||||
|
var claimCount int64
|
||||||
|
if err := s.db.Model(&models.Claim{}). // ← Ganti dari s.userRepo.db
|
||||||
|
Where("user_id = ? AND deleted_at IS NULL", userID).
|
||||||
|
Count(&claimCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["claims_made"] = claimCount
|
||||||
|
|
||||||
|
// ✅ Count approved claims
|
||||||
|
var approvedClaimCount int64
|
||||||
|
if err := s.db.Model(&models.Claim{}). // ← Ganti dari s.userRepo.db
|
||||||
|
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, models.ClaimStatusApproved).
|
||||||
|
Count(&approvedClaimCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["claims_approved"] = approvedClaimCount
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole updates user role (admin only)
|
||||||
|
func (s *UserService) UpdateUserRole(adminID, userID, roleID uint, ipAddress, userAgent string) error {
|
||||||
|
// Verify role exists
|
||||||
|
role, err := s.roleRepo.FindByID(roleID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update role
|
||||||
|
if err := s.userRepo.UpdateRole(userID, roleID); err != nil {
|
||||||
|
return errors.New("failed to update user role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityUser, &userID,
|
||||||
|
"Role updated to: "+role.Name, ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockUser blocks a user (admin only)
|
||||||
|
func (s *UserService) BlockUser(adminID, userID uint, ipAddress, userAgent string) error {
|
||||||
|
// Cannot block self
|
||||||
|
if adminID == userID {
|
||||||
|
return errors.New("cannot block yourself")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.BlockUser(userID); err != nil {
|
||||||
|
return errors.New("failed to block user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionBlock, models.EntityUser, &userID,
|
||||||
|
"User blocked", ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockUser unblocks a user (admin only)
|
||||||
|
func (s *UserService) UnblockUser(adminID, userID uint, ipAddress, userAgent string) error {
|
||||||
|
if err := s.userRepo.UnblockUser(userID); err != nil {
|
||||||
|
return errors.New("failed to unblock user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionUnblock, models.EntityUser, &userID,
|
||||||
|
"User unblocked", ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser deletes a user (admin only)
|
||||||
|
func (s *UserService) DeleteUser(adminID, userID uint, ipAddress, userAgent string) error {
|
||||||
|
// Cannot delete self
|
||||||
|
if adminID == userID {
|
||||||
|
return errors.New("cannot delete yourself")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userRepo.Delete(userID); err != nil {
|
||||||
|
return errors.New("failed to delete user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityUser, &userID,
|
||||||
|
"User deleted", ipAddress, userAgent)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetAllUsers(page, limit int) ([]models.User, int64, error) {
|
||||||
|
return s.userRepo.FindAll(page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID gets user by ID
|
||||||
|
func (s *UserService) GetUserByID(userID uint) (*models.User, error) {
|
||||||
|
return s.userRepo.FindByID(userID)
|
||||||
|
}
|
||||||
186
internal/services/verification_service.go
Normal file
186
internal/services/verification_service.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
// internal/services/verification_service.go
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/utils"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VerificationService struct {
|
||||||
|
verificationRepo *repositories.ClaimVerificationRepository
|
||||||
|
claimRepo *repositories.ClaimRepository
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
lostItemRepo *repositories.LostItemRepository // ✅ TAMBAHAN: Untuk Direct Claim
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerificationService(db *gorm.DB) *VerificationService {
|
||||||
|
return &VerificationService{
|
||||||
|
verificationRepo: repositories.NewClaimVerificationRepository(db),
|
||||||
|
claimRepo: repositories.NewClaimRepository(db),
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
lostItemRepo: repositories.NewLostItemRepository(db), // ✅ TAMBAHAN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerificationResult represents the verification result
|
||||||
|
type VerificationResult struct {
|
||||||
|
SimilarityScore float64 `json:"similarity_score"`
|
||||||
|
MatchLevel string `json:"match_level"`
|
||||||
|
MatchedKeywords []string `json:"matched_keywords"`
|
||||||
|
Details map[string]string `json:"details"`
|
||||||
|
Recommendation string `json:"recommendation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyClaimDescription verifies claim description against item/lost_item description
|
||||||
|
func (s *VerificationService) VerifyClaimDescription(claimID uint) (*VerificationResult, error) {
|
||||||
|
// 1. Get Claim
|
||||||
|
claim, err := s.claimRepo.FindByID(claimID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetDescription string
|
||||||
|
var sourceName string
|
||||||
|
|
||||||
|
// 2. Tentukan Target Deskripsi (Regular vs Direct Claim)
|
||||||
|
if claim.ItemID != nil {
|
||||||
|
// --- REGULAR CLAIM (Barang Temuan) ---
|
||||||
|
item, err := s.itemRepo.FindByID(*claim.ItemID) // ✅ Dereference pointer
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bandingkan dengan Secret Details (prioritas) atau Description
|
||||||
|
targetDescription = item.SecretDetails
|
||||||
|
if targetDescription == "" {
|
||||||
|
targetDescription = item.Description
|
||||||
|
}
|
||||||
|
sourceName = "item_secret_details"
|
||||||
|
|
||||||
|
} else if claim.LostItemID != nil {
|
||||||
|
// --- DIRECT CLAIM (Barang Hilang) ---
|
||||||
|
lostItem, err := s.lostItemRepo.FindByID(*claim.LostItemID) // ✅ Dereference pointer
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untuk direct claim, deskripsi Finder (Claim) dibandingkan dengan deskripsi Owner (LostItem)
|
||||||
|
targetDescription = lostItem.Description
|
||||||
|
sourceName = "lost_item_description"
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("invalid claim: no item or lost_item attached")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Calculate similarity
|
||||||
|
similarity := utils.CalculateStringSimilarity(claim.Description, targetDescription)
|
||||||
|
similarityPercent := similarity * 100
|
||||||
|
|
||||||
|
// 4. Extract matched keywords
|
||||||
|
claimKeywords := utils.ExtractKeywords(claim.Description)
|
||||||
|
itemKeywords := utils.ExtractKeywords(targetDescription)
|
||||||
|
matchedKeywords := utils.FindMatchedKeywords(claimKeywords, itemKeywords)
|
||||||
|
|
||||||
|
// 5. Determine match level
|
||||||
|
matchLevel := "low"
|
||||||
|
recommendation := "REJECT - Ciri khusus tidak cocok"
|
||||||
|
|
||||||
|
if similarityPercent >= 75.0 {
|
||||||
|
matchLevel = "high"
|
||||||
|
recommendation = "APPROVE - Ciri khusus sangat cocok"
|
||||||
|
} else if similarityPercent >= 50.0 {
|
||||||
|
matchLevel = "medium"
|
||||||
|
recommendation = "REVIEW - Perlu verifikasi fisik/tanya jawab"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create or update verification record
|
||||||
|
verification, _ := s.verificationRepo.FindByClaimID(claimID)
|
||||||
|
if verification == nil {
|
||||||
|
verification = &models.ClaimVerification{
|
||||||
|
ClaimID: claimID,
|
||||||
|
SimilarityScore: similarityPercent,
|
||||||
|
MatchedKeywords: stringSliceToJSON(matchedKeywords),
|
||||||
|
IsAutoMatched: false,
|
||||||
|
}
|
||||||
|
s.verificationRepo.Create(verification)
|
||||||
|
} else {
|
||||||
|
verification.SimilarityScore = similarityPercent
|
||||||
|
verification.MatchedKeywords = stringSliceToJSON(matchedKeywords)
|
||||||
|
s.verificationRepo.Update(verification)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &VerificationResult{
|
||||||
|
SimilarityScore: similarityPercent,
|
||||||
|
MatchLevel: matchLevel,
|
||||||
|
MatchedKeywords: matchedKeywords,
|
||||||
|
Details: map[string]string{
|
||||||
|
"claim_description": claim.Description,
|
||||||
|
sourceName: targetDescription, // Dinamis (item atau lost_item)
|
||||||
|
"matched_count": strconv.Itoa(len(matchedKeywords)),
|
||||||
|
},
|
||||||
|
Recommendation: recommendation,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVerificationByClaimID gets verification data for a claim
|
||||||
|
func (s *VerificationService) GetVerificationByClaimID(claimID uint) (*models.ClaimVerification, error) {
|
||||||
|
verification, err := s.verificationRepo.FindByClaimID(claimID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if verification == nil {
|
||||||
|
return nil, errors.New("verification not found")
|
||||||
|
}
|
||||||
|
return verification, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHighMatchVerifications gets all high match verifications
|
||||||
|
func (s *VerificationService) GetHighMatchVerifications() ([]models.ClaimVerificationResponse, error) {
|
||||||
|
verifications, err := s.verificationRepo.FindHighMatches()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []models.ClaimVerificationResponse
|
||||||
|
for _, v := range verifications {
|
||||||
|
responses = append(responses, v.ToResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareDescriptions provides detailed comparison between two descriptions
|
||||||
|
func (s *VerificationService) CompareDescriptions(desc1, desc2 string) map[string]interface{} {
|
||||||
|
similarity := utils.CalculateStringSimilarity(desc1, desc2)
|
||||||
|
|
||||||
|
keywords1 := utils.ExtractKeywords(desc1)
|
||||||
|
keywords2 := utils.ExtractKeywords(desc2)
|
||||||
|
matchedKeywords := utils.FindMatchedKeywords(keywords1, keywords2)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"similarity_score": similarity * 100,
|
||||||
|
"description_1": desc1,
|
||||||
|
"description_2": desc2,
|
||||||
|
"keywords_1": keywords1,
|
||||||
|
"keywords_2": keywords2,
|
||||||
|
"matched_keywords": matchedKeywords,
|
||||||
|
"total_keywords_1": len(keywords1),
|
||||||
|
"total_keywords_2": len(keywords2),
|
||||||
|
"matched_count": len(matchedKeywords),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to convert string slice to JSON
|
||||||
|
func stringSliceToJSON(slice []string) string {
|
||||||
|
if len(slice) == 0 {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(slice)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
183
internal/utils/encryption.go
Normal file
183
internal/utils/encryption.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// internal/utils/encryption.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ✅ KRITERIA BASDAT: Keamanan Data - Enkripsi untuk Data Sensitif (5%)
|
||||||
|
|
||||||
|
var encryptionKey []byte
|
||||||
|
|
||||||
|
// InitEncryption inisialisasi encryption key dari environment
|
||||||
|
func InitEncryption() error {
|
||||||
|
key := os.Getenv("ENCRYPTION_KEY")
|
||||||
|
if key == "" {
|
||||||
|
// Generate random key untuk development (TIDAK untuk production!)
|
||||||
|
key = "32-byte-long-encryption-key!!" // 32 bytes untuk AES-256
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key) != 32 {
|
||||||
|
return errors.New("encryption key must be exactly 32 bytes for AES-256")
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptionKey = []byte(key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptString mengenkripsi string menggunakan AES-256-GCM
|
||||||
|
// Digunakan untuk data sensitif: password, NRP, phone, contact info
|
||||||
|
func EncryptString(plaintext string) (string, error) {
|
||||||
|
if encryptionKey == nil {
|
||||||
|
if err := InitEncryption(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create AES cipher
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GCM mode (Galois/Counter Mode) - authenticated encryption
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate nonce (number used once)
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt and authenticate
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
|
||||||
|
// Encode to base64 untuk storage di database
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptString mendekripsi string yang sudah dienkripsi
|
||||||
|
func DecryptString(encrypted string) (string, error) {
|
||||||
|
if encryptionKey == nil {
|
||||||
|
if err := InitEncryption(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode from base64
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create AES cipher
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GCM mode
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract nonce
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
|
||||||
|
// Decrypt and verify
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptSensitiveFields helper untuk encrypt multiple fields
|
||||||
|
func EncryptSensitiveFields(fields map[string]string) (map[string]string, error) {
|
||||||
|
encrypted := make(map[string]string)
|
||||||
|
|
||||||
|
for key, value := range fields {
|
||||||
|
if value == "" {
|
||||||
|
encrypted[key] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
encValue, err := EncryptString(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
encrypted[key] = encValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptSensitiveFields helper untuk decrypt multiple fields
|
||||||
|
func DecryptSensitiveFields(fields map[string]string) (map[string]string, error) {
|
||||||
|
decrypted := make(map[string]string)
|
||||||
|
|
||||||
|
for key, value := range fields {
|
||||||
|
if value == "" {
|
||||||
|
decrypted[key] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
decValue, err := DecryptString(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decrypted[key] = decValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return decrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskSensitiveData untuk logging (mask sebagian data)
|
||||||
|
// Contoh: "081234567890" -> "0812****7890"
|
||||||
|
func MaskSensitiveData(data string) string {
|
||||||
|
if len(data) <= 8 {
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := data[:4]
|
||||||
|
suffix := data[len(data)-4:]
|
||||||
|
|
||||||
|
return prefix + "****" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEncryption test apakah encryption bekerja
|
||||||
|
func ValidateEncryption() error {
|
||||||
|
testData := "test-sensitive-data-12345"
|
||||||
|
|
||||||
|
encrypted, err := EncryptString(testData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := DecryptString(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if decrypted != testData {
|
||||||
|
return errors.New("encryption validation failed: data mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
67
internal/utils/error.go
Normal file
67
internal/utils/error.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// internal/utils/error.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// AppError represents a custom application error
|
||||||
|
type AppError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AppError) Error() string {
|
||||||
|
if e.Err != nil {
|
||||||
|
return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAppError creates a new application error
|
||||||
|
func NewAppError(code, message string, err error) *AppError {
|
||||||
|
return &AppError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common error codes
|
||||||
|
const (
|
||||||
|
ErrCodeValidation = "VALIDATION_ERROR"
|
||||||
|
ErrCodeNotFound = "NOT_FOUND"
|
||||||
|
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||||
|
ErrCodeForbidden = "FORBIDDEN"
|
||||||
|
ErrCodeInternal = "INTERNAL_ERROR"
|
||||||
|
ErrCodeDuplicate = "DUPLICATE_ERROR"
|
||||||
|
ErrCodeBadRequest = "BAD_REQUEST"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error constructors
|
||||||
|
func ValidationError(message string) *AppError {
|
||||||
|
return NewAppError(ErrCodeValidation, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotFoundError(message string) *AppError {
|
||||||
|
return NewAppError(ErrCodeNotFound, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnauthorizedError(message string) *AppError {
|
||||||
|
return NewAppError(ErrCodeUnauthorized, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForbiddenError(message string) *AppError {
|
||||||
|
return NewAppError(ErrCodeForbidden, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InternalError(message string, err error) *AppError {
|
||||||
|
return NewAppError(ErrCodeInternal, message, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DuplicateError(message string) *AppError {
|
||||||
|
return NewAppError(ErrCodeDuplicate, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BadRequestError(message string) *AppError {
|
||||||
|
return NewAppError(ErrCodeBadRequest, message, nil)
|
||||||
|
}
|
||||||
100
internal/utils/excel_export.go
Normal file
100
internal/utils/excel_export.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// internal/utils/excel_export.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExcelExporter handles Excel file generation
|
||||||
|
type ExcelExporter struct {
|
||||||
|
file *excelize.File
|
||||||
|
sheetName string
|
||||||
|
rowIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExcelExporter creates a new Excel exporter
|
||||||
|
func NewExcelExporter() *ExcelExporter {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
return &ExcelExporter{
|
||||||
|
file: f,
|
||||||
|
sheetName: "Sheet1",
|
||||||
|
rowIndex: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSheetName sets the active sheet name
|
||||||
|
func (e *ExcelExporter) SetSheetName(name string) {
|
||||||
|
e.file.SetSheetName("Sheet1", name)
|
||||||
|
e.sheetName = name
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRow adds a row of data
|
||||||
|
func (e *ExcelExporter) AddRow(data []string) error {
|
||||||
|
for colIndex, value := range data {
|
||||||
|
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
|
||||||
|
if err := e.file.SetCellValue(e.sheetName, cell, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.rowIndex++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddHeader adds a header row with bold style
|
||||||
|
func (e *ExcelExporter) AddHeader(headers []string) error {
|
||||||
|
style, err := e.file.NewStyle(&excelize.Style{
|
||||||
|
Font: &excelize.Font{
|
||||||
|
Bold: true,
|
||||||
|
},
|
||||||
|
Fill: excelize.Fill{
|
||||||
|
Type: "pattern",
|
||||||
|
Color: []string{"#D3D3D3"},
|
||||||
|
Pattern: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for colIndex, header := range headers {
|
||||||
|
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
|
||||||
|
if err := e.file.SetCellValue(e.sheetName, cell, header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := e.file.SetCellStyle(e.sheetName, cell, cell, style); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.rowIndex++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoSizeColumns auto-sizes columns
|
||||||
|
func (e *ExcelExporter) AutoSizeColumns(columnCount int) {
|
||||||
|
for i := 0; i < columnCount; i++ {
|
||||||
|
col := getColumnName(i)
|
||||||
|
e.file.SetColWidth(e.sheetName, col, col, 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output returns the Excel file as bytes
|
||||||
|
func (e *ExcelExporter) Output() (*bytes.Buffer, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := e.file.Write(buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getColumnName converts column index to Excel column name (A, B, C, ..., AA, AB, ...)
|
||||||
|
func getColumnName(index int) string {
|
||||||
|
name := ""
|
||||||
|
for index >= 0 {
|
||||||
|
name = string(rune('A'+(index%26))) + name
|
||||||
|
index = index/26 - 1
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
18
internal/utils/hash.go
Normal file
18
internal/utils/hash.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// internal/utils/hash.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashPassword hashes a password using bcrypt
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPasswordHash compares a password with a hash
|
||||||
|
func CheckPasswordHash(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
210
internal/utils/image_handler.go
Normal file
210
internal/utils/image_handler.go
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
// internal/utils/image_handler.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/nfnt/resize"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageHandler handles image upload and processing
|
||||||
|
type ImageHandler struct {
|
||||||
|
uploadPath string
|
||||||
|
maxSize int64
|
||||||
|
allowedTypes []string
|
||||||
|
maxWidth uint
|
||||||
|
maxHeight uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewImageHandler creates a new image handler
|
||||||
|
func NewImageHandler(uploadPath string) *ImageHandler {
|
||||||
|
return &ImageHandler{
|
||||||
|
uploadPath: uploadPath,
|
||||||
|
maxSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
allowedTypes: []string{"image/jpeg", "image/jpg", "image/png"},
|
||||||
|
maxWidth: 1920,
|
||||||
|
maxHeight: 1080,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadImage uploads and processes an image
|
||||||
|
// UploadImage uploads and processes an image
|
||||||
|
func (h *ImageHandler) UploadImage(file *multipart.FileHeader, subfolder string) (string, error) {
|
||||||
|
// Check file size
|
||||||
|
if file.Size > h.maxSize {
|
||||||
|
return "", errors.New("file size exceeds maximum allowed size")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
if !h.isAllowedType(file.Header.Get("Content-Type")) {
|
||||||
|
return "", errors.New("file type not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open uploaded file
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
// ✅ GENERATE FILENAME DENGAN TIMESTAMP
|
||||||
|
ext := filepath.Ext(file.Filename)
|
||||||
|
timestamp := time.Now().Format("20060102_150405") // Format: YYYYMMDD_HHMMSS
|
||||||
|
baseFilename := strings.TrimSuffix(file.Filename, ext)
|
||||||
|
|
||||||
|
// Remove special characters from filename
|
||||||
|
baseFilename = sanitizeFilename(baseFilename)
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s_%s%s", baseFilename, timestamp, ext)
|
||||||
|
|
||||||
|
// Create upload directory if not exists
|
||||||
|
uploadDir := filepath.Join(h.uploadPath, subfolder)
|
||||||
|
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full file path
|
||||||
|
filePath := filepath.Join(uploadDir, filename)
|
||||||
|
|
||||||
|
// Decode image
|
||||||
|
img, format, err := image.Decode(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("invalid image file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize if necessary
|
||||||
|
if uint(img.Bounds().Dx()) > h.maxWidth || uint(img.Bounds().Dy()) > h.maxHeight {
|
||||||
|
img = resize.Thumbnail(h.maxWidth, h.maxHeight, img, resize.Lanczos3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
// Encode and save image
|
||||||
|
switch format {
|
||||||
|
case "jpeg", "jpg":
|
||||||
|
if err := jpeg.Encode(dst, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case "png":
|
||||||
|
if err := png.Encode(dst, img); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported image format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
return filepath.Join(subfolder, filename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ TAMBAHKAN HELPER FUNCTION INI
|
||||||
|
func sanitizeFilename(filename string) string {
|
||||||
|
// Remove special characters, keep only alphanumeric and hyphens
|
||||||
|
reg := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
|
||||||
|
sanitized := reg.ReplaceAllString(filename, "")
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if len(sanitized) > 50 {
|
||||||
|
sanitized = sanitized[:50]
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadImageSimple uploads image without processing
|
||||||
|
func (h *ImageHandler) UploadImageSimple(file *multipart.FileHeader, subfolder string) (string, error) {
|
||||||
|
// Check file size
|
||||||
|
if file.Size > h.maxSize {
|
||||||
|
return "", errors.New("file size exceeds maximum allowed size")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
if !h.isAllowedType(file.Header.Get("Content-Type")) {
|
||||||
|
return "", errors.New("file type not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open uploaded file
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
ext := filepath.Ext(file.Filename)
|
||||||
|
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext)
|
||||||
|
|
||||||
|
// Create upload directory if not exists
|
||||||
|
uploadDir := filepath.Join(h.uploadPath, subfolder)
|
||||||
|
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full file path
|
||||||
|
filePath := filepath.Join(uploadDir, filename)
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
// Copy file
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
return filepath.Join(subfolder, filename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteImage deletes an image file
|
||||||
|
func (h *ImageHandler) DeleteImage(relativePath string) error {
|
||||||
|
if relativePath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(h.uploadPath, relativePath)
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return nil // File doesn't exist, no error
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedType checks if file type is allowed
|
||||||
|
func (h *ImageHandler) isAllowedType(contentType string) bool {
|
||||||
|
for _, allowed := range h.allowedTypes {
|
||||||
|
if strings.EqualFold(contentType, allowed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomString generates a random string
|
||||||
|
func generateRandomString(length int) string {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
b := make([]byte, length)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
76
internal/utils/matching.go
Normal file
76
internal/utils/matching.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// internal/utils/matching.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// CalculateMatchScore calculates match score between two items
|
||||||
|
func CalculateMatchScore(item1, item2 map[string]interface{}) float64 {
|
||||||
|
nameScore := 0.0
|
||||||
|
descScore := 0.0
|
||||||
|
|
||||||
|
// 1. Name Matching (Bobot 50%)
|
||||||
|
if name1, ok1 := item1["name"].(string); ok1 {
|
||||||
|
if name2, ok2 := item2["name"].(string); ok2 {
|
||||||
|
nameScore = CalculateStringSimilarity(name1, name2) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Description/Secret Matching (Bobot 50%)
|
||||||
|
// Cek apakah ada secret_details, jika tidak pakai description
|
||||||
|
text1 := ""
|
||||||
|
if val, ok := item1["secret_details"].(string); ok && val != "" {
|
||||||
|
text1 = val
|
||||||
|
} else if val, ok := item1["description"].(string); ok {
|
||||||
|
text1 = val
|
||||||
|
}
|
||||||
|
|
||||||
|
text2 := ""
|
||||||
|
if val, ok := item2["secret_details"].(string); ok && val != "" {
|
||||||
|
text2 = val
|
||||||
|
} else if val, ok := item2["description"].(string); ok {
|
||||||
|
text2 = val
|
||||||
|
}
|
||||||
|
|
||||||
|
if text1 != "" && text2 != "" {
|
||||||
|
descScore = CalculateStringSimilarity(text1, text2) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total Score = Rata-rata dari keduanya
|
||||||
|
totalScore := (nameScore * 0.5) + (descScore * 0.5)
|
||||||
|
|
||||||
|
return totalScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchItems matches items based on criteria
|
||||||
|
func MatchItems(lostItem, foundItems []map[string]interface{}, threshold float64) []map[string]interface{} {
|
||||||
|
var matches []map[string]interface{}
|
||||||
|
|
||||||
|
if len(lostItem) == 0 || len(foundItems) == 0 {
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
lost := lostItem[0]
|
||||||
|
|
||||||
|
for _, found := range foundItems {
|
||||||
|
score := CalculateMatchScore(lost, found)
|
||||||
|
if score >= threshold {
|
||||||
|
match := make(map[string]interface{})
|
||||||
|
match["item"] = found
|
||||||
|
match["score"] = score
|
||||||
|
match["level"] = getMatchLevel(score)
|
||||||
|
matches = append(matches, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMatchLevel returns match level based on score
|
||||||
|
func getMatchLevel(score float64) string {
|
||||||
|
if score >= 70 {
|
||||||
|
return "high"
|
||||||
|
} else if score >= 50 {
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
108
internal/utils/pdf_export.go
Normal file
108
internal/utils/pdf_export.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// internal/utils/pdf_export.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PDFExporter handles PDF generation
|
||||||
|
type PDFExporter struct {
|
||||||
|
pdf *gofpdf.Fpdf
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPDFExporter creates a new PDF exporter
|
||||||
|
func NewPDFExporter() *PDFExporter {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
// ✅ Coba gunakan font built-in yang pasti ada
|
||||||
|
pdf.SetFont("Helvetica", "", 12) // Ganti dari "Arial" ke "Helvetica"
|
||||||
|
|
||||||
|
return &PDFExporter{
|
||||||
|
pdf: pdf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTitle adds a title to the PDF
|
||||||
|
func (e *PDFExporter) AddTitle(title string) {
|
||||||
|
e.pdf.SetFont("Arial", "B", 16)
|
||||||
|
e.pdf.Cell(0, 10, title)
|
||||||
|
e.pdf.Ln(12)
|
||||||
|
e.pdf.SetFont("Arial", "", 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSubtitle adds a subtitle to the PDF
|
||||||
|
func (e *PDFExporter) AddSubtitle(subtitle string) {
|
||||||
|
e.pdf.SetFont("Arial", "I", 11)
|
||||||
|
e.pdf.Cell(0, 8, subtitle)
|
||||||
|
e.pdf.Ln(10)
|
||||||
|
e.pdf.SetFont("Arial", "", 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddText adds regular text
|
||||||
|
func (e *PDFExporter) AddText(text string) {
|
||||||
|
e.pdf.SetFont("Arial", "", 10)
|
||||||
|
e.pdf.MultiCell(0, 6, text, "", "", false)
|
||||||
|
e.pdf.Ln(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNewLine adds a new line
|
||||||
|
func (e *PDFExporter) AddNewLine() {
|
||||||
|
e.pdf.Ln(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTable adds a table to the PDF
|
||||||
|
func (e *PDFExporter) AddTable(headers []string, data [][]string) {
|
||||||
|
// Calculate column widths
|
||||||
|
pageWidth, _ := e.pdf.GetPageSize()
|
||||||
|
margins := 20.0 // Left + Right margins
|
||||||
|
tableWidth := pageWidth - margins
|
||||||
|
colWidth := tableWidth / float64(len(headers))
|
||||||
|
|
||||||
|
// Add headers
|
||||||
|
e.pdf.SetFont("Arial", "B", 10)
|
||||||
|
e.pdf.SetFillColor(200, 200, 200)
|
||||||
|
for _, header := range headers {
|
||||||
|
e.pdf.CellFormat(colWidth, 8, header, "1", 0, "C", true, 0, "")
|
||||||
|
}
|
||||||
|
e.pdf.Ln(-1)
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
e.pdf.SetFont("Arial", "", 9)
|
||||||
|
e.pdf.SetFillColor(255, 255, 255)
|
||||||
|
|
||||||
|
fill := false
|
||||||
|
for _, row := range data {
|
||||||
|
for _, cell := range row {
|
||||||
|
if fill {
|
||||||
|
e.pdf.SetFillColor(245, 245, 245)
|
||||||
|
} else {
|
||||||
|
e.pdf.SetFillColor(255, 255, 255)
|
||||||
|
}
|
||||||
|
e.pdf.CellFormat(colWidth, 7, cell, "1", 0, "L", true, 0, "")
|
||||||
|
}
|
||||||
|
e.pdf.Ln(-1)
|
||||||
|
fill = !fill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPageNumber adds page numbers
|
||||||
|
func (e *PDFExporter) AddPageNumber() {
|
||||||
|
e.pdf.AliasNbPages("")
|
||||||
|
e.pdf.SetY(-15)
|
||||||
|
e.pdf.SetFont("Arial", "I", 8)
|
||||||
|
e.pdf.CellFormat(0, 10, fmt.Sprintf("Halaman %d/{nb}", e.pdf.PageNo()), "", 0, "C", false, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output returns the PDF as bytes
|
||||||
|
func (e *PDFExporter) Output() *bytes.Buffer {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := e.pdf.Output(&buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &buf
|
||||||
|
}
|
||||||
68
internal/utils/response.go
Normal file
68
internal/utils/response.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// internal/utils/response.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response represents a standard API response
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginatedResponse represents a paginated API response
|
||||||
|
type PaginatedResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Pagination Pagination `json:"pagination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination represents pagination metadata
|
||||||
|
type Pagination struct {
|
||||||
|
CurrentPage int `json:"current_page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
TotalRecords int64 `json:"total_records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuccessResponse sends a success response
|
||||||
|
func SuccessResponse(ctx *gin.Context, statusCode int, message string, data interface{}) {
|
||||||
|
ctx.JSON(statusCode, Response{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse sends an error response
|
||||||
|
func ErrorResponse(ctx *gin.Context, statusCode int, message string, error string) {
|
||||||
|
ctx.JSON(statusCode, Response{
|
||||||
|
Success: false,
|
||||||
|
Message: message,
|
||||||
|
Error: error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPaginatedResponse sends a paginated response (nama fungsi diubah untuk menghindari konflik)
|
||||||
|
func SendPaginatedResponse(ctx *gin.Context, statusCode int, message string, data interface{}, total int64, page, limit int) {
|
||||||
|
totalPages := int(total) / limit
|
||||||
|
if int(total)%limit != 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(statusCode, PaginatedResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
Pagination: Pagination{
|
||||||
|
CurrentPage: page,
|
||||||
|
PerPage: limit,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
TotalRecords: total,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
160
internal/utils/similarity.go
Normal file
160
internal/utils/similarity.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
// internal/utils/similarity.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculateStringSimilarity calculates similarity between two strings using Levenshtein distance
|
||||||
|
func CalculateStringSimilarity(s1, s2 string) float64 {
|
||||||
|
// Normalize strings
|
||||||
|
s1 = normalizeString(s1)
|
||||||
|
s2 = normalizeString(s2)
|
||||||
|
|
||||||
|
if s1 == s2 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s1) == 0 || len(s2) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Levenshtein distance
|
||||||
|
distance := levenshteinDistance(s1, s2)
|
||||||
|
maxLen := math.Max(float64(len(s1)), float64(len(s2)))
|
||||||
|
|
||||||
|
similarity := 1.0 - (float64(distance) / maxLen)
|
||||||
|
return math.Max(0, similarity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||||
|
func levenshteinDistance(s1, s2 string) int {
|
||||||
|
len1 := len(s1)
|
||||||
|
len2 := len(s2)
|
||||||
|
|
||||||
|
// Create a 2D slice for dynamic programming
|
||||||
|
dp := make([][]int, len1+1)
|
||||||
|
for i := range dp {
|
||||||
|
dp[i] = make([]int, len2+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize first row and column
|
||||||
|
for i := 0; i <= len1; i++ {
|
||||||
|
dp[i][0] = i
|
||||||
|
}
|
||||||
|
for j := 0; j <= len2; j++ {
|
||||||
|
dp[0][j] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the dp table
|
||||||
|
for i := 1; i <= len1; i++ {
|
||||||
|
for j := 1; j <= len2; j++ {
|
||||||
|
cost := 0
|
||||||
|
if s1[i-1] != s2[j-1] {
|
||||||
|
cost = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dp[i][j] = min3(
|
||||||
|
dp[i-1][j]+1, // deletion
|
||||||
|
dp[i][j-1]+1, // insertion
|
||||||
|
dp[i-1][j-1]+cost, // substitution
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dp[len1][len2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractKeywords extracts keywords from a string
|
||||||
|
func ExtractKeywords(text string) []string {
|
||||||
|
// Normalize and split text
|
||||||
|
text = normalizeString(text)
|
||||||
|
words := strings.Fields(text)
|
||||||
|
|
||||||
|
// Filter stopwords and short words
|
||||||
|
var keywords []string
|
||||||
|
stopwords := getStopwords()
|
||||||
|
|
||||||
|
for _, word := range words {
|
||||||
|
if len(word) > 2 && !contains(stopwords, word) {
|
||||||
|
keywords = append(keywords, word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywords
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindMatchedKeywords finds common keywords between two lists
|
||||||
|
func FindMatchedKeywords(keywords1, keywords2 []string) []string {
|
||||||
|
var matched []string
|
||||||
|
|
||||||
|
for _, k1 := range keywords1 {
|
||||||
|
for _, k2 := range keywords2 {
|
||||||
|
if strings.EqualFold(k1, k2) || CalculateStringSimilarity(k1, k2) > 0.8 {
|
||||||
|
if !contains(matched, k1) {
|
||||||
|
matched = append(matched, k1)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeString normalizes a string (lowercase, remove extra spaces)
|
||||||
|
func normalizeString(s string) string {
|
||||||
|
// Convert to lowercase
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
|
// Remove punctuation and extra spaces
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
s = strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStopwords returns common Indonesian stopwords
|
||||||
|
func getStopwords() []string {
|
||||||
|
return []string{
|
||||||
|
"dan", "atau", "dengan", "untuk", "dari", "ke", "di", "yang", "ini", "itu",
|
||||||
|
"ada", "adalah", "akan", "telah", "sudah", "pada", "oleh", "sebagai", "dalam",
|
||||||
|
"juga", "saya", "kamu", "dia", "kita", "mereka", "kami", "the", "a", "an",
|
||||||
|
"of", "to", "in", "for", "on", "at", "by", "with", "from",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains checks if a slice contains a string
|
||||||
|
func contains(slice []string, str string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if strings.EqualFold(s, str) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// min3 returns the minimum of three integers
|
||||||
|
func min3(a, b, c int) int {
|
||||||
|
if a < b {
|
||||||
|
if a < c {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if b < c {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
85
internal/utils/validator.go
Normal file
85
internal/utils/validator.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// internal/utils/validator.go
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validate *validator.Validate
|
||||||
|
|
||||||
|
// InitValidator initializes the validator
|
||||||
|
func InitValidator() {
|
||||||
|
validate = validator.New()
|
||||||
|
|
||||||
|
// Register custom validators
|
||||||
|
validate.RegisterValidation("phone", validatePhone)
|
||||||
|
validate.RegisterValidation("nrp", validateNRP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStruct validates a struct
|
||||||
|
func ValidateStruct(s interface{}) error {
|
||||||
|
if validate == nil {
|
||||||
|
InitValidator()
|
||||||
|
}
|
||||||
|
return validate.Struct(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePhone validates Indonesian phone numbers
|
||||||
|
func validatePhone(fl validator.FieldLevel) bool {
|
||||||
|
phone := fl.Field().String()
|
||||||
|
if phone == "" {
|
||||||
|
return true // Allow empty (use required tag separately)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove spaces and dashes
|
||||||
|
phone = strings.ReplaceAll(phone, " ", "")
|
||||||
|
phone = strings.ReplaceAll(phone, "-", "")
|
||||||
|
|
||||||
|
// Check if starts with 0 or +62 or 62
|
||||||
|
pattern := `^(0|\+?62)[0-9]{8,12}$`
|
||||||
|
matched, _ := regexp.MatchString(pattern, phone)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateNRP validates NRP (Nomor Registrasi Pokok)
|
||||||
|
func validateNRP(fl validator.FieldLevel) bool {
|
||||||
|
nrp := fl.Field().String()
|
||||||
|
if nrp == "" {
|
||||||
|
return true // Allow empty (use required tag separately)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NRP format: 10 digits
|
||||||
|
pattern := `^[0-9]{10}$`
|
||||||
|
matched, _ := regexp.MatchString(pattern, nrp)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidEmail checks if email is valid
|
||||||
|
func IsValidEmail(email string) bool {
|
||||||
|
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||||
|
matched, _ := regexp.MatchString(pattern, email)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidURL checks if URL is valid
|
||||||
|
func IsValidURL(url string) bool {
|
||||||
|
pattern := `^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$`
|
||||||
|
matched, _ := regexp.MatchString(pattern, url)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeString removes potentially dangerous characters
|
||||||
|
func SanitizeString(s string) string {
|
||||||
|
// Remove control characters
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
78
internal/workers/audit_worker.go
Normal file
78
internal/workers/audit_worker.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// internal/workers/audit_worker.go - FIXED
|
||||||
|
package workers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditWorker handles audit log background tasks
|
||||||
|
type AuditWorker struct {
|
||||||
|
db *gorm.DB
|
||||||
|
auditLogRepo *repositories.AuditLogRepository
|
||||||
|
stopChan chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuditWorker creates a new audit worker
|
||||||
|
func NewAuditWorker(db *gorm.DB) *AuditWorker {
|
||||||
|
return &AuditWorker{
|
||||||
|
db: db,
|
||||||
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
||||||
|
stopChan: make(chan bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the audit worker
|
||||||
|
// ✅ FIXED: Now runs in goroutine (non-blocking)
|
||||||
|
func (w *AuditWorker) Start() {
|
||||||
|
log.Println("🔍 Audit Worker started")
|
||||||
|
|
||||||
|
// ✅ Run in goroutine to avoid blocking
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(24 * time.Hour) // Run daily
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
w.cleanupOldLogs()
|
||||||
|
case <-w.stopChan:
|
||||||
|
log.Println("🔍 Audit Worker stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// ✅ Return immediately after starting goroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the audit worker
|
||||||
|
func (w *AuditWorker) Stop() {
|
||||||
|
close(w.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldLogs removes audit logs older than 1 year
|
||||||
|
func (w *AuditWorker) cleanupOldLogs() {
|
||||||
|
log.Println("🧹 Cleaning up old audit logs...")
|
||||||
|
|
||||||
|
cutoffDate := time.Now().AddDate(-1, 0, 0) // 1 year ago
|
||||||
|
|
||||||
|
result := w.db.Unscoped().Where("created_at < ?", cutoffDate).Delete(&struct {
|
||||||
|
tableName struct{} `gorm:"audit_logs"`
|
||||||
|
}{})
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("❌ Failed to cleanup audit logs: %v", result.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Cleaned up %d old audit log entries", result.RowsAffected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNow runs cleanup immediately (for testing)
|
||||||
|
func (w *AuditWorker) RunNow() {
|
||||||
|
log.Println("▶️ Running audit cleanup manually...")
|
||||||
|
w.cleanupOldLogs()
|
||||||
|
}
|
||||||
223
internal/workers/expire_worker.go
Normal file
223
internal/workers/expire_worker.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
// internal/workers/expire_worker.go - IMPROVED VERSION
|
||||||
|
package workers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExpireWorker struct {
|
||||||
|
db *gorm.DB
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
archiveRepo *repositories.ArchiveRepository
|
||||||
|
stopChan chan bool
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// ✅ Worker pool configuration
|
||||||
|
maxWorkers int
|
||||||
|
taskQueue chan *models.Item
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExpireWorker(db *gorm.DB) *ExpireWorker {
|
||||||
|
return &ExpireWorker{
|
||||||
|
db: db,
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
archiveRepo: repositories.NewArchiveRepository(db),
|
||||||
|
stopChan: make(chan bool),
|
||||||
|
maxWorkers: 5, // ✅ Configurable worker count
|
||||||
|
taskQueue: make(chan *models.Item, 100), // ✅ Buffered channel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Start with proper worker pool
|
||||||
|
func (w *ExpireWorker) Start() {
|
||||||
|
w.wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer w.wg.Done()
|
||||||
|
|
||||||
|
log.Println("⏰ Expire Worker started with", w.maxWorkers, "workers")
|
||||||
|
|
||||||
|
// ✅ Start worker pool
|
||||||
|
w.startWorkerPool()
|
||||||
|
|
||||||
|
// Run immediately on start
|
||||||
|
w.expireItems()
|
||||||
|
|
||||||
|
// Then run every hour
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
w.expireItems()
|
||||||
|
case <-w.stopChan:
|
||||||
|
log.Println("⏰ Stopping Expire Worker...")
|
||||||
|
close(w.taskQueue) // ✅ Close task queue to signal workers
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Worker Pool Implementation
|
||||||
|
func (w *ExpireWorker) startWorkerPool() {
|
||||||
|
for i := 0; i < w.maxWorkers; i++ {
|
||||||
|
w.wg.Add(1)
|
||||||
|
go w.worker(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Individual Worker
|
||||||
|
func (w *ExpireWorker) worker(id int) {
|
||||||
|
defer w.wg.Done()
|
||||||
|
|
||||||
|
log.Printf("🔧 Worker %d started", id)
|
||||||
|
|
||||||
|
for item := range w.taskQueue {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
|
||||||
|
success := w.archiveExpiredItem(ctx, item)
|
||||||
|
|
||||||
|
if success {
|
||||||
|
log.Printf("✅ Worker %d: Archived item ID %d (%s)", id, item.ID, item.Name)
|
||||||
|
} else {
|
||||||
|
log.Printf("❌ Worker %d: Failed to archive item ID %d", id, item.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🔧 Worker %d stopped", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Improved expireItems
|
||||||
|
func (w *ExpireWorker) expireItems() {
|
||||||
|
log.Println("🔍 Checking for expired items (Using Stored Procedure)...")
|
||||||
|
|
||||||
|
// Panggil method repository yang mengeksekusi SP
|
||||||
|
// Pastikan Anda sudah menambahkan method CallArchiveExpiredProcedure di ItemRepository
|
||||||
|
// seperti pada langkah 1 jawaban sebelumnya.
|
||||||
|
count, err := w.itemRepo.CallArchiveExpiredProcedure()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Error executing archive SP: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
log.Printf("✅ Successfully archived %d items via DB Procedure", count)
|
||||||
|
} else {
|
||||||
|
log.Println("✅ No expired items found to archive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ExpireWorker) findExpiredItems(ctx context.Context) ([]models.Item, error) {
|
||||||
|
var items []models.Item
|
||||||
|
err := w.db.WithContext(ctx).
|
||||||
|
Where("expires_at <= ? AND status = ? AND deleted_at IS NULL",
|
||||||
|
time.Now(), models.ItemStatusUnclaimed).
|
||||||
|
Preload("Category").
|
||||||
|
Find(&items).Error
|
||||||
|
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Archive with proper transaction & locking
|
||||||
|
func (w *ExpireWorker) archiveExpiredItem(ctx context.Context, item *models.Item) bool {
|
||||||
|
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// ✅ Lock the item
|
||||||
|
var lockedItem models.Item
|
||||||
|
// Locking/Isolation: Menerapkan Pessimistic Lock (FOR UPDATE) pada baris item yang akan diproses, mencegah worker lain atau API call lain memodifikasi item ini selama transaksi berlangsung, menjaga Konsistensi dan Pemulihan.
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ? AND deleted_at IS NULL", item.ID).
|
||||||
|
First(&lockedItem).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double check it's still unclaimed
|
||||||
|
if lockedItem.Status != models.ItemStatusUnclaimed {
|
||||||
|
return nil // Already processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create archive record
|
||||||
|
archive := &models.Archive{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
CategoryID: item.CategoryID,
|
||||||
|
PhotoURL: item.PhotoURL,
|
||||||
|
Location: item.Location,
|
||||||
|
Description: item.Description,
|
||||||
|
DateFound: item.DateFound,
|
||||||
|
Status: models.ItemStatusExpired,
|
||||||
|
ReporterName: item.ReporterName,
|
||||||
|
ReporterContact: item.ReporterContact,
|
||||||
|
ArchivedReason: models.ArchiveReasonExpired,
|
||||||
|
ClaimedBy: nil,
|
||||||
|
ArchivedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(archive).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update item status to expired
|
||||||
|
if err := tx.Model(&lockedItem).Update("status", models.ItemStatusExpired).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create audit log
|
||||||
|
auditLog := &models.AuditLog{
|
||||||
|
UserID: nil,
|
||||||
|
Action: "expire",
|
||||||
|
EntityType: models.EntityItem,
|
||||||
|
EntityID: &item.ID,
|
||||||
|
Details: "Item automatically expired and archived by system",
|
||||||
|
IPAddress: "system",
|
||||||
|
UserAgent: "expire_worker",
|
||||||
|
}
|
||||||
|
if err := tx.Create(auditLog).Error; err != nil {
|
||||||
|
log.Printf("Warning: failed to create audit log for item %d: %v", item.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Graceful Stop with WaitGroup
|
||||||
|
func (w *ExpireWorker) Stop() {
|
||||||
|
log.Println("🛑 Signaling Expire Worker to stop...")
|
||||||
|
w.stopChan <- true
|
||||||
|
|
||||||
|
log.Println("⏳ Waiting for all workers to finish...")
|
||||||
|
w.wg.Wait()
|
||||||
|
|
||||||
|
log.Println("✅ Expire Worker gracefully stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNow for manual trigger
|
||||||
|
func (w *ExpireWorker) RunNow() {
|
||||||
|
log.Println("▶️ Running expiration check manually...")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
w.expireItems()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
log.Println("✅ Manual expiration check completed")
|
||||||
|
}
|
||||||
95
internal/workers/matching_worker.go
Normal file
95
internal/workers/matching_worker.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// internal/workers/matching_worker.go - FIXED
|
||||||
|
package workers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"lost-and-found/internal/services"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchingWorker handles automatic matching of lost and found items
|
||||||
|
type MatchingWorker struct {
|
||||||
|
db *gorm.DB
|
||||||
|
matchService *services.MatchService
|
||||||
|
itemRepo *repositories.ItemRepository
|
||||||
|
lostItemRepo *repositories.LostItemRepository
|
||||||
|
stopChan chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMatchingWorker creates a new matching worker
|
||||||
|
func NewMatchingWorker(db *gorm.DB) *MatchingWorker {
|
||||||
|
return &MatchingWorker{
|
||||||
|
db: db,
|
||||||
|
matchService: services.NewMatchService(db),
|
||||||
|
itemRepo: repositories.NewItemRepository(db),
|
||||||
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
||||||
|
stopChan: make(chan bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the matching worker
|
||||||
|
// ✅ FIXED: Now runs in goroutine (non-blocking)
|
||||||
|
func (w *MatchingWorker) Start() {
|
||||||
|
log.Println("🔗 Matching Worker started")
|
||||||
|
|
||||||
|
// ✅ Run in goroutine to avoid blocking
|
||||||
|
go func() {
|
||||||
|
// Run every 30 minutes
|
||||||
|
ticker := time.NewTicker(30 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
w.performMatching()
|
||||||
|
case <-w.stopChan:
|
||||||
|
log.Println("🔗 Matching Worker stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// ✅ Return immediately after starting goroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the matching worker
|
||||||
|
func (w *MatchingWorker) Stop() {
|
||||||
|
close(w.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// performMatching performs automatic matching between lost and found items
|
||||||
|
func (w *MatchingWorker) performMatching() {
|
||||||
|
log.Println("🔍 Performing automatic matching...")
|
||||||
|
|
||||||
|
// Get all unclaimed items
|
||||||
|
items, _, err := w.itemRepo.FindAll(1, 1000, "unclaimed", "", "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Error fetching items: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
log.Println("✅ No unclaimed items to match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matchCount := 0
|
||||||
|
for _, item := range items {
|
||||||
|
// Auto-match with lost items
|
||||||
|
if err := w.matchService.AutoMatchNewItem(item.ID); err != nil {
|
||||||
|
log.Printf("❌ Failed to match item ID %d: %v", item.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Completed matching for %d items", matchCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNow runs matching immediately (for testing)
|
||||||
|
func (w *MatchingWorker) RunNow() {
|
||||||
|
log.Println("▶️ Running matching manually...")
|
||||||
|
w.performMatching()
|
||||||
|
}
|
||||||
122
internal/workers/notification_worker.go
Normal file
122
internal/workers/notification_worker.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
// internal/workers/notification_worker.go - FIXED
|
||||||
|
package workers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"lost-and-found/internal/models"
|
||||||
|
"lost-and-found/internal/repositories"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationWorker handles sending notifications asynchronously
|
||||||
|
type NotificationWorker struct {
|
||||||
|
db *gorm.DB
|
||||||
|
notificationRepo *repositories.NotificationRepository
|
||||||
|
matchRepo *repositories.MatchResultRepository
|
||||||
|
stopChan chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotificationWorker creates a new notification worker
|
||||||
|
func NewNotificationWorker(db *gorm.DB) *NotificationWorker {
|
||||||
|
return &NotificationWorker{
|
||||||
|
db: db,
|
||||||
|
notificationRepo: repositories.NewNotificationRepository(db),
|
||||||
|
matchRepo: repositories.NewMatchResultRepository(db),
|
||||||
|
stopChan: make(chan bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the notification worker
|
||||||
|
// ✅ FIXED: Now runs in goroutine (non-blocking)
|
||||||
|
func (w *NotificationWorker) Start() {
|
||||||
|
log.Println("📬 Notification Worker started")
|
||||||
|
|
||||||
|
// ✅ Run in goroutine to avoid blocking
|
||||||
|
go func() {
|
||||||
|
// Run every 5 minutes
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
w.sendPendingNotifications()
|
||||||
|
case <-w.stopChan:
|
||||||
|
log.Println("📬 Notification Worker stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// ✅ Return immediately after starting goroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the notification worker
|
||||||
|
func (w *NotificationWorker) Stop() {
|
||||||
|
close(w.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPendingNotifications sends notifications for unnotified matches
|
||||||
|
func (w *NotificationWorker) sendPendingNotifications() {
|
||||||
|
log.Println("📧 Checking for pending notifications...")
|
||||||
|
|
||||||
|
// Get unnotified matches
|
||||||
|
matches, err := w.matchRepo.FindUnnotifiedMatches()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Error fetching unnotified matches: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
log.Println("✅ No pending notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📧 Found %d pending notifications", len(matches))
|
||||||
|
|
||||||
|
sentCount := 0
|
||||||
|
for _, match := range matches {
|
||||||
|
// Send notification to lost item owner
|
||||||
|
if err := w.sendMatchNotification(&match); err != nil {
|
||||||
|
log.Printf("❌ Failed to send notification for match ID %d: %v", match.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as notified
|
||||||
|
if err := w.matchRepo.MarkAsNotified(match.ID); err != nil {
|
||||||
|
log.Printf("❌ Failed to mark match ID %d as notified: %v", match.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sentCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Sent %d notifications", sentCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMatchNotification sends a match notification
|
||||||
|
func (w *NotificationWorker) sendMatchNotification(match *models.MatchResult) error {
|
||||||
|
// Create notification
|
||||||
|
err := models.CreateMatchNotification(
|
||||||
|
w.db,
|
||||||
|
match.LostItem.UserID,
|
||||||
|
match.Item.Name,
|
||||||
|
match.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("📧 Sent match notification to user ID %d for item: %s",
|
||||||
|
match.LostItem.UserID, match.Item.Name)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNow runs notification sending immediately (for testing)
|
||||||
|
func (w *NotificationWorker) RunNow() {
|
||||||
|
log.Println("▶️ Running notification sending manually...")
|
||||||
|
w.sendPendingNotifications()
|
||||||
|
}
|
||||||
0
uploads/claims/.gitkeep
Normal file
0
uploads/claims/.gitkeep
Normal file
0
uploads/items/.gitkeep
Normal file
0
uploads/items/.gitkeep
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user