15315 lines
450 KiB
Plaintext
15315 lines
450 KiB
Plaintext
// cmd/server/main.go
|
|
// cmd/server/main.go
|
|
package main
|
|
|
|
import (
|
|
"log"
|
|
"lost-and-found/internal/config"
|
|
"lost-and-found/internal/middleware"
|
|
"lost-and-found/internal/routes"
|
|
"lost-and-found/internal/workers"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
func main() {
|
|
// Load .env file
|
|
if err := godotenv.Load(); err != nil {
|
|
log.Println("⚠️ No .env file found, using environment variables")
|
|
}
|
|
|
|
// Initialize JWT config
|
|
config.InitJWT()
|
|
|
|
// Initialize database
|
|
log.Println("📊 Initializing database...")
|
|
if err := config.InitDB(); err != nil {
|
|
log.Fatalf("❌ Failed to initialize database: %v", err)
|
|
}
|
|
defer config.CloseDB()
|
|
|
|
// Run migrations
|
|
log.Println("🔄 Running migrations...")
|
|
db := config.GetDB()
|
|
if err := config.RunMigrations(db); err != nil {
|
|
log.Fatalf("❌ Failed to run migrations: %v", err)
|
|
}
|
|
|
|
// Initialize Gin
|
|
if config.IsProduction() {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
}
|
|
|
|
router := gin.Default()
|
|
|
|
// Apply middleware
|
|
router.Use(middleware.CORSMiddleware())
|
|
router.Use(middleware.LoggerMiddleware())
|
|
router.Use(middleware.RateLimiterMiddleware())
|
|
|
|
// Serve static files (uploads)
|
|
router.Static("/uploads", "./uploads")
|
|
router.Static("/css", "./web/css")
|
|
router.Static("/js", "./web/js")
|
|
|
|
// Frontend routes
|
|
router.GET("/", func(c *gin.Context) {
|
|
c.File("./web/index.html")
|
|
})
|
|
router.GET("/login", func(c *gin.Context) {
|
|
c.File("./web/login.html")
|
|
})
|
|
router.GET("/register", func(c *gin.Context) {
|
|
c.File("./web/register.html")
|
|
})
|
|
router.GET("/admin", func(c *gin.Context) {
|
|
c.File("./web/admin.html")
|
|
})
|
|
router.GET("/manager", func(c *gin.Context) {
|
|
c.File("./web/manager.html")
|
|
})
|
|
router.GET("/user", func(c *gin.Context) {
|
|
c.File("./web/user.html")
|
|
})
|
|
|
|
// Setup API routes
|
|
routes.SetupRoutes(router, db)
|
|
|
|
// Start background workers with error handling
|
|
log.Println("🔄 Starting background workers...")
|
|
expireWorker := workers.NewExpireWorker(db)
|
|
auditWorker := workers.NewAuditWorker(db)
|
|
matchingWorker := workers.NewMatchingWorker(db)
|
|
notificationWorker := workers.NewNotificationWorker(db)
|
|
|
|
go expireWorker.Start()
|
|
go auditWorker.Start()
|
|
go matchingWorker.Start()
|
|
go notificationWorker.Start()
|
|
log.Println("✅ Background workers started")
|
|
|
|
// Setup graceful shutdown
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
// Get server config
|
|
serverConfig := config.GetServerConfig()
|
|
port := serverConfig.Port
|
|
|
|
// Start server
|
|
go func() {
|
|
log.Printf("🚀 Server starting on http://localhost:%s\n", port)
|
|
log.Printf("📍 API available at http://localhost:%s/api\n", port)
|
|
log.Printf("🌐 Frontend available at http://localhost:%s\n", port)
|
|
log.Println("✅ Ready to receive requests")
|
|
|
|
if err := router.Run(":" + port); err != nil {
|
|
log.Fatalf("❌ Failed to start server: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Wait for interrupt signal
|
|
<-quit
|
|
log.Println("\n🛑 Shutting down server gracefully...")
|
|
|
|
// Stop workers
|
|
log.Println("🔄 Stopping background workers...")
|
|
expireWorker.Stop()
|
|
auditWorker.Stop()
|
|
matchingWorker.Stop()
|
|
notificationWorker.Stop()
|
|
log.Println("✅ Background workers stopped")
|
|
|
|
log.Println("✅ Server exited gracefully")
|
|
}
|
|
|
|
-- database/enhancement.sql
|
|
-- ============================================
|
|
-- ENHANCEMENT FOR LOST & FOUND DATABASE
|
|
-- Stored Procedures, Functions, Triggers, Views
|
|
-- ============================================
|
|
DELIMITER $$
|
|
|
|
-- ============================================
|
|
-- FUNCTIONS
|
|
-- ============================================
|
|
|
|
-- Function: Hitung similarity score antara 2 string
|
|
CREATE FUNCTION fn_calculate_similarity(str1 TEXT, str2 TEXT)
|
|
RETURNS DECIMAL(5,2)
|
|
DETERMINISTIC
|
|
BEGIN
|
|
DECLARE similarity DECIMAL(5,2);
|
|
DECLARE len1 INT;
|
|
DECLARE len2 INT;
|
|
DECLARE common_words INT DEFAULT 0;
|
|
|
|
SET len1 = LENGTH(str1);
|
|
SET len2 = LENGTH(str2);
|
|
|
|
-- Simple similarity calculation (bisa diganti dengan algorithm lebih kompleks)
|
|
IF str1 = str2 THEN
|
|
SET similarity = 100.00;
|
|
ELSEIF LOCATE(LOWER(str1), LOWER(str2)) > 0 THEN
|
|
SET similarity = 75.00;
|
|
ELSE
|
|
SET similarity = 50.00;
|
|
END IF;
|
|
|
|
RETURN similarity;
|
|
END$$
|
|
|
|
-- Function: Check apakah item sudah expired
|
|
CREATE FUNCTION fn_is_item_expired(item_id INT)
|
|
RETURNS BOOLEAN
|
|
READS SQL DATA
|
|
BEGIN
|
|
DECLARE is_expired BOOLEAN DEFAULT FALSE;
|
|
DECLARE expire_date TIMESTAMP;
|
|
|
|
SELECT expires_at INTO expire_date
|
|
FROM items
|
|
WHERE id = item_id AND deleted_at IS NULL;
|
|
|
|
IF expire_date IS NOT NULL AND expire_date < NOW() THEN
|
|
SET is_expired = TRUE;
|
|
END IF;
|
|
|
|
RETURN is_expired;
|
|
END$$
|
|
|
|
-- Function: Hitung total items per kategori
|
|
CREATE FUNCTION fn_count_items_by_category(cat_id INT)
|
|
RETURNS INT
|
|
READS SQL DATA
|
|
BEGIN
|
|
DECLARE total INT DEFAULT 0;
|
|
|
|
SELECT COUNT(*) INTO total
|
|
FROM items
|
|
WHERE category_id = cat_id
|
|
AND deleted_at IS NULL
|
|
AND status = 'unclaimed';
|
|
|
|
RETURN total;
|
|
END$$
|
|
|
|
-- ============================================
|
|
-- STORED PROCEDURES
|
|
-- ============================================
|
|
|
|
-- Procedure: Create claim dengan transaction dan locking
|
|
CREATE PROCEDURE sp_create_claim(
|
|
IN p_item_id INT,
|
|
IN p_user_id INT,
|
|
IN p_description TEXT,
|
|
IN p_proof_url VARCHAR(255),
|
|
IN p_contact VARCHAR(50),
|
|
OUT p_claim_id INT,
|
|
OUT p_status VARCHAR(50),
|
|
OUT p_message TEXT
|
|
)
|
|
BEGIN
|
|
DECLARE v_item_status VARCHAR(50);
|
|
DECLARE v_item_desc TEXT;
|
|
DECLARE v_similarity DECIMAL(5,2);
|
|
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
|
BEGIN
|
|
ROLLBACK;
|
|
SET p_status = 'error';
|
|
SET p_message = 'Transaction failed. Claim not created.';
|
|
SET p_claim_id = NULL;
|
|
END;
|
|
|
|
START TRANSACTION;
|
|
|
|
-- Lock item untuk prevent concurrent claims
|
|
SELECT status, description INTO v_item_status, v_item_desc
|
|
FROM items
|
|
WHERE id = p_item_id AND deleted_at IS NULL
|
|
FOR UPDATE;
|
|
|
|
-- Validasi: item harus unclaimed
|
|
IF v_item_status != 'unclaimed' THEN
|
|
SET p_status = 'error';
|
|
SET p_message = 'Item sudah diklaim atau tidak tersedia';
|
|
ROLLBACK;
|
|
ELSE
|
|
-- Insert claim
|
|
INSERT INTO claims (
|
|
item_id, user_id, description,
|
|
proof_url, contact, status
|
|
) VALUES (
|
|
p_item_id, p_user_id, p_description,
|
|
p_proof_url, p_contact, 'pending'
|
|
);
|
|
|
|
SET p_claim_id = LAST_INSERT_ID();
|
|
|
|
-- Calculate similarity score
|
|
SET v_similarity = fn_calculate_similarity(p_description, v_item_desc);
|
|
|
|
-- Insert verification record
|
|
INSERT INTO claim_verifications (
|
|
claim_id, similarity_score, is_auto_matched
|
|
) VALUES (
|
|
p_claim_id, v_similarity, FALSE
|
|
);
|
|
|
|
-- Create notification for managers
|
|
INSERT INTO notifications (
|
|
user_id, type, title, message, entity_type, entity_id
|
|
)
|
|
SELECT
|
|
u.id,
|
|
'new_claim',
|
|
'Klaim Baru',
|
|
CONCAT('Ada klaim baru untuk barang: ', i.name),
|
|
'claim',
|
|
p_claim_id
|
|
FROM users u
|
|
CROSS JOIN items i
|
|
WHERE u.role_id = 2
|
|
AND u.deleted_at IS NULL
|
|
AND i.id = p_item_id;
|
|
|
|
SET p_status = 'success';
|
|
SET p_message = 'Claim created successfully';
|
|
|
|
COMMIT;
|
|
END IF;
|
|
END$$
|
|
|
|
-- Procedure: Verify claim (approve/reject)
|
|
CREATE PROCEDURE sp_verify_claim(
|
|
IN p_claim_id INT,
|
|
IN p_verifier_id INT,
|
|
IN p_action VARCHAR(20), -- 'approve' or 'reject'
|
|
IN p_notes TEXT,
|
|
OUT p_status VARCHAR(50),
|
|
OUT p_message TEXT
|
|
)
|
|
BEGIN
|
|
DECLARE v_item_id INT;
|
|
DECLARE v_user_id INT;
|
|
DECLARE v_claim_status VARCHAR(50);
|
|
|
|
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
|
BEGIN
|
|
ROLLBACK;
|
|
SET p_status = 'error';
|
|
SET p_message = 'Verification failed';
|
|
END;
|
|
|
|
START TRANSACTION;
|
|
|
|
-- Get claim details with lock
|
|
SELECT item_id, user_id, status
|
|
INTO v_item_id, v_user_id, v_claim_status
|
|
FROM claims
|
|
WHERE id = p_claim_id AND deleted_at IS NULL
|
|
FOR UPDATE;
|
|
|
|
IF v_claim_status != 'pending' THEN
|
|
SET p_status = 'error';
|
|
SET p_message = 'Claim already processed';
|
|
ROLLBACK;
|
|
ELSE
|
|
-- Update claim status
|
|
UPDATE claims
|
|
SET status = p_action,
|
|
notes = p_notes,
|
|
verified_at = NOW(),
|
|
verified_by = p_verifier_id
|
|
WHERE id = p_claim_id;
|
|
|
|
-- If approved, update item status
|
|
IF p_action = 'approved' THEN
|
|
UPDATE items
|
|
SET status = 'claimed'
|
|
WHERE id = v_item_id;
|
|
|
|
-- Archive the item
|
|
INSERT INTO archives (
|
|
item_id, name, category_id, photo_url, location,
|
|
description, date_found, status, reporter_name,
|
|
reporter_contact, archived_reason, claimed_by
|
|
)
|
|
SELECT
|
|
id, name, category_id, photo_url, location,
|
|
description, date_found, status, reporter_name,
|
|
reporter_contact, 'case_closed', v_user_id
|
|
FROM items
|
|
WHERE id = v_item_id;
|
|
END IF;
|
|
|
|
-- Notify user
|
|
INSERT INTO notifications (
|
|
user_id, type, title, message, entity_type, entity_id
|
|
) VALUES (
|
|
v_user_id,
|
|
CONCAT('claim_', p_action),
|
|
IF(p_action = 'approved', 'Klaim Disetujui!', 'Klaim Ditolak'),
|
|
CONCAT('Klaim Anda telah ', IF(p_action = 'approved', 'disetujui', 'ditolak')),
|
|
'claim',
|
|
p_claim_id
|
|
);
|
|
|
|
SET p_status = 'success';
|
|
SET p_message = CONCAT('Claim ', p_action, ' successfully');
|
|
|
|
COMMIT;
|
|
END IF;
|
|
END$$
|
|
|
|
-- Procedure: Archive expired items
|
|
CREATE PROCEDURE sp_archive_expired_items(
|
|
OUT p_archived_count INT
|
|
)
|
|
BEGIN
|
|
DECLARE done INT DEFAULT FALSE;
|
|
DECLARE v_item_id INT;
|
|
DECLARE cur CURSOR FOR
|
|
SELECT id FROM items
|
|
WHERE expires_at < NOW()
|
|
AND status = 'unclaimed'
|
|
AND deleted_at IS NULL;
|
|
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
|
|
|
SET p_archived_count = 0;
|
|
|
|
OPEN cur;
|
|
|
|
read_loop: LOOP
|
|
FETCH cur INTO v_item_id;
|
|
IF done THEN
|
|
LEAVE read_loop;
|
|
END IF;
|
|
|
|
-- Archive the item
|
|
INSERT INTO archives (
|
|
item_id, name, category_id, photo_url, location,
|
|
description, date_found, status, reporter_name,
|
|
reporter_contact, archived_reason
|
|
)
|
|
SELECT
|
|
id, name, category_id, photo_url, location,
|
|
description, date_found, status, reporter_name,
|
|
reporter_contact, 'expired'
|
|
FROM items
|
|
WHERE id = v_item_id;
|
|
|
|
-- Update item status
|
|
UPDATE items
|
|
SET status = 'expired'
|
|
WHERE id = v_item_id;
|
|
|
|
SET p_archived_count = p_archived_count + 1;
|
|
END LOOP;
|
|
|
|
CLOSE cur;
|
|
END$$
|
|
|
|
-- Procedure: Auto-match lost items with found items
|
|
CREATE PROCEDURE sp_auto_match_items()
|
|
BEGIN
|
|
DECLARE done INT DEFAULT FALSE;
|
|
DECLARE v_lost_id INT;
|
|
DECLARE v_item_id INT;
|
|
DECLARE v_similarity DECIMAL(5,2);
|
|
|
|
DECLARE cur CURSOR FOR
|
|
SELECT li.id, i.id, fn_calculate_similarity(li.description, i.description)
|
|
FROM lost_items li
|
|
CROSS JOIN items i
|
|
WHERE li.status = 'active'
|
|
AND i.status = 'unclaimed'
|
|
AND li.category_id = i.category_id
|
|
AND li.deleted_at IS NULL
|
|
AND i.deleted_at IS NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM match_results mr
|
|
WHERE mr.lost_item_id = li.id
|
|
AND mr.item_id = i.id
|
|
AND mr.deleted_at IS NULL
|
|
);
|
|
|
|
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
|
|
|
OPEN cur;
|
|
|
|
match_loop: LOOP
|
|
FETCH cur INTO v_lost_id, v_item_id, v_similarity;
|
|
IF done THEN
|
|
LEAVE match_loop;
|
|
END IF;
|
|
|
|
-- Only create match if similarity >= 70%
|
|
IF v_similarity >= 70.00 THEN
|
|
INSERT INTO match_results (
|
|
lost_item_id, item_id, similarity_score,
|
|
matched_fields, is_notified
|
|
) VALUES (
|
|
v_lost_id, v_item_id, v_similarity,
|
|
'{"category": 100, "description": ' || v_similarity || '}',
|
|
FALSE
|
|
);
|
|
|
|
-- Create notification
|
|
INSERT INTO notifications (
|
|
user_id, type, title, message, entity_type, entity_id
|
|
)
|
|
SELECT
|
|
li.user_id,
|
|
'match_found',
|
|
'Barang yang Mirip Ditemukan!',
|
|
CONCAT('Kami menemukan barang yang mirip: ', i.name),
|
|
'match',
|
|
LAST_INSERT_ID()
|
|
FROM lost_items li
|
|
JOIN items i ON i.id = v_item_id
|
|
WHERE li.id = v_lost_id;
|
|
END IF;
|
|
END LOOP;
|
|
|
|
CLOSE cur;
|
|
END$$
|
|
|
|
-- ============================================
|
|
-- TRIGGERS
|
|
-- ============================================
|
|
|
|
-- Trigger: Auto-set expires_at saat item dibuat (3 bulan dari date_found)
|
|
CREATE TRIGGER trg_items_before_insert
|
|
BEFORE INSERT ON items
|
|
FOR EACH ROW
|
|
BEGIN
|
|
IF NEW.expires_at IS NULL THEN
|
|
SET NEW.expires_at = DATE_ADD(NEW.date_found, INTERVAL 3 MONTH);
|
|
END IF;
|
|
END$$
|
|
|
|
-- Trigger: Auto-log ke audit_logs saat user login
|
|
CREATE TRIGGER trg_audit_log_after_user_update
|
|
AFTER UPDATE ON users
|
|
FOR EACH ROW
|
|
BEGIN
|
|
IF OLD.updated_at != NEW.updated_at THEN
|
|
INSERT INTO audit_logs (
|
|
user_id, action, entity_type, entity_id, details
|
|
) VALUES (
|
|
NEW.id,
|
|
'update',
|
|
'user',
|
|
NEW.id,
|
|
CONCAT('User updated: ', NEW.name)
|
|
);
|
|
END IF;
|
|
END$$
|
|
|
|
-- Trigger: Auto-create revision log saat item di-update
|
|
CREATE TRIGGER trg_items_after_update
|
|
AFTER UPDATE ON items
|
|
FOR EACH ROW
|
|
BEGIN
|
|
IF OLD.name != NEW.name THEN
|
|
INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value)
|
|
VALUES (NEW.id, NEW.reporter_id, 'name', OLD.name, NEW.name);
|
|
END IF;
|
|
|
|
IF OLD.description != NEW.description THEN
|
|
INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value)
|
|
VALUES (NEW.id, NEW.reporter_id, 'description', OLD.description, NEW.description);
|
|
END IF;
|
|
|
|
IF OLD.status != NEW.status THEN
|
|
INSERT INTO revision_logs (item_id, user_id, field_name, old_value, new_value)
|
|
VALUES (NEW.id, NEW.reporter_id, 'status', OLD.status, NEW.status);
|
|
END IF;
|
|
END$$
|
|
|
|
-- Trigger: Prevent delete if item has active claims
|
|
CREATE TRIGGER trg_items_before_delete
|
|
BEFORE DELETE ON items
|
|
FOR EACH ROW
|
|
BEGIN
|
|
DECLARE v_active_claims INT;
|
|
|
|
SELECT COUNT(*) INTO v_active_claims
|
|
FROM claims
|
|
WHERE item_id = OLD.id
|
|
AND status = 'pending'
|
|
AND deleted_at IS NULL;
|
|
|
|
IF v_active_claims > 0 THEN
|
|
SIGNAL SQLSTATE '45000'
|
|
SET MESSAGE_TEXT = 'Cannot delete item with active claims';
|
|
END IF;
|
|
END$$
|
|
|
|
-- Trigger: Auto-archive item when claimed
|
|
CREATE TRIGGER trg_items_after_status_claimed
|
|
AFTER UPDATE ON items
|
|
FOR EACH ROW
|
|
BEGIN
|
|
IF OLD.status != 'claimed' AND NEW.status = 'claimed' THEN
|
|
INSERT INTO archives (
|
|
item_id, name, category_id, photo_url, location,
|
|
description, date_found, status, reporter_name,
|
|
reporter_contact, archived_reason
|
|
) VALUES (
|
|
NEW.id, NEW.name, NEW.category_id, NEW.photo_url,
|
|
NEW.location, NEW.description, NEW.date_found,
|
|
NEW.status, NEW.reporter_name, NEW.reporter_contact,
|
|
'case_closed'
|
|
);
|
|
END IF;
|
|
END$$
|
|
|
|
DELIMITER ;
|
|
|
|
-- ============================================
|
|
-- VIEWS untuk ETL dan Reporting
|
|
-- ============================================
|
|
|
|
-- View: Dashboard statistics
|
|
CREATE OR REPLACE VIEW vw_dashboard_stats AS
|
|
SELECT
|
|
(SELECT COUNT(*) FROM items WHERE status = 'unclaimed' AND deleted_at IS NULL) AS total_unclaimed,
|
|
(SELECT COUNT(*) FROM items WHERE status = 'claimed' AND deleted_at IS NULL) AS total_claimed,
|
|
(SELECT COUNT(*) FROM lost_items WHERE status = 'active' AND deleted_at IS NULL) AS total_lost_reports,
|
|
(SELECT COUNT(*) FROM claims WHERE status = 'pending' AND deleted_at IS NULL) AS pending_claims,
|
|
(SELECT COUNT(*) FROM match_results WHERE is_notified = FALSE AND deleted_at IS NULL) AS unnotified_matches;
|
|
|
|
-- View: Items dengan informasi kategori dan reporter
|
|
CREATE OR REPLACE VIEW vw_items_detail AS
|
|
SELECT
|
|
i.id,
|
|
i.name,
|
|
c.name AS category_name,
|
|
c.slug AS category_slug,
|
|
i.photo_url,
|
|
i.location,
|
|
i.date_found,
|
|
i.status,
|
|
i.reporter_name,
|
|
i.reporter_contact,
|
|
i.expires_at,
|
|
u.name AS reporter_user_name,
|
|
u.email AS reporter_email,
|
|
DATEDIFF(i.expires_at, NOW()) AS days_until_expire,
|
|
i.created_at
|
|
FROM items i
|
|
JOIN categories c ON i.category_id = c.id
|
|
JOIN users u ON i.reporter_id = u.id
|
|
WHERE i.deleted_at IS NULL;
|
|
|
|
-- View: Claims dengan detail lengkap
|
|
CREATE OR REPLACE VIEW vw_claims_detail AS
|
|
SELECT
|
|
c.id,
|
|
c.status,
|
|
i.name AS item_name,
|
|
cat.name AS category_name,
|
|
u.name AS claimant_name,
|
|
u.email AS claimant_email,
|
|
u.phone AS claimant_phone,
|
|
c.description AS claim_description,
|
|
c.contact,
|
|
cv.similarity_score,
|
|
c.verified_at,
|
|
v.name AS verified_by_name,
|
|
c.notes,
|
|
c.created_at
|
|
FROM claims c
|
|
JOIN items i ON c.item_id = i.id
|
|
JOIN categories cat ON i.category_id = cat.id
|
|
JOIN users u ON c.user_id = u.id
|
|
LEFT JOIN claim_verifications cv ON c.id = cv.claim_id
|
|
LEFT JOIN users v ON c.verified_by = v.id
|
|
WHERE c.deleted_at IS NULL;
|
|
|
|
-- View: Match results dengan detail
|
|
CREATE OR REPLACE VIEW vw_match_results_detail AS
|
|
SELECT
|
|
mr.id,
|
|
li.name AS lost_item_name,
|
|
li.user_id AS lost_by_user_id,
|
|
u.name AS lost_by_user_name,
|
|
u.email AS lost_by_email,
|
|
i.name AS found_item_name,
|
|
i.reporter_name AS found_by_name,
|
|
mr.similarity_score,
|
|
mr.is_notified,
|
|
mr.matched_at,
|
|
i.id AS found_item_id,
|
|
li.id AS lost_item_id
|
|
FROM match_results mr
|
|
JOIN lost_items li ON mr.lost_item_id = li.id
|
|
JOIN items i ON mr.item_id = i.id
|
|
JOIN users u ON li.user_id = u.id
|
|
WHERE mr.deleted_at IS NULL
|
|
ORDER BY mr.similarity_score DESC;
|
|
|
|
-- View: Category statistics
|
|
CREATE OR REPLACE VIEW vw_category_stats AS
|
|
SELECT
|
|
c.id,
|
|
c.name,
|
|
c.slug,
|
|
COUNT(DISTINCT i.id) AS total_items,
|
|
COUNT(DISTINCT CASE WHEN i.status = 'unclaimed' THEN i.id END) AS unclaimed_items,
|
|
COUNT(DISTINCT CASE WHEN i.status = 'claimed' THEN i.id END) AS claimed_items,
|
|
COUNT(DISTINCT li.id) AS total_lost_reports
|
|
FROM categories c
|
|
LEFT JOIN items i ON c.id = i.category_id AND i.deleted_at IS NULL
|
|
LEFT JOIN lost_items li ON c.id = li.category_id AND li.deleted_at IS NULL
|
|
WHERE c.deleted_at IS NULL
|
|
GROUP BY c.id, c.name, c.slug;
|
|
|
|
-- ============================================
|
|
-- EVENT SCHEDULER (untuk auto-archiving)
|
|
-- ============================================
|
|
|
|
-- Enable event scheduler
|
|
SET GLOBAL event_scheduler = ON;
|
|
|
|
-- Event: Archive expired items setiap hari jam 1 pagi
|
|
CREATE EVENT IF NOT EXISTS evt_archive_expired_items
|
|
ON SCHEDULE EVERY 1 DAY
|
|
STARTS CURRENT_TIMESTAMP + INTERVAL 1 DAY
|
|
DO
|
|
CALL sp_archive_expired_items(@count);
|
|
|
|
-- Event: Auto-match items setiap 1 jam
|
|
CREATE EVENT IF NOT EXISTS evt_auto_match_items
|
|
ON SCHEDULE EVERY 1 HOUR
|
|
DO
|
|
CALL sp_auto_match_items();
|
|
|
|
-- ============================================
|
|
-- SUCCESS MESSAGE
|
|
-- ============================================
|
|
SELECT '✅ Database enhancements created!' AS Status;
|
|
SELECT '📝 Functions: 3' AS Info;
|
|
SELECT '⚙️ Procedures: 4' AS Info;
|
|
SELECT '🔔 Triggers: 5' AS Info;
|
|
SELECT '📊 Views: 5' AS Info;
|
|
SELECT '⏰ Events: 2' AS Info;
|
|
|
|
-- database/schema.sql
|
|
-- Lost & Found Database Schema (FIXED - SYNC with ERD)
|
|
-- MySQL/MariaDB Database
|
|
|
|
-- ============================================
|
|
-- CREATE DATABASE
|
|
-- ============================================
|
|
DROP DATABASE IF EXISTS lost_and_found;
|
|
CREATE DATABASE lost_and_found CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
USE lost_and_found;
|
|
|
|
-- Set charset dan collation
|
|
SET NAMES utf8mb4;
|
|
SET CHARACTER SET utf8mb4;
|
|
|
|
-- Drop tables if exists (untuk clean migration)
|
|
DROP TABLE IF EXISTS notifications;
|
|
DROP TABLE IF EXISTS revision_logs;
|
|
DROP TABLE IF EXISTS verification_logs;
|
|
DROP TABLE IF EXISTS match_results;
|
|
DROP TABLE IF EXISTS claim_verifications;
|
|
DROP TABLE IF EXISTS audit_logs;
|
|
DROP TABLE IF EXISTS archives;
|
|
DROP TABLE IF EXISTS claims;
|
|
DROP TABLE IF EXISTS attachments;
|
|
DROP TABLE IF EXISTS items;
|
|
DROP TABLE IF EXISTS lost_items;
|
|
DROP TABLE IF EXISTS categories;
|
|
DROP TABLE IF EXISTS users;
|
|
DROP TABLE IF EXISTS roles;
|
|
|
|
-- ============================================
|
|
-- ROLES TABLE
|
|
-- ============================================
|
|
CREATE TABLE roles (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
name VARCHAR(50) UNIQUE NOT NULL,
|
|
description TEXT,
|
|
permissions JSON DEFAULT NULL COMMENT 'RBAC permissions in JSON format',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
INDEX idx_roles_name (name),
|
|
INDEX idx_roles_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- USERS TABLE
|
|
-- ============================================
|
|
CREATE TABLE users (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
name VARCHAR(100) NOT NULL,
|
|
email VARCHAR(100) UNIQUE NOT NULL,
|
|
password VARCHAR(255) NOT NULL,
|
|
nrp VARCHAR(20) UNIQUE DEFAULT NULL,
|
|
phone VARCHAR(20) DEFAULT NULL,
|
|
role_id INT UNSIGNED NOT NULL DEFAULT 3,
|
|
status VARCHAR(20) DEFAULT 'active',
|
|
last_login DATETIME DEFAULT NULL COMMENT 'Last login timestamp',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE RESTRICT,
|
|
INDEX idx_users_email (email),
|
|
INDEX idx_users_nrp (nrp),
|
|
INDEX idx_users_role_id (role_id),
|
|
INDEX idx_users_status (status),
|
|
INDEX idx_users_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- CATEGORIES TABLE
|
|
-- ============================================
|
|
CREATE TABLE categories (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
name VARCHAR(100) NOT NULL,
|
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
|
description TEXT,
|
|
icon_url VARCHAR(255) DEFAULT NULL COMMENT 'Category icon URL',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
INDEX idx_categories_slug (slug),
|
|
INDEX idx_categories_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- ITEMS TABLE (Barang Ditemukan)
|
|
-- ============================================
|
|
CREATE TABLE items (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
name VARCHAR(100) NOT NULL,
|
|
category_id INT UNSIGNED NOT NULL,
|
|
photo_url VARCHAR(255) DEFAULT NULL,
|
|
location VARCHAR(200) NOT NULL,
|
|
description TEXT NOT NULL COMMENT 'Public description',
|
|
secret_details TEXT DEFAULT NULL COMMENT 'RAHASIA - untuk verifikasi klaim (hanya visible untuk owner/admin)',
|
|
date_found DATE NOT NULL COMMENT 'Tanggal barang ditemukan',
|
|
status VARCHAR(50) DEFAULT 'unclaimed' COMMENT 'unclaimed, claimed, expired',
|
|
reporter_id INT UNSIGNED NOT NULL,
|
|
reporter_name VARCHAR(100) NOT NULL,
|
|
reporter_contact VARCHAR(50) NOT NULL,
|
|
view_count INT DEFAULT 0 COMMENT 'Total views untuk analytics',
|
|
expires_at TIMESTAMP NULL DEFAULT NULL COMMENT 'Tanggal barang akan dihapus (3 bulan dari date_found)',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
|
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE RESTRICT,
|
|
INDEX idx_items_category_id (category_id),
|
|
INDEX idx_items_status (status),
|
|
INDEX idx_items_reporter_id (reporter_id),
|
|
INDEX idx_items_date_found (date_found),
|
|
INDEX idx_items_expires_at (expires_at),
|
|
INDEX idx_items_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- LOST_ITEMS TABLE (Barang Hilang)
|
|
-- ============================================
|
|
CREATE TABLE lost_items (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
name VARCHAR(100) NOT NULL,
|
|
category_id INT UNSIGNED NOT NULL,
|
|
color VARCHAR(50) DEFAULT NULL,
|
|
location VARCHAR(200) DEFAULT NULL,
|
|
description TEXT NOT NULL COMMENT 'Deskripsi untuk auto-matching',
|
|
date_lost DATE NOT NULL COMMENT 'Tanggal barang hilang',
|
|
status VARCHAR(50) DEFAULT 'active' COMMENT 'active, resolved',
|
|
resolved_at DATETIME DEFAULT NULL COMMENT 'Kapan laporan hilang ditandai sebagai selesai',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
|
INDEX idx_lost_items_user_id (user_id),
|
|
INDEX idx_lost_items_category_id (category_id),
|
|
INDEX idx_lost_items_status (status),
|
|
INDEX idx_lost_items_date_lost (date_lost),
|
|
INDEX idx_lost_items_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- ATTACHMENTS TABLE (Foto Barang)
|
|
-- ============================================
|
|
CREATE TABLE attachments (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to items, nullable for lost_items',
|
|
lost_item_id INT UNSIGNED DEFAULT NULL COMMENT 'FK to lost_items, nullable for items',
|
|
file_url VARCHAR(255) NOT NULL,
|
|
file_type VARCHAR(50) DEFAULT NULL COMMENT 'jpg, png, gif, dll',
|
|
file_size INT DEFAULT NULL COMMENT 'File size in bytes',
|
|
upload_by_user_id INT UNSIGNED DEFAULT NULL,
|
|
display_order INT DEFAULT 0 COMMENT 'Order untuk display multiple photos',
|
|
is_primary BOOLEAN DEFAULT FALSE COMMENT 'Primary photo untuk display',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (upload_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_attachments_item_id (item_id),
|
|
INDEX idx_attachments_lost_item_id (lost_item_id),
|
|
INDEX idx_attachments_is_primary (is_primary)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- CLAIMS TABLE (Klaim Barang)
|
|
-- ============================================
|
|
CREATE TABLE claims (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
item_id INT UNSIGNED NOT NULL,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
description TEXT NOT NULL COMMENT 'Deskripsi dari user untuk verifikasi',
|
|
proof_url VARCHAR(255) DEFAULT NULL,
|
|
contact VARCHAR(50) NOT NULL,
|
|
status VARCHAR(50) DEFAULT 'pending' COMMENT 'pending, approved, rejected',
|
|
notes TEXT DEFAULT NULL COMMENT 'Admin verification notes',
|
|
rejection_reason VARCHAR(255) DEFAULT NULL COMMENT 'Alasan penolakan klaim',
|
|
attempt_count INT DEFAULT 1 COMMENT 'Jumlah percobaan klaim (fraud prevention)',
|
|
verified_at TIMESTAMP NULL DEFAULT NULL,
|
|
verified_by INT UNSIGNED DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (verified_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_claims_item_id (item_id),
|
|
INDEX idx_claims_user_id (user_id),
|
|
INDEX idx_claims_status (status),
|
|
INDEX idx_claims_verified_by (verified_by),
|
|
INDEX idx_claims_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- CLAIM_VERIFICATIONS TABLE (Data Verifikasi Klaim)
|
|
-- ============================================
|
|
CREATE TABLE claim_verifications (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
claim_id INT UNSIGNED UNIQUE NOT NULL,
|
|
similarity_score DECIMAL(5,2) DEFAULT 0.00 COMMENT 'Similarity score 0-100',
|
|
matched_keywords TEXT DEFAULT NULL COMMENT 'Keywords matched (JSON format)',
|
|
verification_notes TEXT DEFAULT NULL,
|
|
is_auto_matched BOOLEAN DEFAULT FALSE,
|
|
verification_method VARCHAR(50) DEFAULT 'manual' COMMENT 'manual, auto, hybrid',
|
|
metadata JSON DEFAULT NULL COMMENT 'Additional verification data (extensible)',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
|
|
INDEX idx_claim_verifications_claim_id (claim_id),
|
|
INDEX idx_claim_verifications_similarity_score (similarity_score),
|
|
INDEX idx_claim_verifications_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- VERIFICATION_LOGS TABLE (Audit Trail Verifikasi)
|
|
-- ============================================
|
|
CREATE TABLE verification_logs (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
verification_id INT UNSIGNED NOT NULL,
|
|
verified_by_user_id INT UNSIGNED NOT NULL,
|
|
action VARCHAR(50) NOT NULL COMMENT 'approve, reject, pending, review',
|
|
reason TEXT DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (verification_id) REFERENCES claim_verifications(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (verified_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
|
INDEX idx_verification_logs_verification_id (verification_id),
|
|
INDEX idx_verification_logs_verified_by_user_id (verified_by_user_id),
|
|
INDEX idx_verification_logs_created_at (created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- MATCH_RESULTS TABLE (Hasil Auto-Matching)
|
|
-- ============================================
|
|
CREATE TABLE match_results (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
lost_item_id INT UNSIGNED NOT NULL,
|
|
item_id INT UNSIGNED NOT NULL,
|
|
similarity_score DECIMAL(5,2) NOT NULL,
|
|
matched_fields TEXT DEFAULT NULL COMMENT 'JSON format: {category, description, color}',
|
|
match_reason VARCHAR(100) DEFAULT NULL COMMENT 'Reason for matching: color, location, description, etc',
|
|
matched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
is_notified BOOLEAN DEFAULT FALSE,
|
|
notified_at DATETIME DEFAULT NULL COMMENT 'Kapan user diberitahu tentang match',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (lost_item_id) REFERENCES lost_items(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
|
INDEX idx_match_results_lost_item_id (lost_item_id),
|
|
INDEX idx_match_results_item_id (item_id),
|
|
INDEX idx_match_results_similarity_score (similarity_score),
|
|
INDEX idx_match_results_is_notified (is_notified),
|
|
INDEX idx_match_results_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- ARCHIVES TABLE (Barang yang Diarsipkan)
|
|
-- ============================================
|
|
CREATE TABLE archives (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
item_id INT UNSIGNED UNIQUE NOT NULL COMMENT 'Original item ID',
|
|
name VARCHAR(100) NOT NULL,
|
|
category_id INT UNSIGNED NOT NULL,
|
|
photo_url VARCHAR(255) DEFAULT NULL,
|
|
location VARCHAR(200) DEFAULT NULL,
|
|
description TEXT DEFAULT NULL,
|
|
date_found DATE DEFAULT NULL COMMENT 'Tanggal barang ditemukan (dari items)',
|
|
status VARCHAR(50) DEFAULT NULL,
|
|
reporter_name VARCHAR(100) DEFAULT NULL,
|
|
reporter_contact VARCHAR(50) DEFAULT NULL,
|
|
archived_reason VARCHAR(100) DEFAULT NULL COMMENT 'expired, case_closed',
|
|
claimed_by INT UNSIGNED DEFAULT NULL,
|
|
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT,
|
|
FOREIGN KEY (claimed_by) REFERENCES users(id) ON DELETE SET NULL,
|
|
INDEX idx_archives_item_id (item_id),
|
|
INDEX idx_archives_category_id (category_id),
|
|
INDEX idx_archives_archived_reason (archived_reason),
|
|
INDEX idx_archives_archived_at (archived_at),
|
|
INDEX idx_archives_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- REVISION_LOGS TABLE (Audit Trail Edit Barang)
|
|
-- ============================================
|
|
CREATE TABLE revision_logs (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
item_id INT UNSIGNED NOT NULL,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
field_name VARCHAR(50) NOT NULL,
|
|
old_value TEXT DEFAULT NULL,
|
|
new_value TEXT DEFAULT NULL,
|
|
reason TEXT DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_revision_logs_item_id (item_id),
|
|
INDEX idx_revision_logs_user_id (user_id),
|
|
INDEX idx_revision_logs_created_at (created_at),
|
|
INDEX idx_revision_logs_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- AUDIT_LOGS TABLE (System Audit Trail)
|
|
-- ============================================
|
|
CREATE TABLE audit_logs (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT UNSIGNED DEFAULT NULL,
|
|
action VARCHAR(50) NOT NULL,
|
|
entity_type VARCHAR(50) DEFAULT NULL,
|
|
entity_id INT UNSIGNED DEFAULT NULL,
|
|
details TEXT DEFAULT NULL,
|
|
ip_address VARCHAR(50) DEFAULT NULL,
|
|
user_agent VARCHAR(255) DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
INDEX idx_audit_logs_user_id (user_id),
|
|
INDEX idx_audit_logs_action (action),
|
|
INDEX idx_audit_logs_entity_type (entity_type),
|
|
INDEX idx_audit_logs_entity_id (entity_id),
|
|
INDEX idx_audit_logs_created_at (created_at),
|
|
INDEX idx_audit_logs_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- NOTIFICATIONS TABLE (Notifikasi User)
|
|
-- ============================================
|
|
CREATE TABLE notifications (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT UNSIGNED NOT NULL,
|
|
type VARCHAR(50) NOT NULL,
|
|
title VARCHAR(200) NOT NULL,
|
|
message TEXT NOT NULL,
|
|
entity_type VARCHAR(50) DEFAULT NULL,
|
|
entity_id INT UNSIGNED DEFAULT NULL,
|
|
channel VARCHAR(50) DEFAULT 'push' COMMENT 'email, sms, push',
|
|
is_read BOOLEAN DEFAULT FALSE,
|
|
read_at TIMESTAMP NULL DEFAULT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_notifications_user_id (user_id),
|
|
INDEX idx_notifications_type (type),
|
|
INDEX idx_notifications_is_read (is_read),
|
|
INDEX idx_notifications_created_at (created_at),
|
|
INDEX idx_notifications_deleted_at (deleted_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- ============================================
|
|
-- SUCCESS MESSAGE
|
|
-- ============================================
|
|
SELECT '✅ Database schema created successfully!' AS Status;
|
|
SELECT '📋 Total tables: 15 (UPGRADED from 13)' AS Info;
|
|
SELECT '🔑 Indexes created on all tables' AS Info;
|
|
SELECT '🔗 Foreign keys with proper constraints' AS Info;
|
|
SELECT '✨ NOW SYNC with ERD diagram!' AS Info;
|
|
SELECT '📝 Next step: Run seed.sql to populate initial data' AS NextStep;
|
|
|
|
-- database/seed.sql
|
|
-- Lost & Found Database Seed Data (FIXED - SYNC with ERD)
|
|
-- MySQL/MariaDB Database
|
|
|
|
SET NAMES utf8mb4;
|
|
SET CHARACTER SET utf8mb4;
|
|
SET FOREIGN_KEY_CHECKS = 0;
|
|
|
|
-- ============================================
|
|
-- SEED ROLES
|
|
-- ============================================
|
|
INSERT INTO roles (name, description, permissions) VALUES
|
|
('admin', 'Administrator with full access', JSON_OBJECT(
|
|
'users', JSON_OBJECT('create', true, 'read', true, 'update', true, 'delete', true),
|
|
'items', JSON_OBJECT('create', true, 'read', true, 'update', true, 'delete', true),
|
|
'claims', JSON_OBJECT('create', true, 'read', true, 'update', true, 'delete', true, 'verify', true),
|
|
'reports', JSON_OBJECT('access', true)
|
|
)),
|
|
('manager', 'Manager/Cleaning Service - can verify claims', JSON_OBJECT(
|
|
'items', JSON_OBJECT('read', true, 'update', true),
|
|
'claims', JSON_OBJECT('read', true, 'verify', true),
|
|
'reports', JSON_OBJECT('access', true)
|
|
)),
|
|
('user', 'Regular user/student', JSON_OBJECT(
|
|
'items', JSON_OBJECT('read', true),
|
|
'lost_items', JSON_OBJECT('create', true, 'read', true, 'update', true),
|
|
'claims', JSON_OBJECT('create', true, 'read', true)
|
|
));
|
|
|
|
-- ============================================
|
|
-- SEED CATEGORIES
|
|
-- ============================================
|
|
INSERT INTO categories (name, slug, description, icon_url) VALUES
|
|
('Pakaian', 'pakaian', 'Jaket, baju, celana, dll', '/icons/clothing.png'),
|
|
('Alat Makan', 'alat_makan', 'Botol minum, lunchbox, dll', '/icons/food.png'),
|
|
('Aksesoris', 'aksesoris', 'Jam tangan, gelang, kacamata, dll', '/icons/accessories.png'),
|
|
('Elektronik', 'elektronik', 'Kalkulator, mouse, headset, charger, dll', '/icons/electronics.png'),
|
|
('Alat Tulis', 'alat_tulis', 'Buku, pulpen, tipe-x, dll', '/icons/stationery.png'),
|
|
('Lainnya', 'lainnya', 'Kategori lainnya', '/icons/others.png');
|
|
|
|
-- ============================================
|
|
-- SEED USERS
|
|
-- Password untuk semua user: "password123"
|
|
-- Hash bcrypt: $2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi
|
|
-- ============================================
|
|
|
|
-- Admin User
|
|
INSERT INTO users (name, email, password, nrp, phone, role_id, status, last_login) VALUES
|
|
('Admin System', 'admin@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '1234567890', '081234567890', 1, 'active', '2024-02-10 14:30:00');
|
|
|
|
-- Manager Users
|
|
INSERT INTO users (name, email, password, nrp, phone, role_id, status, last_login) VALUES
|
|
('Pak Budi', 'manager1@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567890', '081234567891', 2, 'active', '2024-02-10 13:00:00'),
|
|
('Bu Siti', 'manager2@lostandfound.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '2234567891', '081234567892', 2, 'active', '2024-02-09 15:30:00');
|
|
|
|
-- Regular Users (Students)
|
|
INSERT INTO users (name, email, password, nrp, phone, role_id, status, last_login) VALUES
|
|
('Ahmad Rizki', 'ahmad@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211001', '081234567893', 3, 'active', '2024-02-10 10:15:00'),
|
|
('Siti Nurhaliza', 'siti@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211002', '081234567894', 3, 'active', '2024-02-08 14:45:00'),
|
|
('Budi Santoso', 'budi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211003', '081234567895', 3, 'active', '2024-02-07 11:20:00'),
|
|
('Dewi Lestari', 'dewi@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211004', '081234567896', 3, 'active', '2024-02-05 09:30:00'),
|
|
('Eko Prasetyo', 'eko@student.com', '$2a$10$zhRlU9e1v6T2x4/bPwOZNOAqSktHjj6QFgXV3i2/0pITlKasUpv4G', '5025211005', '081234567897', 3, 'active', '2024-02-04 16:00:00');
|
|
-- ============================================
|
|
-- SEED ITEMS (Barang Ditemukan)
|
|
-- ============================================
|
|
INSERT INTO items (name, category_id, photo_url, location, description, secret_details, date_found, status, reporter_id, reporter_name, reporter_contact, view_count, expires_at) VALUES
|
|
-- Pakaian
|
|
('Jaket Hitam Nike', 1, '/photos/item1.jpg', 'Gedung A lantai 2', 'Jaket hitam merk Nike ukuran M, ada logo putih di dada kiri, resleting berwarna silver', 'Resleting sudah rusak di bagian bawah, ada tambalan kecil di saku kanan', '2024-01-15', 'unclaimed', 2, 'Pak Budi', '081234567891', 24, '2024-04-15 09:30:00'),
|
|
('Sweater Abu-abu', 1, '/photos/item2.jpg', 'Perpustakaan', 'Sweater abu-abu polos, ada tulisan "ITS" di punggung dengan huruf biru', 'Kancing di tengah ada yang hilang, halus dan lembut', '2024-01-18', 'unclaimed', 3, 'Bu Siti', '081234567892', 18, '2024-04-18 14:20:00'),
|
|
|
|
-- Alat Makan
|
|
('Botol Minum Tupperware Biru', 2, '/photos/item3.jpg', 'Kantin Utama', 'Botol minum tupperware warna biru tua, ada stiker nama "RARA" di samping, tutup warna putih', 'Stiker nama hampir hilang, ada goresan di dasar botol', '2024-01-20', 'unclaimed', 2, 'Pak Budi', '081234567891', 31, '2024-04-20 12:00:00'),
|
|
('Lunchbox Pink', 2, '/photos/item4.jpg', 'Gedung B lantai 1', 'Kotak makan plastik warna pink dengan 2 sekat, ada gambar Hello Kitty di tutup', 'Plastik agak kusam, ada bekas noda coklat di sekat bawah', '2024-01-22', 'unclaimed', 3, 'Bu Siti', '081234567892', 15, '2024-04-22 11:30:00'),
|
|
|
|
-- Elektronik
|
|
('Kalkulator Casio FX-991ID Plus', 4, '/photos/item5.jpg', 'Ruang Kelas C101', 'Kalkulator scientific Casio FX-991ID Plus, warna hitam, ada goresan kecil di layar', 'Baterai masih bagus, layar LCD cerah, tidak ada tombol yang macet', '2024-01-25', 'unclaimed', 2, 'Pak Budi', '081234567891', 42, '2024-04-25 08:15:00'),
|
|
('Mouse Wireless Logitech', 4, '/photos/item6.jpg', 'Lab Komputer', 'Mouse wireless Logitech M185, warna merah, tidak ada baterai dan receiver USB', 'Receiver USB hilang, butuh baterai AA 2 pcs untuk penggantinya', '2024-01-28', 'unclaimed', 3, 'Bu Siti', '081234567892', 19, '2024-04-28 15:45:00'),
|
|
('Charger Laptop HP', 4, '/photos/item7.jpg', 'Perpustakaan meja 15', 'Charger laptop HP original 65W, kabel agak kusut, ujung konektor bulat', 'Konektor terlihat bagus, output stabil, tested working', '2024-02-01', 'unclaimed', 2, 'Pak Budi', '081234567891', 27, '2024-05-01 10:20:00'),
|
|
|
|
-- Aksesoris
|
|
('Jam Tangan Casio G-Shock', 3, '/photos/item8.jpg', 'Toilet Gedung A', 'Jam tangan Casio G-Shock warna hitam dengan strip orange, tali karet, ada goresan di layar', 'Baterai berusia 2 tahun, tali karet masih lentur, tahan air', '2024-02-03', 'unclaimed', 3, 'Bu Siti', '081234567892', 35, '2024-05-03 13:00:00'),
|
|
('Kacamata Minus', 3, '/photos/item9.jpg', 'Masjid Kampus', 'Kacamata minus frame hitam persegi, lensa agak tebal, ada case warna coklat', 'Minus -4.00 untuk kedua mata, lensa anti radiasi, frame bagus', '2024-02-05', 'unclaimed', 2, 'Pak Budi', '081234567891', 22, '2024-05-05 16:30:00'),
|
|
|
|
-- Alat Tulis
|
|
('Buku Kalkulus', 5, '/photos/item10.jpg', 'Gedung C lantai 3', 'Buku Kalkulus karangan Purcell edisi 9, sampul biru, ada coretan nama di halaman pertama (nama dicoret)', 'Halaman lengkap, tidak ada yang hilang, tulisan tangan di margin', '2024-02-08', 'unclaimed', 3, 'Bu Siti', '081234567892', 13, '2024-05-08 09:00:00'),
|
|
('Pensil Mekanik Rotring', 5, '/photos/item11.jpg', 'Studio Gambar', 'Pensil mekanik Rotring Rapid Pro 0.5mm warna silver, agak berat, ada penyok kecil di badan', 'Grip karet masih bagus, mekanisme click terasa presisi', '2024-02-10', 'unclaimed', 2, 'Pak Budi', '081234567891', 8, '2024-05-10 14:45:00');
|
|
|
|
-- ============================================
|
|
-- SEED LOST_ITEMS (Barang Hilang)
|
|
-- ============================================
|
|
INSERT INTO lost_items (user_id, name, category_id, color, location, description, date_lost, status, resolved_at) VALUES
|
|
-- User Ahmad
|
|
(4, 'Jaket Hitam', 1, 'Hitam', 'Gedung A', 'Jaket hitam merk Nike ukuran M dengan logo putih di dada, resleting silver', '2024-01-15', 'active', NULL),
|
|
(4, 'Kalkulator Casio Scientific', 4, 'Hitam', 'Ruang Kelas', 'Kalkulator scientific Casio FX-991ID Plus warna hitam', '2024-01-25', 'active', NULL),
|
|
|
|
-- User Siti
|
|
(5, 'Botol Minum Biru', 2, 'Biru', 'Kantin', 'Botol tupperware biru dengan stiker nama RARA', '2024-01-20', 'active', NULL),
|
|
(5, 'Jam Tangan Casio', 3, 'Hitam', 'Toilet', 'Jam tangan G-Shock hitam dengan strip orange', '2024-02-03', 'active', NULL),
|
|
|
|
-- User Budi
|
|
(6, 'Mouse Wireless', 4, 'Merah', 'Lab Komputer', 'Mouse wireless Logitech warna merah tanpa receiver', '2024-01-28', 'active', NULL),
|
|
|
|
-- User Dewi
|
|
(7, 'Buku Kalkulus', 5, 'Biru', 'Gedung C', 'Buku Kalkulus Purcell edisi 9 sampul biru', '2024-02-08', 'active', NULL),
|
|
|
|
-- User Eko
|
|
(8, 'Kacamata Minus', 3, 'Hitam', 'Masjid', 'Kacamata frame hitam persegi dengan case coklat', '2024-02-05', 'active', NULL);
|
|
|
|
-- ============================================
|
|
-- SEED ATTACHMENTS (Foto Barang)
|
|
-- ============================================
|
|
INSERT INTO attachments (item_id, lost_item_id, file_url, file_type, file_size, upload_by_user_id, display_order, is_primary) VALUES
|
|
-- Attachments untuk items (barang ditemukan)
|
|
(1, NULL, '/photos/items/jaket-nike-1.jpg', 'jpg', 2048576, 2, 1, TRUE),
|
|
(1, NULL, '/photos/items/jaket-nike-2.jpg', 'jpg', 1843200, 2, 2, FALSE),
|
|
(3, NULL, '/photos/items/botol-tupperware-1.jpg', 'jpg', 1536000, 2, 1, TRUE),
|
|
(5, NULL, '/photos/items/kalkulator-casio-1.jpg', 'jpg', 1920000, 2, 1, TRUE),
|
|
(5, NULL, '/photos/items/kalkulator-casio-2.jpg', 'jpg', 1720320, 2, 2, FALSE),
|
|
(8, NULL, '/photos/items/jam-tangan-1.jpg', 'jpg', 2150400, 3, 1, TRUE),
|
|
|
|
-- Attachments untuk lost_items (barang hilang)
|
|
(NULL, 1, '/photos/lost_items/jaket-hilang-1.jpg', 'jpg', 1843200, 4, 1, TRUE),
|
|
(NULL, 3, '/photos/lost_items/botol-hilang-1.jpg', 'jpg', 1638400, 5, 1, TRUE),
|
|
(NULL, 4, '/photos/lost_items/jam-tangan-hilang-1.jpg', 'jpg', 2048576, 5, 1, TRUE),
|
|
(NULL, 5, '/photos/lost_items/mouse-hilang-1.jpg', 'jpg', 1434880, 6, 1, TRUE);
|
|
|
|
-- ============================================
|
|
-- SEED CLAIMS (Klaim Barang)
|
|
-- ============================================
|
|
INSERT INTO claims (item_id, user_id, description, proof_url, contact, status, notes, rejection_reason, attempt_count, verified_at, verified_by) VALUES
|
|
-- Claim pending (belum diverifikasi)
|
|
(1, 4, 'Jaket Nike hitam ukuran M, ada logo putih di dada kiri, resleting silver', '/proofs/claim1.jpg', '081234567893', 'pending', NULL, NULL, 1, NULL, NULL),
|
|
(3, 5, 'Botol tupperware biru dengan stiker nama RARA di samping, tutup putih', '/proofs/claim2.jpg', '081234567894', 'pending', NULL, NULL, 1, NULL, NULL),
|
|
|
|
-- Claim yang sudah approved
|
|
(5, 4, 'Kalkulator Casio FX-991ID Plus warna hitam, ada goresan kecil di layar, baterai masih bagus', '/proofs/claim3.jpg', '081234567893', 'approved', 'Deskripsi cocok dengan secret details, barang diserahkan tanggal 2024-02-09', NULL, 1, '2024-02-09 10:30:00', 2);
|
|
|
|
-- ============================================
|
|
-- SEED CLAIM_VERIFICATIONS
|
|
-- ============================================
|
|
INSERT INTO claim_verifications (claim_id, similarity_score, matched_keywords, verification_notes, is_auto_matched, verification_method, metadata) VALUES
|
|
(1, 85.50, '["jaket", "nike", "hitam", "logo", "putih", "resleting", "silver"]', 'High similarity detected in description and secret details', FALSE, 'manual', JSON_OBJECT(
|
|
'description_match', 85,
|
|
'keywords_match', 90,
|
|
'confidence', 'high',
|
|
'reviewer_notes', 'Sangat sesuai dengan foto dan deskripsi'
|
|
)),
|
|
(2, 92.30, '["botol", "tupperware", "biru", "stiker", "rara", "tutup", "putih"]', 'Very high similarity - likely owner, specific details match perfectly', FALSE, 'manual', JSON_OBJECT(
|
|
'description_match', 95,
|
|
'keywords_match', 94,
|
|
'confidence', 'very_high',
|
|
'reviewer_notes', 'Pemilik tahu detail spesifik dari botol'
|
|
)),
|
|
(3, 88.70, '["kalkulator", "casio", "hitam", "goresan", "layar", "baterai"]', 'Verified and approved - exact match with secret details', TRUE, 'hybrid', JSON_OBJECT(
|
|
'description_match', 92,
|
|
'keywords_match', 89,
|
|
'confidence', 'very_high',
|
|
'auto_match_score', 85,
|
|
'manual_review', 'Confirmed by manager'
|
|
));
|
|
|
|
-- ============================================
|
|
-- SEED VERIFICATION_LOGS
|
|
-- ============================================
|
|
INSERT INTO verification_logs (verification_id, verified_by_user_id, action, reason) VALUES
|
|
(3, 2, 'approve', 'Deskripsi dan detail rahasia cocok sempurna. Klaim disetujui dan barang sudah diserahkan kepada Ahmad Rizki pada 2024-02-09 pukul 10:30 dengan tanda tangan.'),
|
|
(1, 2, 'pending', 'Menunggu konfirmasi lebih lanjut dari manajer. Saat ini sedang dalam review awal.'),
|
|
(2, 2, 'pending', 'Dalam proses verifikasi. Akan hubungi claimant untuk konfirmasi lebih detail.');
|
|
|
|
-- ============================================
|
|
-- SEED MATCH_RESULTS (Auto-Matching)
|
|
-- ============================================
|
|
INSERT INTO match_results (lost_item_id, item_id, similarity_score, matched_fields, match_reason, matched_at, is_notified, notified_at) VALUES
|
|
(1, 1, 87.50, '{"name": 85, "category": 100, "description": 90, "color": 100}', 'color, description, category match', '2024-01-15 10:00:00', TRUE, '2024-01-15 10:15:00'),
|
|
(2, 5, 89.20, '{"name": 88, "category": 100, "description": 91}', 'name, category, description match', '2024-01-25 09:00:00', TRUE, '2024-01-25 09:30:00'),
|
|
(3, 3, 91.80, '{"name": 90, "category": 100, "color": 100, "description": 89}', 'color, category match perfectly', '2024-01-20 13:00:00', TRUE, '2024-01-20 13:45:00'),
|
|
(4, 8, 86.40, '{"name": 82, "category": 100, "color": 100, "description": 85}', 'color, category match', '2024-02-03 14:00:00', TRUE, '2024-02-03 14:20:00'),
|
|
(5, 6, 84.60, '{"name": 80, "category": 100, "color": 100, "description": 88}', 'color, category match', '2024-01-28 16:00:00', FALSE, NULL);
|
|
|
|
-- ============================================
|
|
-- SEED NOTIFICATIONS
|
|
-- ============================================
|
|
INSERT INTO notifications (user_id, type, title, message, entity_type, entity_id, channel, is_read, read_at) VALUES
|
|
-- Notifikasi match ditemukan
|
|
(4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Jaket Hitam Nike. Similarity Score: 87.5%', 'match', 1, 'push', TRUE, '2024-01-15 11:00:00'),
|
|
(4, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Kalkulator Casio FX-991ID Plus. Similarity Score: 89.2%', 'match', 2, 'push', FALSE, NULL),
|
|
(5, 'match_found', 'Barang yang Mirip Ditemukan!', 'Kami menemukan barang yang mirip dengan laporan kehilangan Anda: Botol Minum Tupperware Biru. Similarity Score: 91.8%', 'match', 3, 'push', FALSE, NULL),
|
|
|
|
-- Notifikasi klaim disetujui
|
|
(4, 'claim_approved', 'Klaim Disetujui!', 'Klaim Anda untuk barang Kalkulator Casio FX-991ID Plus telah disetujui. Silakan ambil barang di tempat yang ditentukan.', 'claim', 3, 'email', FALSE, NULL),
|
|
|
|
-- Notifikasi untuk manager (pending claims)
|
|
(2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Jaket Hitam Nike dari Ahmad Rizki. Similarity Score: 85.5%', 'claim', 1, 'push', FALSE, NULL),
|
|
(2, 'new_claim', 'Klaim Baru', 'Ada klaim baru untuk barang: Botol Minum Tupperware Biru dari Siti Nurhaliza. Similarity Score: 92.3%', 'claim', 2, 'push', FALSE, NULL);
|
|
|
|
-- ============================================
|
|
-- SEED AUDIT_LOGS
|
|
-- ============================================
|
|
INSERT INTO audit_logs (user_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES
|
|
-- Login activities
|
|
(1, 'login', 'user', 1, 'Admin logged in', '127.0.0.1', 'Mozilla/5.0'),
|
|
(2, 'login', 'user', 2, 'Manager logged in', '127.0.0.1', 'Mozilla/5.0'),
|
|
(4, 'login', 'user', 4, 'User logged in', '127.0.0.1', 'Mozilla/5.0'),
|
|
|
|
-- Item creation activities
|
|
(2, 'create', 'item', 1, 'Item created: Jaket Hitam Nike', '127.0.0.1', 'Mozilla/5.0'),
|
|
(3, 'create', 'item', 2, 'Item created: Sweater Abu-abu', '127.0.0.1', 'Mozilla/5.0'),
|
|
(2, 'create', 'item', 3, 'Item created: Botol Minum Tupperware Biru', '127.0.0.1', 'Mozilla/5.0'),
|
|
|
|
-- Lost item creation activities
|
|
(4, 'create', 'lost_item', 1, 'Lost item report created: Jaket Hitam', '127.0.0.1', 'Mozilla/5.0'),
|
|
(5, 'create', 'lost_item', 3, 'Lost item report created: Botol Minum Biru', '127.0.0.1', 'Mozilla/5.0'),
|
|
|
|
-- Claim activities
|
|
(4, 'create', 'claim', 1, 'Claim created for item: Jaket Hitam Nike', '127.0.0.1', 'Mozilla/5.0'),
|
|
(5, 'create', 'claim', 2, 'Claim created for item: Botol Minum Tupperware Biru', '127.0.0.1', 'Mozilla/5.0'),
|
|
(4, 'create', 'claim', 3, 'Claim created for item: Kalkulator Casio FX-991ID Plus', '127.0.0.1', 'Mozilla/5.0'),
|
|
|
|
-- Claim verification activity
|
|
(2, 'approve', 'claim', 3, 'Claim approved: Deskripsi cocok dengan secret details, barang diserahkan', '127.0.0.1', 'Mozilla/5.0'),
|
|
|
|
-- Attachment upload
|
|
(2, 'create', 'attachment', 1, 'Attachment uploaded for item 1', '127.0.0.1', 'Mozilla/5.0'),
|
|
(4, 'create', 'attachment', 7, 'Attachment uploaded for lost_item 1', '127.0.0.1', 'Mozilla/5.0');
|
|
|
|
SET FOREIGN_KEY_CHECKS = 1;
|
|
|
|
-- ============================================
|
|
-- SUCCESS MESSAGE
|
|
-- ============================================
|
|
SELECT '✅ Database seeded successfully!' AS Status;
|
|
SELECT '👥 Users created: 8 (1 admin, 2 managers, 5 students)' AS Info;
|
|
SELECT '📦 Items created: 11 found items' AS Info;
|
|
SELECT '📝 Lost items created: 7 reports' AS Info;
|
|
SELECT '📸 Attachments created: 11 photos' AS Info;
|
|
SELECT '🎫 Claims created: 3 (1 approved, 2 pending)' AS Info;
|
|
SELECT '✔️ Claim verifications created: 3' AS Info;
|
|
SELECT '🔍 Verification logs created: 3' AS Info;
|
|
SELECT '🔗 Match results created: 5' AS Info;
|
|
SELECT '🔔 Notifications created: 6' AS Info;
|
|
SELECT '📋 Audit logs created: 13' AS Info;
|
|
SELECT '' AS Empty;
|
|
SELECT '🔐 Login Credentials:' AS Credentials;
|
|
SELECT 'Admin: admin@lostandfound.com / password123' AS Admin;
|
|
SELECT 'Manager: manager1@lostandfound.com / password123' AS Manager1;
|
|
SELECT 'Manager: manager2@lostandfound.com / password123' AS Manager2;
|
|
SELECT 'Student: ahmad@student.com / password123' AS Student;
|
|
SELECT '' AS Empty2;
|
|
SELECT '✨ NOW 100% SYNC with ERD!' AS Status2;
|
|
SELECT '🚀 Ready to use! Database is production-ready' AS NextStep;
|
|
|
|
// internal/config/config.go
|
|
package config
|
|
|
|
import (
|
|
"os"
|
|
)
|
|
|
|
// Config holds all configuration for the application
|
|
type Config struct {
|
|
Database DatabaseConfig
|
|
JWT JWTConfig
|
|
Server ServerConfig
|
|
}
|
|
|
|
// ServerConfig holds server configuration
|
|
type ServerConfig struct {
|
|
Port string
|
|
Environment string
|
|
UploadPath string
|
|
MaxUploadSize int64
|
|
AllowedOrigins []string
|
|
}
|
|
|
|
// GetConfig returns the application configuration
|
|
func GetConfig() *Config {
|
|
return &Config{
|
|
Database: GetDatabaseConfig(),
|
|
JWT: GetJWTConfig(),
|
|
Server: GetServerConfig(),
|
|
}
|
|
}
|
|
|
|
// GetServerConfig returns server configuration from environment
|
|
func GetServerConfig() ServerConfig {
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
env := os.Getenv("ENVIRONMENT")
|
|
if env == "" {
|
|
env = "development"
|
|
}
|
|
|
|
uploadPath := os.Getenv("UPLOAD_PATH")
|
|
if uploadPath == "" {
|
|
uploadPath = "./uploads"
|
|
}
|
|
|
|
return ServerConfig{
|
|
Port: port,
|
|
Environment: env,
|
|
UploadPath: uploadPath,
|
|
MaxUploadSize: 10 * 1024 * 1024, // 10MB
|
|
AllowedOrigins: []string{"*"}, // In production, specify exact origins
|
|
}
|
|
}
|
|
|
|
// IsProduction checks if running in production environment
|
|
func IsProduction() bool {
|
|
return os.Getenv("ENVIRONMENT") == "production"
|
|
}
|
|
|
|
// IsDevelopment checks if running in development environment
|
|
func IsDevelopment() bool {
|
|
return os.Getenv("ENVIRONMENT") != "production"
|
|
}
|
|
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"lost-and-found/internal/models"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var db *gorm.DB
|
|
|
|
// DatabaseConfig holds database connection configuration
|
|
type DatabaseConfig struct {
|
|
Host string
|
|
Port string
|
|
User string
|
|
Password string
|
|
DBName string
|
|
Charset string
|
|
ParseTime string
|
|
Loc string
|
|
}
|
|
|
|
// GetDatabaseConfig returns database configuration from environment
|
|
func GetDatabaseConfig() DatabaseConfig {
|
|
return DatabaseConfig{
|
|
Host: getEnv("DB_HOST", "localhost"),
|
|
Port: getEnv("DB_PORT", "3306"),
|
|
User: getEnv("DB_USER", "root"),
|
|
Password: getEnv("DB_PASSWORD", ""),
|
|
DBName: getEnv("DB_NAME", "lost_and_found"),
|
|
Charset: getEnv("DB_CHARSET", "utf8mb4"),
|
|
ParseTime: getEnv("DB_PARSE_TIME", "True"),
|
|
Loc: getEnv("DB_LOC", "Local"),
|
|
}
|
|
}
|
|
|
|
// InitDB initializes database connection
|
|
func InitDB() error {
|
|
config := GetDatabaseConfig()
|
|
|
|
// Step 1: Connect to MySQL without specifying database (to create if not exists)
|
|
if err := ensureDatabaseExists(config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 2: Connect to the specific database
|
|
dsn := fmt.Sprintf(
|
|
"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s&multiStatements=true",
|
|
config.User,
|
|
config.Password,
|
|
config.Host,
|
|
config.Port,
|
|
config.DBName,
|
|
config.Charset,
|
|
config.ParseTime,
|
|
config.Loc,
|
|
)
|
|
|
|
// Configure GORM logger
|
|
gormLogger := logger.Default
|
|
if IsDevelopment() {
|
|
gormLogger = logger.Default.LogMode(logger.Info)
|
|
} else {
|
|
gormLogger = logger.Default.LogMode(logger.Error)
|
|
}
|
|
|
|
// Open database connection
|
|
var err error
|
|
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|
Logger: gormLogger,
|
|
NowFunc: func() time.Time {
|
|
return time.Now().Local()
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
|
|
// Get underlying SQL database
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get database instance: %w", err)
|
|
}
|
|
|
|
// Set connection pool settings
|
|
sqlDB.SetMaxIdleConns(10)
|
|
sqlDB.SetMaxOpenConns(100)
|
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
|
|
|
// Test connection
|
|
if err := sqlDB.Ping(); err != nil {
|
|
return fmt.Errorf("failed to ping database: %w", err)
|
|
}
|
|
|
|
log.Println("✅ Database connected successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureDatabaseExists checks if database exists, creates it if not
|
|
func ensureDatabaseExists(config DatabaseConfig) error {
|
|
// Connect to MySQL without specifying a database
|
|
dsn := fmt.Sprintf(
|
|
"%s:%s@tcp(%s:%s)/?charset=%s&parseTime=%s&loc=%s",
|
|
config.User,
|
|
config.Password,
|
|
config.Host,
|
|
config.Port,
|
|
config.Charset,
|
|
config.ParseTime,
|
|
config.Loc,
|
|
)
|
|
|
|
tempDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to MySQL server: %w", err)
|
|
}
|
|
|
|
log.Printf("🔍 Checking if database '%s' exists...", config.DBName)
|
|
|
|
// Check if database exists
|
|
var dbExists int64
|
|
if err := tempDB.Raw(
|
|
"SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?",
|
|
config.DBName,
|
|
).Scan(&dbExists).Error; err != nil {
|
|
return fmt.Errorf("failed to check database existence: %w", err)
|
|
}
|
|
|
|
if dbExists == 0 {
|
|
log.Printf("📝 Creating database '%s'...", config.DBName)
|
|
createSQL := fmt.Sprintf(
|
|
"CREATE DATABASE IF NOT EXISTS %s CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
|
|
config.DBName,
|
|
)
|
|
if err := tempDB.Exec(createSQL).Error; err != nil {
|
|
return fmt.Errorf("failed to create database: %w", err)
|
|
}
|
|
log.Printf("✅ Database '%s' created successfully", config.DBName)
|
|
} else {
|
|
log.Printf("✅ Database '%s' already exists", config.DBName)
|
|
}
|
|
|
|
// Close temporary connection
|
|
sqlDB, _ := tempDB.DB()
|
|
sqlDB.Close()
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetDB returns the database instance
|
|
func GetDB() *gorm.DB {
|
|
return db
|
|
}
|
|
|
|
// RunMigrations runs database migrations from SQL files
|
|
func RunMigrations(db *gorm.DB) error {
|
|
log.Println("📊 Starting database migrations...")
|
|
|
|
// Check if tables already exist
|
|
if db.Migrator().HasTable(&models.Role{}) {
|
|
log.Println("✅ Database tables already exist, skipping migration")
|
|
return nil
|
|
}
|
|
|
|
log.Println("📋 Tables not found, running migration scripts...")
|
|
|
|
// Step 1: Run schema.sql
|
|
if err := runSQLFile(db, "database/schema.sql"); err != nil {
|
|
return fmt.Errorf("❌ Failed to run schema.sql: %w", err)
|
|
}
|
|
log.Println("✅ Schema created successfully")
|
|
|
|
// Step 2: Run seed.sql
|
|
if err := runSQLFile(db, "database/seed.sql"); err != nil {
|
|
return fmt.Errorf("❌ Failed to run seed.sql: %w", err)
|
|
}
|
|
log.Println("✅ Seed data inserted successfully")
|
|
|
|
// Step 3: Run enhancement.sql (optional - for triggers, procedures, etc)
|
|
if err := runSQLFile(db, "database/enhancement.sql"); err != nil {
|
|
log.Printf("⚠️ Warning: Failed to run enhancement.sql: %v", err)
|
|
log.Println("💡 Enhancement features (triggers, procedures) may not be available")
|
|
} else {
|
|
log.Println("✅ Enhancement features loaded successfully")
|
|
}
|
|
|
|
log.Println("🎉 Database migration completed!")
|
|
log.Println("🔧 Default admin: admin@lostandfound.com / password123")
|
|
|
|
return nil
|
|
}
|
|
|
|
// runSQLFile executes SQL from file
|
|
func runSQLFile(db *gorm.DB, filepath string) error {
|
|
// Check if file exists
|
|
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
|
return fmt.Errorf("file not found: %s", filepath)
|
|
}
|
|
|
|
log.Printf("📄 Reading SQL file: %s", filepath)
|
|
|
|
// Read file
|
|
content, err := os.ReadFile(filepath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
// Split SQL content by delimiter
|
|
sqlContent := string(content)
|
|
|
|
// Remove comments and empty lines
|
|
sqlContent = removeComments(sqlContent)
|
|
|
|
// Split by DELIMITER if exists (for procedures/triggers)
|
|
if strings.Contains(sqlContent, "DELIMITER") {
|
|
return executeSQLWithDelimiter(db, sqlContent)
|
|
}
|
|
|
|
// Execute SQL normally
|
|
if err := db.Exec(sqlContent).Error; err != nil {
|
|
return fmt.Errorf("failed to execute SQL: %w", err)
|
|
}
|
|
|
|
log.Printf("✅ SQL file executed: %s", filepath)
|
|
return nil
|
|
}
|
|
|
|
// executeSQLWithDelimiter handles SQL with custom delimiters (for procedures/triggers)
|
|
func executeSQLWithDelimiter(db *gorm.DB, content string) error {
|
|
// Split by DELIMITER changes
|
|
parts := strings.Split(content, "DELIMITER")
|
|
|
|
for i, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" || part == "$$" || part == ";" {
|
|
continue
|
|
}
|
|
|
|
// Remove the delimiter declaration line
|
|
lines := strings.Split(part, "\n")
|
|
if len(lines) > 0 && (strings.HasPrefix(lines[0], "$$") || strings.HasPrefix(lines[0], ";")) {
|
|
lines = lines[1:]
|
|
}
|
|
part = strings.Join(lines, "\n")
|
|
|
|
// Split by custom delimiter ($$)
|
|
if i%2 == 1 { // Odd parts use $$ delimiter
|
|
statements := strings.Split(part, "$$")
|
|
for _, stmt := range statements {
|
|
stmt = strings.TrimSpace(stmt)
|
|
if stmt == "" || stmt == ";" {
|
|
continue
|
|
}
|
|
if err := db.Exec(stmt).Error; err != nil {
|
|
log.Printf("⚠️ Warning executing statement: %v", err)
|
|
// Don't fail on enhancement errors (triggers, procedures)
|
|
// These might fail if they already exist or MySQL version issues
|
|
}
|
|
}
|
|
} else { // Even parts use ; delimiter
|
|
statements := strings.Split(part, ";")
|
|
for _, stmt := range statements {
|
|
stmt = strings.TrimSpace(stmt)
|
|
if stmt == "" {
|
|
continue
|
|
}
|
|
if err := db.Exec(stmt).Error; err != nil {
|
|
return fmt.Errorf("failed to execute statement: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// removeComments removes SQL comments from content
|
|
func removeComments(sql string) string {
|
|
lines := strings.Split(sql, "\n")
|
|
var cleaned []string
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Skip empty lines
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Skip single-line comments
|
|
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
// Keep the line
|
|
cleaned = append(cleaned, line)
|
|
}
|
|
|
|
return strings.Join(cleaned, "\n")
|
|
}
|
|
|
|
// CloseDB closes database connection
|
|
func CloseDB() error {
|
|
if db != nil {
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sqlDB.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Helper function to get environment variable with default value
|
|
func getEnv(key, defaultValue string) string {
|
|
value := os.Getenv(key)
|
|
if value == "" {
|
|
return defaultValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
// internal/config/jwt.go
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
var jwtConfig *JWTConfig
|
|
|
|
// JWTConfig holds JWT configuration
|
|
type JWTConfig struct {
|
|
SecretKey string
|
|
ExpirationHours int
|
|
Issuer string
|
|
}
|
|
|
|
// JWTClaims represents the JWT claims
|
|
type JWTClaims struct {
|
|
UserID uint `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// InitJWT initializes JWT configuration
|
|
func InitJWT() {
|
|
secretKey := os.Getenv("JWT_SECRET_KEY")
|
|
if secretKey == "" {
|
|
secretKey = "your-secret-key-change-this-in-production" // Default for development
|
|
}
|
|
|
|
jwtConfig = &JWTConfig{
|
|
SecretKey: secretKey,
|
|
ExpirationHours: 24 * 7, // 7 days
|
|
Issuer: "lost-and-found-system",
|
|
}
|
|
}
|
|
|
|
// GetJWTConfig returns JWT configuration
|
|
func GetJWTConfig() JWTConfig {
|
|
if jwtConfig == nil {
|
|
InitJWT()
|
|
}
|
|
return *jwtConfig
|
|
}
|
|
|
|
// GenerateToken generates a new JWT token for a user
|
|
func GenerateToken(userID uint, email, role string) (string, error) {
|
|
config := GetJWTConfig()
|
|
|
|
// Create claims
|
|
claims := JWTClaims{
|
|
UserID: userID,
|
|
Email: email,
|
|
Role: role,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(config.ExpirationHours))),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
Issuer: config.Issuer,
|
|
},
|
|
}
|
|
|
|
// Create token
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
|
|
// Sign token with secret key
|
|
tokenString, err := token.SignedString([]byte(config.SecretKey))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return tokenString, nil
|
|
}
|
|
|
|
// ValidateToken validates a JWT token and returns the claims
|
|
func ValidateToken(tokenString string) (*JWTClaims, error) {
|
|
config := GetJWTConfig()
|
|
|
|
// Parse token
|
|
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
// Validate signing method
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, errors.New("invalid signing method")
|
|
}
|
|
return []byte(config.SecretKey), nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract claims
|
|
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
|
return claims, nil
|
|
}
|
|
|
|
return nil, errors.New("invalid token")
|
|
}
|
|
|
|
// RefreshToken generates a new token with extended expiration
|
|
func RefreshToken(oldTokenString string) (string, error) {
|
|
// Validate old token
|
|
claims, err := ValidateToken(oldTokenString)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Generate new token with same user info
|
|
return GenerateToken(claims.UserID, claims.Email, claims.Role)
|
|
}
|
|
|
|
// ExtractUserID extracts user ID from token string
|
|
func ExtractUserID(tokenString string) (uint, error) {
|
|
claims, err := ValidateToken(tokenString)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return claims.UserID, nil
|
|
}
|
|
|
|
// ExtractRole extracts role from token string
|
|
func ExtractRole(tokenString string) (string, error) {
|
|
claims, err := ValidateToken(tokenString)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return claims.Role, nil
|
|
}
|
|
|
|
// internal/controllers/admin_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AdminController struct {
|
|
userService *services.UserService
|
|
itemRepo *repositories.ItemRepository
|
|
claimRepo *repositories.ClaimRepository
|
|
archiveRepo *repositories.ArchiveRepository
|
|
auditService *services.AuditService
|
|
}
|
|
|
|
func NewAdminController(db *gorm.DB) *AdminController {
|
|
return &AdminController{
|
|
userService: services.NewUserService(db),
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
claimRepo: repositories.NewClaimRepository(db),
|
|
archiveRepo: repositories.NewArchiveRepository(db),
|
|
auditService: services.NewAuditService(db),
|
|
}
|
|
}
|
|
|
|
// GetDashboardStats gets dashboard statistics (admin/manager)
|
|
// GET /api/admin/dashboard
|
|
func (c *AdminController) GetDashboardStats(ctx *gin.Context) {
|
|
stats := make(map[string]interface{})
|
|
|
|
// Item statistics
|
|
totalItems, _ := c.itemRepo.CountByStatus("")
|
|
unclaimedItems, _ := c.itemRepo.CountByStatus("unclaimed")
|
|
verifiedItems, _ := c.itemRepo.CountByStatus("verified")
|
|
expiredItems, _ := c.itemRepo.CountByStatus("expired")
|
|
|
|
stats["items"] = map[string]interface{}{
|
|
"total": totalItems,
|
|
"unclaimed": unclaimedItems,
|
|
"verified": verifiedItems,
|
|
"expired": expiredItems,
|
|
}
|
|
|
|
// Claim statistics
|
|
totalClaims, _ := c.claimRepo.CountByStatus("")
|
|
pendingClaims, _ := c.claimRepo.CountByStatus("pending")
|
|
approvedClaims, _ := c.claimRepo.CountByStatus("approved")
|
|
rejectedClaims, _ := c.claimRepo.CountByStatus("rejected")
|
|
|
|
stats["claims"] = map[string]interface{}{
|
|
"total": totalClaims,
|
|
"pending": pendingClaims,
|
|
"approved": approvedClaims,
|
|
"rejected": rejectedClaims,
|
|
}
|
|
|
|
// Archive statistics
|
|
archivedExpired, _ := c.archiveRepo.CountByReason("expired")
|
|
archivedClosed, _ := c.archiveRepo.CountByReason("case_closed")
|
|
|
|
stats["archives"] = map[string]interface{}{
|
|
"total": archivedExpired + archivedClosed,
|
|
"expired": archivedExpired,
|
|
"case_closed": archivedClosed,
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Dashboard stats retrieved", stats)
|
|
}
|
|
|
|
// GetAuditLogs gets audit logs (admin only)
|
|
// GET /api/admin/audit-logs
|
|
func (c *AdminController) GetAuditLogs(ctx *gin.Context) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20"))
|
|
action := ctx.Query("action")
|
|
entityType := ctx.Query("entity_type")
|
|
|
|
var userID *uint
|
|
if userIDStr := ctx.Query("user_id"); userIDStr != "" {
|
|
id, _ := strconv.ParseUint(userIDStr, 10, 32)
|
|
userID = new(uint)
|
|
*userID = uint(id)
|
|
}
|
|
|
|
logs, total, err := c.auditService.GetAllAuditLogs(page, limit, action, entityType, userID)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get audit logs", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Audit logs retrieved", logs, total, page, limit)
|
|
}
|
|
|
|
// internal/controllers/archive_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ArchiveController struct {
|
|
archiveService *services.ArchiveService
|
|
}
|
|
|
|
func NewArchiveController(db *gorm.DB) *ArchiveController {
|
|
return &ArchiveController{
|
|
archiveService: services.NewArchiveService(db),
|
|
}
|
|
}
|
|
|
|
// GetAllArchives gets all archived items
|
|
// GET /api/archives
|
|
func (c *ArchiveController) GetAllArchives(ctx *gin.Context) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
reason := ctx.Query("reason")
|
|
search := ctx.Query("search")
|
|
|
|
archives, total, err := c.archiveService.GetAllArchives(page, limit, reason, search)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get archives", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Archives retrieved", archives, total, page, limit)
|
|
}
|
|
|
|
// GetArchiveByID gets archive by ID
|
|
// GET /api/archives/:id
|
|
func (c *ArchiveController) GetArchiveByID(ctx *gin.Context) {
|
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid archive ID", err.Error())
|
|
return
|
|
}
|
|
|
|
archive, err := c.archiveService.GetArchiveByID(uint(id))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Archive not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Archive retrieved", archive.ToResponse())
|
|
}
|
|
|
|
// GetArchiveStats gets archive statistics
|
|
// GET /api/archives/stats
|
|
func (c *ArchiveController) GetArchiveStats(ctx *gin.Context) {
|
|
stats, err := c.archiveService.GetArchiveStats()
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Archive stats retrieved", stats)
|
|
}
|
|
|
|
// internal/controllers/auth_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AuthController struct {
|
|
authService *services.AuthService
|
|
}
|
|
|
|
func NewAuthController(db *gorm.DB) *AuthController {
|
|
return &AuthController{
|
|
authService: services.NewAuthService(db),
|
|
}
|
|
}
|
|
|
|
// Register handles user registration
|
|
// POST /api/register
|
|
func (c *AuthController) Register(ctx *gin.Context) {
|
|
var req services.RegisterRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
// Get IP and User-Agent
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
// Register user
|
|
result, err := c.authService.Register(req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Registration failed", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusCreated, "Registration successful", result)
|
|
}
|
|
|
|
// Login handles user login
|
|
// POST /api/login
|
|
func (c *AuthController) Login(ctx *gin.Context) {
|
|
var req services.LoginRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
// Get IP and User-Agent
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
// Login user
|
|
result, err := c.authService.Login(req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Login failed", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Login successful", result)
|
|
}
|
|
|
|
// RefreshToken handles token refresh
|
|
// POST /api/refresh-token
|
|
func (c *AuthController) RefreshToken(ctx *gin.Context) {
|
|
var req struct {
|
|
Token string `json:"token" binding:"required"`
|
|
}
|
|
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
// Refresh token
|
|
newToken, err := c.authService.RefreshToken(req.Token)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Token refreshed", gin.H{
|
|
"token": newToken,
|
|
})
|
|
}
|
|
|
|
// GetMe returns current user info
|
|
// GET /api/me
|
|
func (c *AuthController) GetMe(ctx *gin.Context) {
|
|
user, exists := ctx.Get("user")
|
|
if !exists {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "User info retrieved", user)
|
|
}
|
|
|
|
// internal/controllers/category_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type CategoryController struct {
|
|
categoryService *services.CategoryService
|
|
}
|
|
|
|
func NewCategoryController(db *gorm.DB) *CategoryController {
|
|
return &CategoryController{
|
|
categoryService: services.NewCategoryService(db),
|
|
}
|
|
}
|
|
|
|
// GetAllCategories gets all categories
|
|
// GET /api/categories
|
|
func (c *CategoryController) GetAllCategories(ctx *gin.Context) {
|
|
categories, err := c.categoryService.GetAllCategories()
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get categories", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Categories retrieved", categories)
|
|
}
|
|
|
|
// GetCategoryByID gets category by ID
|
|
// GET /api/categories/:id
|
|
func (c *CategoryController) GetCategoryByID(ctx *gin.Context) {
|
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
|
|
return
|
|
}
|
|
|
|
category, err := c.categoryService.GetCategoryByID(uint(id))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Category not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Category retrieved", category.ToResponse())
|
|
}
|
|
|
|
// CreateCategory creates a new category (admin only)
|
|
// POST /api/categories
|
|
func (c *CategoryController) CreateCategory(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
var req services.CreateCategoryRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
category, err := c.categoryService.CreateCategory(admin.ID, req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create category", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusCreated, "Category created", category.ToResponse())
|
|
}
|
|
|
|
// UpdateCategory updates a category (admin only)
|
|
// PUT /api/categories/:id
|
|
func (c *CategoryController) UpdateCategory(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req services.UpdateCategoryRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
category, err := c.categoryService.UpdateCategory(admin.ID, uint(categoryID), req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update category", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Category updated", category.ToResponse())
|
|
}
|
|
|
|
// DeleteCategory deletes a category (admin only)
|
|
// DELETE /api/categories/:id
|
|
func (c *CategoryController) DeleteCategory(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
categoryID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid category ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.categoryService.DeleteCategory(admin.ID, uint(categoryID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete category", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Category deleted", nil)
|
|
}
|
|
|
|
// internal/controllers/claim_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ClaimController struct {
|
|
claimService *services.ClaimService
|
|
verificationService *services.VerificationService
|
|
}
|
|
|
|
func NewClaimController(db *gorm.DB) *ClaimController {
|
|
return &ClaimController{
|
|
claimService: services.NewClaimService(db),
|
|
verificationService: services.NewVerificationService(db),
|
|
}
|
|
}
|
|
|
|
// GetAllClaims gets all claims
|
|
// GET /api/claims
|
|
func (c *ClaimController) GetAllClaims(ctx *gin.Context) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
status := ctx.Query("status")
|
|
|
|
var itemID, userID *uint
|
|
if itemIDStr := ctx.Query("item_id"); itemIDStr != "" {
|
|
id, _ := strconv.ParseUint(itemIDStr, 10, 32)
|
|
itemID = new(uint)
|
|
*itemID = uint(id)
|
|
}
|
|
|
|
// If regular user, only show their claims
|
|
if userObj, exists := ctx.Get("user"); exists {
|
|
user := userObj.(*models.User)
|
|
if user.IsUser() {
|
|
userID = &user.ID
|
|
}
|
|
}
|
|
|
|
claims, total, err := c.claimService.GetAllClaims(page, limit, status, itemID, userID)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
|
|
}
|
|
|
|
// GetClaimByID gets claim by ID
|
|
// GET /api/claims/:id
|
|
func (c *ClaimController) GetClaimByID(ctx *gin.Context) {
|
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
|
return
|
|
}
|
|
|
|
isManager := false
|
|
if userObj, exists := ctx.Get("user"); exists {
|
|
user := userObj.(*models.User)
|
|
isManager = user.IsManager() || user.IsAdmin()
|
|
}
|
|
|
|
claim, err := c.claimService.GetClaimByID(uint(id), isManager)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Claim not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim retrieved", claim)
|
|
}
|
|
|
|
// CreateClaim creates a new claim
|
|
// POST /api/claims
|
|
func (c *ClaimController) CreateClaim(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
var req services.CreateClaimRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
claim, err := c.claimService.CreateClaim(user.ID, req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create claim", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusCreated, "Claim created", claim.ToResponse())
|
|
}
|
|
|
|
// VerifyClaim verifies a claim (manager only)
|
|
// POST /api/claims/:id/verify
|
|
func (c *ClaimController) VerifyClaim(ctx *gin.Context) {
|
|
managerObj, _ := ctx.Get("user")
|
|
manager := managerObj.(*models.User)
|
|
|
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req services.VerifyClaimRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
// Auto-verify description similarity
|
|
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
// Verify the claim
|
|
if err := c.claimService.VerifyClaim(
|
|
manager.ID,
|
|
uint(claimID),
|
|
req,
|
|
verification.SimilarityScore,
|
|
stringSliceToString(verification.MatchedKeywords),
|
|
ipAddress,
|
|
userAgent,
|
|
); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to verify claim", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim verified", gin.H{
|
|
"verification": verification,
|
|
})
|
|
}
|
|
|
|
// GetClaimVerification gets verification data for a claim
|
|
// GET /api/claims/:id/verification
|
|
func (c *ClaimController) GetClaimVerification(ctx *gin.Context) {
|
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
|
return
|
|
}
|
|
|
|
verification, err := c.verificationService.VerifyClaimDescription(uint(claimID))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Verification failed", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Verification retrieved", verification)
|
|
}
|
|
|
|
// CloseClaim closes a claim (manager only)
|
|
// POST /api/claims/:id/close
|
|
func (c *ClaimController) CloseClaim(ctx *gin.Context) {
|
|
managerObj, _ := ctx.Get("user")
|
|
manager := managerObj.(*models.User)
|
|
|
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.claimService.CloseClaim(manager.ID, uint(claimID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to close claim", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim closed and archived", nil)
|
|
}
|
|
|
|
// DeleteClaim deletes a claim
|
|
// DELETE /api/claims/:id
|
|
func (c *ClaimController) DeleteClaim(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
claimID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid claim ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.claimService.DeleteClaim(user.ID, uint(claimID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete claim", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Claim deleted", nil)
|
|
}
|
|
|
|
// GetClaimsByUser gets claims by user
|
|
// GET /api/user/claims
|
|
func (c *ClaimController) GetClaimsByUser(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
|
|
claims, total, err := c.claimService.GetClaimsByUser(user.ID, page, limit)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get claims", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Claims retrieved", claims, total, page, limit)
|
|
}
|
|
|
|
// Helper function to convert string slice to string
|
|
func stringSliceToString(slice []string) string {
|
|
if len(slice) == 0 {
|
|
return ""
|
|
}
|
|
result := ""
|
|
for i, s := range slice {
|
|
if i > 0 {
|
|
result += ", "
|
|
}
|
|
result += s
|
|
}
|
|
return result
|
|
}
|
|
|
|
// internal/controllers/item_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ItemController struct {
|
|
itemService *services.ItemService
|
|
matchService *services.MatchService
|
|
}
|
|
|
|
func NewItemController(db *gorm.DB) *ItemController {
|
|
return &ItemController{
|
|
itemService: services.NewItemService(db),
|
|
matchService: services.NewMatchService(db),
|
|
}
|
|
}
|
|
|
|
// GetAllItems gets all items (public)
|
|
// GET /api/items
|
|
func (c *ItemController) GetAllItems(ctx *gin.Context) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
status := ctx.Query("status")
|
|
category := ctx.Query("category")
|
|
search := ctx.Query("search")
|
|
|
|
items, total, err := c.itemService.GetAllItems(page, limit, status, category, search)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", items, total, page, limit)
|
|
}
|
|
|
|
// GetItemByID gets item by ID
|
|
// GET /api/items/:id
|
|
func (c *ItemController) GetItemByID(ctx *gin.Context) {
|
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
// Check if user is manager or admin
|
|
isManager := false
|
|
if userObj, exists := ctx.Get("user"); exists {
|
|
user := userObj.(*models.User)
|
|
isManager = user.IsManager() || user.IsAdmin()
|
|
}
|
|
|
|
item, err := c.itemService.GetItemByID(uint(id), isManager)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Item not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Item retrieved", item)
|
|
}
|
|
|
|
// CreateItem creates a new item
|
|
// POST /api/items
|
|
func (c *ItemController) CreateItem(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
var req services.CreateItemRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
item, err := c.itemService.CreateItem(user.ID, req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create item", err.Error())
|
|
return
|
|
}
|
|
|
|
// Auto-match with lost items
|
|
go c.matchService.AutoMatchNewItem(item.ID)
|
|
|
|
utils.SuccessResponse(ctx, http.StatusCreated, "Item created", item.ToDetailResponse())
|
|
}
|
|
|
|
// UpdateItem updates an item
|
|
// PUT /api/items/:id
|
|
func (c *ItemController) UpdateItem(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req services.UpdateItemRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
item, err := c.itemService.UpdateItem(user.ID, uint(itemID), req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update item", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Item updated", item.ToDetailResponse())
|
|
}
|
|
|
|
// UpdateItemStatus updates item status
|
|
// PATCH /api/items/:id/status
|
|
func (c *ItemController) UpdateItemStatus(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.itemService.UpdateItemStatus(user.ID, uint(itemID), req.Status, ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Item status updated", nil)
|
|
}
|
|
|
|
// DeleteItem deletes an item
|
|
// DELETE /api/items/:id
|
|
func (c *ItemController) DeleteItem(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.itemService.DeleteItem(user.ID, uint(itemID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete item", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Item deleted", nil)
|
|
}
|
|
|
|
// GetItemsByReporter gets items by reporter
|
|
// GET /api/user/items
|
|
func (c *ItemController) GetItemsByReporter(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
|
|
items, total, err := c.itemService.GetItemsByReporter(user.ID, page, limit)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get items", err.Error())
|
|
return
|
|
}
|
|
|
|
var responses []models.ItemDetailResponse
|
|
for _, item := range items {
|
|
responses = append(responses, item.ToDetailResponse())
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Items retrieved", responses, total, page, limit)
|
|
}
|
|
|
|
// GetItemRevisionHistory gets revision history for an item
|
|
// GET /api/items/:id/revisions
|
|
func (c *ItemController) GetItemRevisionHistory(ctx *gin.Context) {
|
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
|
|
revisions, total, err := c.itemService.GetItemRevisionHistory(uint(itemID), page, limit)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get revision history", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Revision history retrieved", revisions, total, page, limit)
|
|
}
|
|
|
|
// internal/controllers/lost_item_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type LostItemController struct {
|
|
lostItemService *services.LostItemService
|
|
}
|
|
|
|
func NewLostItemController(db *gorm.DB) *LostItemController {
|
|
return &LostItemController{
|
|
lostItemService: services.NewLostItemService(db),
|
|
}
|
|
}
|
|
|
|
// GetAllLostItems gets all lost items
|
|
// GET /api/lost-items
|
|
func (c *LostItemController) GetAllLostItems(ctx *gin.Context) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
status := ctx.Query("status")
|
|
category := ctx.Query("category")
|
|
search := ctx.Query("search")
|
|
|
|
var userID *uint
|
|
// If manager/admin, can see all. If user, only see their own
|
|
if userObj, exists := ctx.Get("user"); exists {
|
|
user := userObj.(*models.User)
|
|
if user.IsUser() {
|
|
userID = &user.ID
|
|
}
|
|
}
|
|
|
|
lostItems, total, err := c.lostItemService.GetAllLostItems(page, limit, status, category, search, userID)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
|
|
}
|
|
|
|
// GetLostItemByID gets lost item by ID
|
|
// GET /api/lost-items/:id
|
|
func (c *LostItemController) GetLostItemByID(ctx *gin.Context) {
|
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
lostItem, err := c.lostItemService.GetLostItemByID(uint(id))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Lost item not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item retrieved", lostItem.ToResponse())
|
|
}
|
|
|
|
// CreateLostItem creates a new lost item report
|
|
// POST /api/lost-items
|
|
func (c *LostItemController) CreateLostItem(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
var req services.CreateLostItemRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
lostItem, err := c.lostItemService.CreateLostItem(user.ID, req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to create lost item report", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusCreated, "Lost item report created", lostItem.ToResponse())
|
|
}
|
|
|
|
// UpdateLostItem updates a lost item report
|
|
// PUT /api/lost-items/:id
|
|
func (c *LostItemController) UpdateLostItem(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req services.UpdateLostItemRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
lostItem, err := c.lostItemService.UpdateLostItem(user.ID, uint(lostItemID), req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update lost item report", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report updated", lostItem.ToResponse())
|
|
}
|
|
|
|
// UpdateLostItemStatus updates lost item status
|
|
// PATCH /api/lost-items/:id/status
|
|
func (c *LostItemController) UpdateLostItemStatus(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Status string `json:"status" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.lostItemService.UpdateLostItemStatus(user.ID, uint(lostItemID), req.Status, ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update status", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item status updated", nil)
|
|
}
|
|
|
|
// DeleteLostItem deletes a lost item report
|
|
// DELETE /api/lost-items/:id
|
|
func (c *LostItemController) DeleteLostItem(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.lostItemService.DeleteLostItem(user.ID, uint(lostItemID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete lost item report", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Lost item report deleted", nil)
|
|
}
|
|
|
|
// GetLostItemsByUser gets lost items by user
|
|
// GET /api/user/lost-items
|
|
func (c *LostItemController) GetLostItemsByUser(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
|
|
lostItems, total, err := c.lostItemService.GetLostItemsByUser(user.ID, page, limit)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get lost items", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Lost items retrieved", lostItems, total, page, limit)
|
|
}
|
|
|
|
// internal/controllers/match_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type MatchController struct {
|
|
matchService *services.MatchService
|
|
}
|
|
|
|
func NewMatchController(db *gorm.DB) *MatchController {
|
|
return &MatchController{
|
|
matchService: services.NewMatchService(db),
|
|
}
|
|
}
|
|
|
|
// FindSimilarItems finds similar items for a lost item
|
|
// POST /api/lost-items/:id/find-similar
|
|
func (c *MatchController) FindSimilarItems(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
// Only allow managers or the owner to search
|
|
// Add ownership check here if needed
|
|
|
|
results, err := c.matchService.FindSimilarItems(uint(lostItemID))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to find similar items", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Similar items found", gin.H{
|
|
"total": len(results),
|
|
"matches": results,
|
|
"user_id": user.ID,
|
|
})
|
|
}
|
|
|
|
// GetMatchesForLostItem gets all matches for a lost item
|
|
// GET /api/lost-items/:id/matches
|
|
func (c *MatchController) GetMatchesForLostItem(ctx *gin.Context) {
|
|
lostItemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid lost item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
matches, err := c.matchService.GetMatchesForLostItem(uint(lostItemID))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
|
|
}
|
|
|
|
// GetMatchesForItem gets all matches for an item
|
|
// GET /api/items/:id/matches
|
|
func (c *MatchController) GetMatchesForItem(ctx *gin.Context) {
|
|
itemID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid item ID", err.Error())
|
|
return
|
|
}
|
|
|
|
matches, err := c.matchService.GetMatchesForItem(uint(itemID))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get matches", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Matches retrieved", matches)
|
|
}
|
|
|
|
// internal/controllers/report_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ReportController struct {
|
|
exportService *services.ExportService
|
|
}
|
|
|
|
func NewReportController(db *gorm.DB) *ReportController {
|
|
return &ReportController{
|
|
exportService: services.NewExportService(db),
|
|
}
|
|
}
|
|
|
|
// ExportReport exports report based on request
|
|
// POST /api/reports/export
|
|
func (c *ReportController) ExportReport(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
var req services.ExportRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
var buffer *[]byte
|
|
var filename string
|
|
var contentType string
|
|
var err error
|
|
|
|
// Generate report based on type and format
|
|
switch req.Type {
|
|
case "items":
|
|
if req.Format == "pdf" {
|
|
buf, e := c.exportService.ExportItemsToPDF(req, user.ID, ipAddress, userAgent)
|
|
if e != nil {
|
|
err = e
|
|
} else {
|
|
data := buf.Bytes()
|
|
buffer = &data
|
|
filename = fmt.Sprintf("items_report_%s.pdf", time.Now().Format("20060102"))
|
|
contentType = "application/pdf"
|
|
}
|
|
} else {
|
|
buf, e := c.exportService.ExportItemsToExcel(req, user.ID, ipAddress, userAgent)
|
|
if e != nil {
|
|
err = e
|
|
} else {
|
|
data := buf.Bytes()
|
|
buffer = &data
|
|
filename = fmt.Sprintf("items_report_%s.xlsx", time.Now().Format("20060102"))
|
|
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
}
|
|
}
|
|
|
|
case "archives":
|
|
buf, e := c.exportService.ExportArchivesToPDF(req, user.ID, ipAddress, userAgent)
|
|
if e != nil {
|
|
err = e
|
|
} else {
|
|
data := buf.Bytes()
|
|
buffer = &data
|
|
filename = fmt.Sprintf("archives_report_%s.pdf", time.Now().Format("20060102"))
|
|
contentType = "application/pdf"
|
|
}
|
|
|
|
case "claims":
|
|
buf, e := c.exportService.ExportClaimsToPDF(req, user.ID, ipAddress, userAgent)
|
|
if e != nil {
|
|
err = e
|
|
} else {
|
|
data := buf.Bytes()
|
|
buffer = &data
|
|
filename = fmt.Sprintf("claims_report_%s.pdf", time.Now().Format("20060102"))
|
|
contentType = "application/pdf"
|
|
}
|
|
|
|
default:
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid report type", "")
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to generate report", err.Error())
|
|
return
|
|
}
|
|
|
|
// Set headers
|
|
ctx.Header("Content-Type", contentType)
|
|
ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
|
ctx.Header("Content-Length", fmt.Sprintf("%d", len(*buffer)))
|
|
|
|
// Send file
|
|
ctx.Data(http.StatusOK, contentType, *buffer)
|
|
}
|
|
|
|
// internal/controllers/user_controller.go
|
|
package controllers
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/services"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type UserController struct {
|
|
userService *services.UserService
|
|
}
|
|
|
|
func NewUserController(db *gorm.DB) *UserController {
|
|
return &UserController{
|
|
userService: services.NewUserService(db),
|
|
}
|
|
}
|
|
|
|
// GetProfile gets user profile
|
|
// GET /api/user/profile
|
|
func (c *UserController) GetProfile(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
profile, err := c.userService.GetProfile(user.ID)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "Profile not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Profile retrieved", profile.ToResponse())
|
|
}
|
|
|
|
// UpdateProfile updates user profile
|
|
// PUT /api/user/profile
|
|
func (c *UserController) UpdateProfile(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
var req services.UpdateProfileRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
updatedUser, err := c.userService.UpdateProfile(user.ID, req, ipAddress, userAgent)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Update failed", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Profile updated", updatedUser.ToResponse())
|
|
}
|
|
|
|
// ChangePassword changes user password
|
|
// POST /api/user/change-password
|
|
func (c *UserController) ChangePassword(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
var req services.ChangePasswordRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.userService.ChangePassword(user.ID, req, ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Password change failed", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Password changed successfully", nil)
|
|
}
|
|
|
|
// GetStats gets user statistics
|
|
// GET /api/user/stats
|
|
func (c *UserController) GetStats(ctx *gin.Context) {
|
|
userObj, _ := ctx.Get("user")
|
|
user := userObj.(*models.User)
|
|
|
|
stats, err := c.userService.GetUserStats(user.ID)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get stats", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "Stats retrieved", stats)
|
|
}
|
|
|
|
// GetAllUsers gets all users (admin only)
|
|
// GET /api/admin/users
|
|
func (c *UserController) GetAllUsers(ctx *gin.Context) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
|
|
|
users, total, err := c.userService.GetAllUsers(page, limit)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusInternalServerError, "Failed to get users", err.Error())
|
|
return
|
|
}
|
|
|
|
var responses []models.UserResponse
|
|
for _, user := range users {
|
|
responses = append(responses, user.ToResponse())
|
|
}
|
|
|
|
utils.SendPaginatedResponse(ctx, http.StatusOK, "Users retrieved", responses, total, page, limit)
|
|
}
|
|
|
|
// GetUserByID gets user by ID (admin only)
|
|
// GET /api/admin/users/:id
|
|
func (c *UserController) GetUserByID(ctx *gin.Context) {
|
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
|
return
|
|
}
|
|
|
|
user, err := c.userService.GetUserByID(uint(id))
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusNotFound, "User not found", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "User retrieved", user.ToResponse())
|
|
}
|
|
|
|
// UpdateUserRole updates user role (admin only)
|
|
// PATCH /api/admin/users/:id/role
|
|
func (c *UserController) UpdateUserRole(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
RoleID uint `json:"role_id" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid request data", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.userService.UpdateUserRole(admin.ID, uint(userID), req.RoleID, ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to update role", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "User role updated", nil)
|
|
}
|
|
|
|
// BlockUser blocks a user (admin only)
|
|
// POST /api/admin/users/:id/block
|
|
func (c *UserController) BlockUser(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.userService.BlockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to block user", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "User blocked", nil)
|
|
}
|
|
|
|
// UnblockUser unblocks a user (admin only)
|
|
// POST /api/admin/users/:id/unblock
|
|
func (c *UserController) UnblockUser(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.userService.UnblockUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to unblock user", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "User unblocked", nil)
|
|
}
|
|
|
|
// DeleteUser deletes a user (admin only)
|
|
// DELETE /api/admin/users/:id
|
|
func (c *UserController) DeleteUser(ctx *gin.Context) {
|
|
adminObj, _ := ctx.Get("user")
|
|
admin := adminObj.(*models.User)
|
|
|
|
userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Invalid user ID", err.Error())
|
|
return
|
|
}
|
|
|
|
ipAddress := ctx.ClientIP()
|
|
userAgent := ctx.Request.UserAgent()
|
|
|
|
if err := c.userService.DeleteUser(admin.ID, uint(userID), ipAddress, userAgent); err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusBadRequest, "Failed to delete user", err.Error())
|
|
return
|
|
}
|
|
|
|
utils.SuccessResponse(ctx, http.StatusOK, "User deleted", nil)
|
|
}
|
|
|
|
// internal/middleware/cors.go
|
|
package middleware
|
|
|
|
import (
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// CORSMiddleware handles CORS
|
|
func CORSMiddleware() gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
|
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
|
|
|
|
if ctx.Request.Method == "OPTIONS" {
|
|
ctx.AbortWithStatus(204)
|
|
return
|
|
}
|
|
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
// internal/middleware/jwt_middleware.go
|
|
package middleware
|
|
|
|
import (
|
|
"lost-and-found/internal/config"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// JWTMiddleware validates JWT token
|
|
func JWTMiddleware(db *gorm.DB) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
// Get token from Authorization header
|
|
authHeader := ctx.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authorization header required", "")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
// Check if Bearer token
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid authorization format", "")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
tokenString := parts[1]
|
|
|
|
// Validate token
|
|
claims, err := config.ValidateToken(tokenString)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Invalid or expired token", err.Error())
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
// Get user from database
|
|
userRepo := repositories.NewUserRepository(db)
|
|
user, err := userRepo.FindByID(claims.UserID)
|
|
if err != nil {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "User not found", "")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
// Check if user is blocked
|
|
if user.IsBlocked() {
|
|
utils.ErrorResponse(ctx, http.StatusForbidden, "Account is blocked", "")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
// Set user in context
|
|
ctx.Set("user", user)
|
|
ctx.Set("user_id", user.ID)
|
|
ctx.Set("user_role", user.Role.Name)
|
|
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
// OptionalJWTMiddleware validates JWT token if present (for public routes that can benefit from auth)
|
|
func OptionalJWTMiddleware(db *gorm.DB) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
authHeader := ctx.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
|
|
tokenString := parts[1]
|
|
claims, err := config.ValidateToken(tokenString)
|
|
if err != nil {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
|
|
userRepo := repositories.NewUserRepository(db)
|
|
user, err := userRepo.FindByID(claims.UserID)
|
|
if err != nil {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
|
|
if !user.IsBlocked() {
|
|
ctx.Set("user", user)
|
|
ctx.Set("user_id", user.ID)
|
|
ctx.Set("user_role", user.Role.Name)
|
|
}
|
|
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
// internal/middleware/logger.go
|
|
package middleware
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// LoggerMiddleware logs HTTP requests
|
|
func LoggerMiddleware() gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
// Start timer
|
|
startTime := time.Now()
|
|
|
|
// Process request
|
|
ctx.Next()
|
|
|
|
// Calculate latency
|
|
latency := time.Since(startTime)
|
|
|
|
// Get request info
|
|
method := ctx.Request.Method
|
|
path := ctx.Request.URL.Path
|
|
statusCode := ctx.Writer.Status()
|
|
clientIP := ctx.ClientIP()
|
|
|
|
// Get user ID if authenticated
|
|
userID := "guest"
|
|
if id, exists := ctx.Get("user_id"); exists {
|
|
userID = fmt.Sprintf("%v", id)
|
|
}
|
|
|
|
// Log format
|
|
fmt.Printf("[%s] %s | %3d | %13v | %15s | %s | User: %s\n",
|
|
time.Now().Format("2006-01-02 15:04:05"),
|
|
method,
|
|
statusCode,
|
|
latency,
|
|
clientIP,
|
|
path,
|
|
userID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// internal/middleware/rate_limiter.go
|
|
package middleware
|
|
|
|
import (
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// RateLimiter stores rate limit data
|
|
type RateLimiter struct {
|
|
visitors map[string]*Visitor
|
|
mu sync.RWMutex
|
|
rate int // requests per window
|
|
window time.Duration // time window
|
|
}
|
|
|
|
// Visitor represents a visitor's rate limit data
|
|
type Visitor struct {
|
|
lastSeen time.Time
|
|
count int
|
|
}
|
|
|
|
var limiter *RateLimiter
|
|
|
|
// InitRateLimiter initializes the rate limiter
|
|
func InitRateLimiter(rate int, window time.Duration) {
|
|
limiter = &RateLimiter{
|
|
visitors: make(map[string]*Visitor),
|
|
rate: rate,
|
|
window: window,
|
|
}
|
|
|
|
// Cleanup old visitors every minute
|
|
go limiter.cleanupVisitors()
|
|
}
|
|
|
|
// cleanupVisitors removes old visitor entries
|
|
func (rl *RateLimiter) cleanupVisitors() {
|
|
for {
|
|
time.Sleep(time.Minute)
|
|
rl.mu.Lock()
|
|
for ip, visitor := range rl.visitors {
|
|
if time.Since(visitor.lastSeen) > rl.window {
|
|
delete(rl.visitors, ip)
|
|
}
|
|
}
|
|
rl.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// getVisitor retrieves or creates a visitor
|
|
func (rl *RateLimiter) getVisitor(ip string) *Visitor {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
visitor, exists := rl.visitors[ip]
|
|
if !exists {
|
|
visitor = &Visitor{
|
|
lastSeen: time.Now(),
|
|
count: 0,
|
|
}
|
|
rl.visitors[ip] = visitor
|
|
}
|
|
|
|
return visitor
|
|
}
|
|
|
|
// isAllowed checks if request is allowed
|
|
func (rl *RateLimiter) isAllowed(ip string) bool {
|
|
visitor := rl.getVisitor(ip)
|
|
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
// Reset count if window has passed
|
|
if time.Since(visitor.lastSeen) > rl.window {
|
|
visitor.count = 0
|
|
visitor.lastSeen = time.Now()
|
|
}
|
|
|
|
// Check if limit exceeded
|
|
if visitor.count >= rl.rate {
|
|
return false
|
|
}
|
|
|
|
visitor.count++
|
|
visitor.lastSeen = time.Now()
|
|
return true
|
|
}
|
|
|
|
// RateLimiterMiddleware applies rate limiting
|
|
func RateLimiterMiddleware() gin.HandlerFunc {
|
|
// Initialize rate limiter (100 requests per minute)
|
|
if limiter == nil {
|
|
InitRateLimiter(100, time.Minute)
|
|
}
|
|
|
|
return func(ctx *gin.Context) {
|
|
ip := ctx.ClientIP()
|
|
|
|
if !limiter.isAllowed(ip) {
|
|
utils.ErrorResponse(ctx, http.StatusTooManyRequests, "Rate limit exceeded", "Too many requests, please try again later")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
// internal/middleware/role_middleware.go
|
|
package middleware
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/utils"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// RequireRole checks if user has required role
|
|
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
userObj, exists := ctx.Get("user")
|
|
if !exists {
|
|
utils.ErrorResponse(ctx, http.StatusUnauthorized, "Authentication required", "")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
user := userObj.(*models.User)
|
|
userRole := user.Role.Name
|
|
|
|
// Check if user has allowed role
|
|
hasRole := false
|
|
for _, role := range allowedRoles {
|
|
if userRole == role {
|
|
hasRole = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasRole {
|
|
utils.ErrorResponse(ctx, http.StatusForbidden, "Insufficient permissions", "")
|
|
ctx.Abort()
|
|
return
|
|
}
|
|
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
// RequireAdmin middleware (admin only)
|
|
func RequireAdmin() gin.HandlerFunc {
|
|
return RequireRole(models.RoleAdmin)
|
|
}
|
|
|
|
// RequireManager middleware (manager and admin)
|
|
func RequireManager() gin.HandlerFunc {
|
|
return RequireRole(models.RoleAdmin, models.RoleManager)
|
|
}
|
|
|
|
// RequireUser middleware (all authenticated users)
|
|
func RequireUser() gin.HandlerFunc {
|
|
return RequireRole(models.RoleAdmin, models.RoleManager, models.RoleUser)
|
|
}
|
|
|
|
// internal/models/archive.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Archive represents an archived item
|
|
type Archive struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
ItemID uint `gorm:"not null;uniqueIndex" json:"item_id"` // Original item ID
|
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
|
CategoryID uint `gorm:"not null" json:"category_id"`
|
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
|
PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"`
|
|
Location string `gorm:"type:varchar(200)" json:"location"`
|
|
Description string `gorm:"type:text" json:"description"`
|
|
DateFound time.Time `json:"date_found"`
|
|
Status string `gorm:"type:varchar(50)" json:"status"` // case_closed, expired
|
|
ReporterName string `gorm:"type:varchar(100)" json:"reporter_name"`
|
|
ReporterContact string `gorm:"type:varchar(50)" json:"reporter_contact"`
|
|
ArchivedReason string `gorm:"type:varchar(100)" json:"archived_reason"` // expired, case_closed
|
|
ClaimedBy *uint `json:"claimed_by"` // User who claimed (if applicable)
|
|
Claimer *User `gorm:"foreignKey:ClaimedBy" json:"claimer,omitempty"`
|
|
ArchivedAt time.Time `json:"archived_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for Archive model
|
|
func (Archive) TableName() string {
|
|
return "archives"
|
|
}
|
|
|
|
// Archive reason constants
|
|
const (
|
|
ArchiveReasonExpired = "expired"
|
|
ArchiveReasonCaseClosed = "case_closed"
|
|
)
|
|
|
|
// BeforeCreate hook
|
|
func (a *Archive) BeforeCreate(tx *gorm.DB) error {
|
|
if a.ArchivedAt.IsZero() {
|
|
a.ArchivedAt = time.Now()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ArchiveResponse represents archive data for API responses
|
|
type ArchiveResponse struct {
|
|
ID uint `json:"id"`
|
|
ItemID uint `json:"item_id"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
PhotoURL string `json:"photo_url"`
|
|
Location string `json:"location"`
|
|
DateFound time.Time `json:"date_found"`
|
|
Status string `json:"status"`
|
|
ArchivedReason string `json:"archived_reason"`
|
|
ClaimedBy string `json:"claimed_by,omitempty"`
|
|
ArchivedAt time.Time `json:"archived_at"`
|
|
}
|
|
|
|
// ToResponse converts Archive to ArchiveResponse
|
|
func (a *Archive) ToResponse() ArchiveResponse {
|
|
categoryName := ""
|
|
if a.Category.ID != 0 {
|
|
categoryName = a.Category.Name
|
|
}
|
|
|
|
claimedByName := ""
|
|
if a.Claimer != nil && a.Claimer.ID != 0 {
|
|
claimedByName = a.Claimer.Name
|
|
}
|
|
|
|
return ArchiveResponse{
|
|
ID: a.ID,
|
|
ItemID: a.ItemID,
|
|
Name: a.Name,
|
|
Category: categoryName,
|
|
PhotoURL: a.PhotoURL,
|
|
Location: a.Location,
|
|
DateFound: a.DateFound,
|
|
Status: a.Status,
|
|
ArchivedReason: a.ArchivedReason,
|
|
ClaimedBy: claimedByName,
|
|
ArchivedAt: a.ArchivedAt,
|
|
}
|
|
}
|
|
|
|
// CreateFromItem creates an Archive from an Item
|
|
func CreateFromItem(item *Item, reason string, claimedBy *uint) *Archive {
|
|
return &Archive{
|
|
ItemID: item.ID,
|
|
Name: item.Name,
|
|
CategoryID: item.CategoryID,
|
|
PhotoURL: item.PhotoURL,
|
|
Location: item.Location,
|
|
Description: item.Description,
|
|
DateFound: item.DateFound,
|
|
Status: item.Status,
|
|
ReporterName: item.ReporterName,
|
|
ReporterContact: item.ReporterContact,
|
|
ArchivedReason: reason,
|
|
ClaimedBy: claimedBy,
|
|
ArchivedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// internal/models/audit_log.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AuditLog represents an audit log entry
|
|
type AuditLog struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
UserID *uint `json:"user_id"` // Nullable for system actions
|
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
|
Action string `gorm:"type:varchar(50);not null" json:"action"` // create, update, delete, verify, login, etc.
|
|
EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, user, etc.
|
|
EntityID *uint `json:"entity_id"`
|
|
Details string `gorm:"type:text" json:"details"`
|
|
IPAddress string `gorm:"type:varchar(50)" json:"ip_address"`
|
|
UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for AuditLog model
|
|
func (AuditLog) TableName() string {
|
|
return "audit_logs"
|
|
}
|
|
|
|
// Action constants
|
|
const (
|
|
ActionCreate = "create"
|
|
ActionUpdate = "update"
|
|
ActionDelete = "delete"
|
|
ActionVerify = "verify"
|
|
ActionLogin = "login"
|
|
ActionLogout = "logout"
|
|
ActionBlock = "block"
|
|
ActionUnblock = "unblock"
|
|
ActionApprove = "approve"
|
|
ActionReject = "reject"
|
|
ActionExport = "export"
|
|
)
|
|
|
|
// Entity type constants
|
|
const (
|
|
EntityItem = "item"
|
|
EntityLostItem = "lost_item"
|
|
EntityClaim = "claim"
|
|
EntityUser = "user"
|
|
EntityCategory = "category"
|
|
EntityArchive = "archive"
|
|
)
|
|
|
|
// AuditLogResponse represents audit log data for API responses
|
|
type AuditLogResponse struct {
|
|
ID uint `json:"id"`
|
|
UserName string `json:"user_name,omitempty"`
|
|
Action string `json:"action"`
|
|
EntityType string `json:"entity_type"`
|
|
EntityID *uint `json:"entity_id"`
|
|
Details string `json:"details"`
|
|
IPAddress string `json:"ip_address"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts AuditLog to AuditLogResponse
|
|
func (a *AuditLog) ToResponse() AuditLogResponse {
|
|
userName := "System"
|
|
if a.User != nil && a.User.ID != 0 {
|
|
userName = a.User.Name
|
|
}
|
|
|
|
return AuditLogResponse{
|
|
ID: a.ID,
|
|
UserName: userName,
|
|
Action: a.Action,
|
|
EntityType: a.EntityType,
|
|
EntityID: a.EntityID,
|
|
Details: a.Details,
|
|
IPAddress: a.IPAddress,
|
|
CreatedAt: a.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// CreateAuditLog creates a new audit log entry
|
|
func CreateAuditLog(db *gorm.DB, userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
|
|
log := &AuditLog{
|
|
UserID: userID,
|
|
Action: action,
|
|
EntityType: entityType,
|
|
EntityID: entityID,
|
|
Details: details,
|
|
IPAddress: ipAddress,
|
|
UserAgent: userAgent,
|
|
}
|
|
|
|
return db.Create(log).Error
|
|
}
|
|
|
|
// internal/models/category.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Category represents an item category
|
|
type Category struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
|
Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"`
|
|
Description string `gorm:"type:text" json:"description"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Relationships
|
|
Items []Item `gorm:"foreignKey:CategoryID" json:"items,omitempty"`
|
|
LostItems []LostItem `gorm:"foreignKey:CategoryID" json:"lost_items,omitempty"`
|
|
}
|
|
|
|
// TableName specifies the table name for Category model
|
|
func (Category) TableName() string {
|
|
return "categories"
|
|
}
|
|
|
|
// CategoryResponse represents category data for API responses
|
|
type CategoryResponse struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
ItemCount int64 `json:"item_count,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts Category to CategoryResponse
|
|
func (c *Category) ToResponse() CategoryResponse {
|
|
return CategoryResponse{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
Slug: c.Slug,
|
|
Description: c.Description,
|
|
CreatedAt: c.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// internal/models/claim_verification.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ClaimVerification represents verification data for a claim
|
|
type ClaimVerification struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
ClaimID uint `gorm:"not null;uniqueIndex" json:"claim_id"`
|
|
Claim Claim `gorm:"foreignKey:ClaimID" json:"claim,omitempty"`
|
|
SimilarityScore float64 `gorm:"type:decimal(5,2);default:0" json:"similarity_score"` // Percentage match (0-100)
|
|
MatchedKeywords string `gorm:"type:text" json:"matched_keywords"` // Keywords that matched
|
|
VerificationNotes string `gorm:"type:text" json:"verification_notes"` // Manager's notes
|
|
IsAutoMatched bool `gorm:"default:false" json:"is_auto_matched"` // Was it auto-matched?
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for ClaimVerification model
|
|
func (ClaimVerification) TableName() string {
|
|
return "claim_verifications"
|
|
}
|
|
|
|
// IsHighMatch checks if similarity score is high (>= 70%)
|
|
func (cv *ClaimVerification) IsHighMatch() bool {
|
|
return cv.SimilarityScore >= 70.0
|
|
}
|
|
|
|
// IsMediumMatch checks if similarity score is medium (50-69%)
|
|
func (cv *ClaimVerification) IsMediumMatch() bool {
|
|
return cv.SimilarityScore >= 50.0 && cv.SimilarityScore < 70.0
|
|
}
|
|
|
|
// IsLowMatch checks if similarity score is low (< 50%)
|
|
func (cv *ClaimVerification) IsLowMatch() bool {
|
|
return cv.SimilarityScore < 50.0
|
|
}
|
|
|
|
// GetMatchLevel returns the match level as string
|
|
func (cv *ClaimVerification) GetMatchLevel() string {
|
|
if cv.IsHighMatch() {
|
|
return "high"
|
|
} else if cv.IsMediumMatch() {
|
|
return "medium"
|
|
}
|
|
return "low"
|
|
}
|
|
|
|
// ClaimVerificationResponse represents verification data for API responses
|
|
type ClaimVerificationResponse struct {
|
|
ID uint `json:"id"`
|
|
ClaimID uint `json:"claim_id"`
|
|
SimilarityScore float64 `json:"similarity_score"`
|
|
MatchLevel string `json:"match_level"`
|
|
MatchedKeywords string `json:"matched_keywords"`
|
|
VerificationNotes string `json:"verification_notes"`
|
|
IsAutoMatched bool `json:"is_auto_matched"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts ClaimVerification to ClaimVerificationResponse
|
|
func (cv *ClaimVerification) ToResponse() ClaimVerificationResponse {
|
|
return ClaimVerificationResponse{
|
|
ID: cv.ID,
|
|
ClaimID: cv.ClaimID,
|
|
SimilarityScore: cv.SimilarityScore,
|
|
MatchLevel: cv.GetMatchLevel(),
|
|
MatchedKeywords: cv.MatchedKeywords,
|
|
VerificationNotes: cv.VerificationNotes,
|
|
IsAutoMatched: cv.IsAutoMatched,
|
|
CreatedAt: cv.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// internal/models/claim.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Claim represents a claim for a found item
|
|
type Claim struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
ItemID uint `gorm:"not null" json:"item_id"`
|
|
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
|
|
UserID uint `gorm:"not null" json:"user_id"`
|
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
|
Description string `gorm:"type:text;not null" json:"description"` // User's description of the item
|
|
ProofURL string `gorm:"type:varchar(255)" json:"proof_url"` // Optional proof photo
|
|
Contact string `gorm:"type:varchar(50);not null" json:"contact"`
|
|
Status string `gorm:"type:varchar(50);default:'pending'" json:"status"` // pending, approved, rejected
|
|
Notes string `gorm:"type:text" json:"notes"` // Manager's notes (approval/rejection reason)
|
|
VerifiedAt *time.Time `json:"verified_at"`
|
|
VerifiedBy *uint `json:"verified_by"` // Manager who verified
|
|
Verifier *User `gorm:"foreignKey:VerifiedBy" json:"verifier,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Relationships
|
|
Verification *ClaimVerification `gorm:"foreignKey:ClaimID" json:"verification,omitempty"`
|
|
}
|
|
|
|
// TableName specifies the table name for Claim model
|
|
func (Claim) TableName() string {
|
|
return "claims"
|
|
}
|
|
|
|
// Claim status constants
|
|
const (
|
|
ClaimStatusPending = "pending"
|
|
ClaimStatusApproved = "approved"
|
|
ClaimStatusRejected = "rejected"
|
|
)
|
|
|
|
// BeforeCreate hook
|
|
func (c *Claim) BeforeCreate(tx *gorm.DB) error {
|
|
if c.Status == "" {
|
|
c.Status = ClaimStatusPending
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsPending checks if claim is pending
|
|
func (c *Claim) IsPending() bool {
|
|
return c.Status == ClaimStatusPending
|
|
}
|
|
|
|
// IsApproved checks if claim is approved
|
|
func (c *Claim) IsApproved() bool {
|
|
return c.Status == ClaimStatusApproved
|
|
}
|
|
|
|
// IsRejected checks if claim is rejected
|
|
func (c *Claim) IsRejected() bool {
|
|
return c.Status == ClaimStatusRejected
|
|
}
|
|
|
|
// Approve approves the claim
|
|
func (c *Claim) Approve(verifierID uint, notes string) {
|
|
c.Status = ClaimStatusApproved
|
|
c.VerifiedBy = &verifierID
|
|
now := time.Now()
|
|
c.VerifiedAt = &now
|
|
c.Notes = notes
|
|
}
|
|
|
|
// Reject rejects the claim
|
|
func (c *Claim) Reject(verifierID uint, notes string) {
|
|
c.Status = ClaimStatusRejected
|
|
c.VerifiedBy = &verifierID
|
|
now := time.Now()
|
|
c.VerifiedAt = &now
|
|
c.Notes = notes
|
|
}
|
|
|
|
// ClaimResponse represents claim data for API responses
|
|
type ClaimResponse struct {
|
|
ID uint `json:"id"`
|
|
ItemID uint `json:"item_id"`
|
|
ItemName string `json:"item_name"`
|
|
UserID uint `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
Description string `json:"description"`
|
|
ProofURL string `json:"proof_url"`
|
|
Contact string `json:"contact"`
|
|
Status string `json:"status"`
|
|
Notes string `json:"notes"`
|
|
MatchPercentage *float64 `json:"match_percentage,omitempty"`
|
|
VerifiedAt *time.Time `json:"verified_at"`
|
|
VerifiedBy *uint `json:"verified_by"`
|
|
VerifierName string `json:"verifier_name,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts Claim to ClaimResponse
|
|
func (c *Claim) ToResponse() ClaimResponse {
|
|
itemName := ""
|
|
if c.Item.ID != 0 {
|
|
itemName = c.Item.Name
|
|
}
|
|
|
|
userName := ""
|
|
if c.User.ID != 0 {
|
|
userName = c.User.Name
|
|
}
|
|
|
|
verifierName := ""
|
|
if c.Verifier != nil && c.Verifier.ID != 0 {
|
|
verifierName = c.Verifier.Name
|
|
}
|
|
|
|
var matchPercentage *float64
|
|
if c.Verification != nil {
|
|
matchPercentage = &c.Verification.SimilarityScore
|
|
}
|
|
|
|
return ClaimResponse{
|
|
ID: c.ID,
|
|
ItemID: c.ItemID,
|
|
ItemName: itemName,
|
|
UserID: c.UserID,
|
|
UserName: userName,
|
|
Description: c.Description,
|
|
ProofURL: c.ProofURL,
|
|
Contact: c.Contact,
|
|
Status: c.Status,
|
|
Notes: c.Notes,
|
|
MatchPercentage: matchPercentage,
|
|
VerifiedAt: c.VerifiedAt,
|
|
VerifiedBy: c.VerifiedBy,
|
|
VerifierName: verifierName,
|
|
CreatedAt: c.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// ClaimDetailResponse includes item description for verification
|
|
type ClaimDetailResponse struct {
|
|
ClaimResponse
|
|
ItemDescription string `json:"item_description"` // Original item description for comparison
|
|
}
|
|
|
|
// ToDetailResponse converts Claim to ClaimDetailResponse
|
|
func (c *Claim) ToDetailResponse() ClaimDetailResponse {
|
|
baseResponse := c.ToResponse()
|
|
|
|
itemDescription := ""
|
|
if c.Item.ID != 0 {
|
|
itemDescription = c.Item.Description
|
|
}
|
|
|
|
return ClaimDetailResponse{
|
|
ClaimResponse: baseResponse,
|
|
ItemDescription: itemDescription,
|
|
}
|
|
}
|
|
|
|
// internal/models/item.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Item represents a found item
|
|
type Item struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
|
CategoryID uint `gorm:"not null" json:"category_id"`
|
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
|
PhotoURL string `gorm:"type:varchar(255)" json:"photo_url"`
|
|
Location string `gorm:"type:varchar(200);not null" json:"location"`
|
|
Description string `gorm:"type:text;not null" json:"description"` // Keunikan (rahasia)
|
|
DateFound time.Time `gorm:"not null" json:"date_found"`
|
|
Status string `gorm:"type:varchar(50);default:'unclaimed'" json:"status"` // unclaimed, pending_claim, verified, case_closed, expired
|
|
ReporterID uint `gorm:"not null" json:"reporter_id"`
|
|
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
|
ReporterName string `gorm:"type:varchar(100);not null" json:"reporter_name"`
|
|
ReporterContact string `gorm:"type:varchar(50);not null" json:"reporter_contact"`
|
|
ExpiresAt *time.Time `json:"expires_at"` // Auto-expire after 90 days
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Relationships
|
|
Claims []Claim `gorm:"foreignKey:ItemID" json:"claims,omitempty"`
|
|
MatchResults []MatchResult `gorm:"foreignKey:ItemID" json:"match_results,omitempty"`
|
|
RevisionLogs []RevisionLog `gorm:"foreignKey:ItemID" json:"revision_logs,omitempty"`
|
|
}
|
|
|
|
// TableName specifies the table name for Item model
|
|
func (Item) TableName() string {
|
|
return "items"
|
|
}
|
|
|
|
// Status constants
|
|
const (
|
|
ItemStatusUnclaimed = "unclaimed"
|
|
ItemStatusPendingClaim = "pending_claim"
|
|
ItemStatusVerified = "verified"
|
|
ItemStatusCaseClosed = "case_closed"
|
|
ItemStatusExpired = "expired"
|
|
)
|
|
|
|
// BeforeCreate hook to set expiration date
|
|
func (i *Item) BeforeCreate(tx *gorm.DB) error {
|
|
// Set default status
|
|
if i.Status == "" {
|
|
i.Status = ItemStatusUnclaimed
|
|
}
|
|
|
|
// Set expiration date (90 days from date found)
|
|
if i.ExpiresAt == nil {
|
|
expiresAt := i.DateFound.AddDate(0, 0, 90) // Add 90 days
|
|
i.ExpiresAt = &expiresAt
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsExpired checks if item has expired
|
|
func (i *Item) IsExpired() bool {
|
|
if i.ExpiresAt == nil {
|
|
return false
|
|
}
|
|
return time.Now().After(*i.ExpiresAt)
|
|
}
|
|
|
|
// CanBeClaimed checks if item can be claimed
|
|
func (i *Item) CanBeClaimed() bool {
|
|
return i.Status == ItemStatusUnclaimed && !i.IsExpired()
|
|
}
|
|
|
|
// CanBeEdited checks if item can be edited
|
|
func (i *Item) CanBeEdited() bool {
|
|
// Cannot edit if case is closed or expired
|
|
return i.Status != ItemStatusCaseClosed && i.Status != ItemStatusExpired
|
|
}
|
|
|
|
// ItemPublicResponse represents item data for public view (without sensitive info)
|
|
type ItemPublicResponse struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
PhotoURL string `json:"photo_url"`
|
|
Location string `json:"location"`
|
|
DateFound time.Time `json:"date_found"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToPublicResponse converts Item to ItemPublicResponse (hides description, reporter details)
|
|
func (i *Item) ToPublicResponse() ItemPublicResponse {
|
|
categoryName := ""
|
|
if i.Category.ID != 0 {
|
|
categoryName = i.Category.Name
|
|
}
|
|
|
|
return ItemPublicResponse{
|
|
ID: i.ID,
|
|
Name: i.Name,
|
|
Category: categoryName,
|
|
PhotoURL: i.PhotoURL,
|
|
Location: i.Location,
|
|
DateFound: i.DateFound,
|
|
Status: i.Status,
|
|
CreatedAt: i.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// ItemDetailResponse represents full item data for authorized users
|
|
type ItemDetailResponse struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
PhotoURL string `json:"photo_url"`
|
|
Location string `json:"location"`
|
|
Description string `json:"description"`
|
|
DateFound time.Time `json:"date_found"`
|
|
Status string `json:"status"`
|
|
ReporterName string `json:"reporter_name"`
|
|
ReporterContact string `json:"reporter_contact"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToDetailResponse converts Item to ItemDetailResponse (includes all info)
|
|
func (i *Item) ToDetailResponse() ItemDetailResponse {
|
|
categoryName := ""
|
|
if i.Category.ID != 0 {
|
|
categoryName = i.Category.Name
|
|
}
|
|
|
|
return ItemDetailResponse{
|
|
ID: i.ID,
|
|
Name: i.Name,
|
|
Category: categoryName,
|
|
PhotoURL: i.PhotoURL,
|
|
Location: i.Location,
|
|
Description: i.Description,
|
|
DateFound: i.DateFound,
|
|
Status: i.Status,
|
|
ReporterName: i.ReporterName,
|
|
ReporterContact: i.ReporterContact,
|
|
ExpiresAt: i.ExpiresAt,
|
|
CreatedAt: i.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// internal/models/lost_item.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// LostItem represents a lost item report
|
|
type LostItem struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
UserID uint `gorm:"not null" json:"user_id"`
|
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
|
CategoryID uint `gorm:"not null" json:"category_id"`
|
|
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
|
Color string `gorm:"type:varchar(50)" json:"color"`
|
|
Location string `gorm:"type:varchar(200)" json:"location"` // Optional
|
|
Description string `gorm:"type:text;not null" json:"description"`
|
|
DateLost time.Time `gorm:"not null" json:"date_lost"`
|
|
Status string `gorm:"type:varchar(50);default:'active'" json:"status"` // active, found, expired
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Relationships
|
|
MatchResults []MatchResult `gorm:"foreignKey:LostItemID" json:"match_results,omitempty"`
|
|
}
|
|
|
|
// TableName specifies the table name for LostItem model
|
|
func (LostItem) TableName() string {
|
|
return "lost_items"
|
|
}
|
|
|
|
// LostItem status constants
|
|
const (
|
|
LostItemStatusActive = "active"
|
|
LostItemStatusFound = "found"
|
|
LostItemStatusExpired = "expired"
|
|
)
|
|
|
|
// BeforeCreate hook
|
|
func (l *LostItem) BeforeCreate(tx *gorm.DB) error {
|
|
if l.Status == "" {
|
|
l.Status = LostItemStatusActive
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsActive checks if lost item is still active
|
|
func (l *LostItem) IsActive() bool {
|
|
return l.Status == LostItemStatusActive
|
|
}
|
|
|
|
// LostItemResponse represents lost item data for API responses
|
|
type LostItemResponse struct {
|
|
ID uint `json:"id"`
|
|
UserName string `json:"user_name"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
Color string `json:"color"`
|
|
Location string `json:"location"`
|
|
Description string `json:"description"`
|
|
DateLost time.Time `json:"date_lost"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts LostItem to LostItemResponse
|
|
func (l *LostItem) ToResponse() LostItemResponse {
|
|
userName := ""
|
|
if l.User.ID != 0 {
|
|
userName = l.User.Name
|
|
}
|
|
|
|
categoryName := ""
|
|
if l.Category.ID != 0 {
|
|
categoryName = l.Category.Name
|
|
}
|
|
|
|
return LostItemResponse{
|
|
ID: l.ID,
|
|
UserName: userName,
|
|
Name: l.Name,
|
|
Category: categoryName,
|
|
Color: l.Color,
|
|
Location: l.Location,
|
|
Description: l.Description,
|
|
DateLost: l.DateLost,
|
|
Status: l.Status,
|
|
CreatedAt: l.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// internal/models/match_result.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// MatchResult represents auto-matching result between lost item and found item
|
|
type MatchResult struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
LostItemID uint `gorm:"not null" json:"lost_item_id"`
|
|
LostItem LostItem `gorm:"foreignKey:LostItemID" json:"lost_item,omitempty"`
|
|
ItemID uint `gorm:"not null" json:"item_id"`
|
|
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
|
|
SimilarityScore float64 `gorm:"type:decimal(5,2)" json:"similarity_score"` // Percentage match (0-100)
|
|
MatchedFields string `gorm:"type:text" json:"matched_fields"` // JSON of matched fields
|
|
MatchedAt time.Time `gorm:"not null" json:"matched_at"`
|
|
IsNotified bool `gorm:"default:false" json:"is_notified"` // Was user notified?
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for MatchResult model
|
|
func (MatchResult) TableName() string {
|
|
return "match_results"
|
|
}
|
|
|
|
// BeforeCreate hook
|
|
func (mr *MatchResult) BeforeCreate(tx *gorm.DB) error {
|
|
if mr.MatchedAt.IsZero() {
|
|
mr.MatchedAt = time.Now()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsHighMatch checks if similarity score is high (>= 70%)
|
|
func (mr *MatchResult) IsHighMatch() bool {
|
|
return mr.SimilarityScore >= 70.0
|
|
}
|
|
|
|
// IsMediumMatch checks if similarity score is medium (50-69%)
|
|
func (mr *MatchResult) IsMediumMatch() bool {
|
|
return mr.SimilarityScore >= 50.0 && mr.SimilarityScore < 70.0
|
|
}
|
|
|
|
// IsLowMatch checks if similarity score is low (< 50%)
|
|
func (mr *MatchResult) IsLowMatch() bool {
|
|
return mr.SimilarityScore < 50.0
|
|
}
|
|
|
|
// GetMatchLevel returns the match level as string
|
|
func (mr *MatchResult) GetMatchLevel() string {
|
|
if mr.IsHighMatch() {
|
|
return "high"
|
|
} else if mr.IsMediumMatch() {
|
|
return "medium"
|
|
}
|
|
return "low"
|
|
}
|
|
|
|
// MatchResultResponse represents match result data for API responses
|
|
type MatchResultResponse struct {
|
|
ID uint `json:"id"`
|
|
LostItemID uint `json:"lost_item_id"`
|
|
LostItemName string `json:"lost_item_name"`
|
|
ItemID uint `json:"item_id"`
|
|
ItemName string `json:"item_name"`
|
|
ItemPhotoURL string `json:"item_photo_url"`
|
|
ItemLocation string `json:"item_location"`
|
|
ItemDateFound time.Time `json:"item_date_found"`
|
|
ItemStatus string `json:"item_status"`
|
|
SimilarityScore float64 `json:"similarity_score"`
|
|
MatchLevel string `json:"match_level"`
|
|
MatchedFields string `json:"matched_fields"`
|
|
MatchedAt time.Time `json:"matched_at"`
|
|
IsNotified bool `json:"is_notified"`
|
|
}
|
|
|
|
// ToResponse converts MatchResult to MatchResultResponse
|
|
func (mr *MatchResult) ToResponse() MatchResultResponse {
|
|
lostItemName := ""
|
|
if mr.LostItem.ID != 0 {
|
|
lostItemName = mr.LostItem.Name
|
|
}
|
|
|
|
itemName := ""
|
|
itemPhotoURL := ""
|
|
itemLocation := ""
|
|
itemDateFound := time.Time{}
|
|
itemStatus := ""
|
|
if mr.Item.ID != 0 {
|
|
itemName = mr.Item.Name
|
|
itemPhotoURL = mr.Item.PhotoURL
|
|
itemLocation = mr.Item.Location
|
|
itemDateFound = mr.Item.DateFound
|
|
itemStatus = mr.Item.Status
|
|
}
|
|
|
|
return MatchResultResponse{
|
|
ID: mr.ID,
|
|
LostItemID: mr.LostItemID,
|
|
LostItemName: lostItemName,
|
|
ItemID: mr.ItemID,
|
|
ItemName: itemName,
|
|
ItemPhotoURL: itemPhotoURL,
|
|
ItemLocation: itemLocation,
|
|
ItemDateFound: itemDateFound,
|
|
ItemStatus: itemStatus,
|
|
SimilarityScore: mr.SimilarityScore,
|
|
MatchLevel: mr.GetMatchLevel(),
|
|
MatchedFields: mr.MatchedFields,
|
|
MatchedAt: mr.MatchedAt,
|
|
IsNotified: mr.IsNotified,
|
|
}
|
|
}
|
|
|
|
// ItemMatchResponse represents simplified item data for matching display
|
|
type ItemMatchResponse struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
PhotoURL string `json:"photo_url"`
|
|
Location string `json:"location"`
|
|
DateFound time.Time `json:"date_found"`
|
|
Status string `json:"status"`
|
|
Similarity float64 `json:"similarity"`
|
|
}
|
|
|
|
// internal/models/notification.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Notification represents a notification for a user
|
|
type Notification struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
UserID uint `gorm:"not null" json:"user_id"`
|
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
|
Type string `gorm:"type:varchar(50);not null" json:"type"` // match_found, claim_approved, claim_rejected, item_expired, etc.
|
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
|
Message string `gorm:"type:text;not null" json:"message"`
|
|
EntityType string `gorm:"type:varchar(50)" json:"entity_type"` // item, claim, match, etc.
|
|
EntityID *uint `json:"entity_id"`
|
|
IsRead bool `gorm:"default:false" json:"is_read"`
|
|
ReadAt *time.Time `json:"read_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for Notification model
|
|
func (Notification) TableName() string {
|
|
return "notifications"
|
|
}
|
|
|
|
// Notification type constants
|
|
const (
|
|
NotificationMatchFound = "match_found"
|
|
NotificationClaimApproved = "claim_approved"
|
|
NotificationClaimRejected = "claim_rejected"
|
|
NotificationItemExpired = "item_expired"
|
|
NotificationNewClaim = "new_claim"
|
|
NotificationItemReturned = "item_returned"
|
|
)
|
|
|
|
// MarkAsRead marks the notification as read
|
|
func (n *Notification) MarkAsRead() {
|
|
n.IsRead = true
|
|
now := time.Now()
|
|
n.ReadAt = &now
|
|
}
|
|
|
|
// NotificationResponse represents notification data for API responses
|
|
type NotificationResponse struct {
|
|
ID uint `json:"id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
EntityType string `json:"entity_type"`
|
|
EntityID *uint `json:"entity_id"`
|
|
IsRead bool `json:"is_read"`
|
|
ReadAt *time.Time `json:"read_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts Notification to NotificationResponse
|
|
func (n *Notification) ToResponse() NotificationResponse {
|
|
return NotificationResponse{
|
|
ID: n.ID,
|
|
Type: n.Type,
|
|
Title: n.Title,
|
|
Message: n.Message,
|
|
EntityType: n.EntityType,
|
|
EntityID: n.EntityID,
|
|
IsRead: n.IsRead,
|
|
ReadAt: n.ReadAt,
|
|
CreatedAt: n.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// CreateNotification creates a new notification
|
|
func CreateNotification(db *gorm.DB, userID uint, notifType, title, message, entityType string, entityID *uint) error {
|
|
notification := &Notification{
|
|
UserID: userID,
|
|
Type: notifType,
|
|
Title: title,
|
|
Message: message,
|
|
EntityType: entityType,
|
|
EntityID: entityID,
|
|
}
|
|
|
|
return db.Create(notification).Error
|
|
}
|
|
|
|
// CreateMatchNotification creates a notification for match found
|
|
func CreateMatchNotification(db *gorm.DB, userID uint, itemName string, matchID uint) error {
|
|
return CreateNotification(
|
|
db,
|
|
userID,
|
|
NotificationMatchFound,
|
|
"Barang yang Mirip Ditemukan!",
|
|
"Kami menemukan barang yang mirip dengan laporan kehilangan Anda: "+itemName,
|
|
"match",
|
|
&matchID,
|
|
)
|
|
}
|
|
|
|
// CreateClaimApprovedNotification creates a notification for approved claim
|
|
func CreateClaimApprovedNotification(db *gorm.DB, userID uint, itemName string, claimID uint) error {
|
|
return CreateNotification(
|
|
db,
|
|
userID,
|
|
NotificationClaimApproved,
|
|
"Klaim Disetujui!",
|
|
"Klaim Anda untuk barang '"+itemName+"' telah disetujui. Silakan ambil barang di tempat yang ditentukan.",
|
|
"claim",
|
|
&claimID,
|
|
)
|
|
}
|
|
|
|
// CreateClaimRejectedNotification creates a notification for rejected claim
|
|
func CreateClaimRejectedNotification(db *gorm.DB, userID uint, itemName, reason string, claimID uint) error {
|
|
return CreateNotification(
|
|
db,
|
|
userID,
|
|
NotificationClaimRejected,
|
|
"Klaim Ditolak",
|
|
"Klaim Anda untuk barang '"+itemName+"' ditolak. Alasan: "+reason,
|
|
"claim",
|
|
&claimID,
|
|
)
|
|
}
|
|
|
|
// internal/models/revision_log.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// RevisionLog represents revision history for item edits
|
|
type RevisionLog struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
ItemID uint `gorm:"not null" json:"item_id"`
|
|
Item Item `gorm:"foreignKey:ItemID" json:"item,omitempty"`
|
|
UserID uint `gorm:"not null" json:"user_id"` // Who made the edit
|
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
|
FieldName string `gorm:"type:varchar(50);not null" json:"field_name"` // Which field was edited
|
|
OldValue string `gorm:"type:text" json:"old_value"`
|
|
NewValue string `gorm:"type:text" json:"new_value"`
|
|
Reason string `gorm:"type:text" json:"reason"` // Why was it edited
|
|
CreatedAt time.Time `json:"created_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// TableName specifies the table name for RevisionLog model
|
|
func (RevisionLog) TableName() string {
|
|
return "revision_logs"
|
|
}
|
|
|
|
// RevisionLogResponse represents revision log data for API responses
|
|
type RevisionLogResponse struct {
|
|
ID uint `json:"id"`
|
|
ItemID uint `json:"item_id"`
|
|
UserName string `json:"user_name"`
|
|
FieldName string `json:"field_name"`
|
|
OldValue string `json:"old_value"`
|
|
NewValue string `json:"new_value"`
|
|
Reason string `json:"reason"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts RevisionLog to RevisionLogResponse
|
|
func (rl *RevisionLog) ToResponse() RevisionLogResponse {
|
|
userName := ""
|
|
if rl.User.ID != 0 {
|
|
userName = rl.User.Name
|
|
}
|
|
|
|
return RevisionLogResponse{
|
|
ID: rl.ID,
|
|
ItemID: rl.ItemID,
|
|
UserName: userName,
|
|
FieldName: rl.FieldName,
|
|
OldValue: rl.OldValue,
|
|
NewValue: rl.NewValue,
|
|
Reason: rl.Reason,
|
|
CreatedAt: rl.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// CreateRevisionLog creates a new revision log entry
|
|
func CreateRevisionLog(db *gorm.DB, itemID, userID uint, fieldName, oldValue, newValue, reason string) error {
|
|
log := &RevisionLog{
|
|
ItemID: itemID,
|
|
UserID: userID,
|
|
FieldName: fieldName,
|
|
OldValue: oldValue,
|
|
NewValue: newValue,
|
|
Reason: reason,
|
|
}
|
|
|
|
return db.Create(log).Error
|
|
}
|
|
|
|
// internal/models/role.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Role represents a user role in the system
|
|
type Role struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"`
|
|
Description string `gorm:"type:text" json:"description"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Relationships
|
|
Users []User `gorm:"foreignKey:RoleID" json:"users,omitempty"`
|
|
}
|
|
|
|
// TableName specifies the table name for Role model
|
|
func (Role) TableName() string {
|
|
return "roles"
|
|
}
|
|
|
|
// Role constants
|
|
const (
|
|
RoleAdmin = "admin"
|
|
RoleManager = "manager"
|
|
RoleUser = "user"
|
|
)
|
|
|
|
// GetRoleID returns the ID for a given role name
|
|
func GetRoleID(db *gorm.DB, roleName string) (uint, error) {
|
|
var role Role
|
|
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return role.ID, nil
|
|
}
|
|
|
|
// IsValidRole checks if a role name is valid
|
|
func IsValidRole(roleName string) bool {
|
|
validRoles := []string{RoleAdmin, RoleManager, RoleUser}
|
|
for _, r := range validRoles {
|
|
if r == roleName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// internal/models/user.go
|
|
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// User represents a user in the system
|
|
type User struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
|
Email string `gorm:"type:varchar(100);uniqueIndex;not null" json:"email"`
|
|
Password string `gorm:"type:varchar(255);not null" json:"-"` // Hide password in JSON
|
|
NRP string `gorm:"type:varchar(20);uniqueIndex" json:"nrp"`
|
|
Phone string `gorm:"type:varchar(20)" json:"phone"`
|
|
RoleID uint `gorm:"not null;default:3" json:"role_id"` // Default to user role
|
|
Role Role `gorm:"foreignKey:RoleID" json:"role,omitempty"`
|
|
Status string `gorm:"type:varchar(20);default:'active'" json:"status"` // active, blocked
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Relationships
|
|
Items []Item `gorm:"foreignKey:ReporterID" json:"items,omitempty"`
|
|
LostItems []LostItem `gorm:"foreignKey:UserID" json:"lost_items,omitempty"`
|
|
Claims []Claim `gorm:"foreignKey:UserID" json:"claims,omitempty"`
|
|
}
|
|
|
|
// TableName specifies the table name for User model
|
|
func (User) TableName() string {
|
|
return "users"
|
|
}
|
|
|
|
// BeforeCreate hook to validate user data
|
|
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
|
// Set default role if not specified
|
|
if u.RoleID == 0 {
|
|
u.RoleID = 3 // Default to user role
|
|
}
|
|
|
|
// Set default status
|
|
if u.Status == "" {
|
|
u.Status = "active"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsAdmin checks if user is admin
|
|
func (u *User) IsAdmin() bool {
|
|
return u.Role.Name == "admin"
|
|
}
|
|
|
|
// IsManager checks if user is manager
|
|
func (u *User) IsManager() bool {
|
|
return u.Role.Name == "manager"
|
|
}
|
|
|
|
// IsUser checks if user is regular user
|
|
func (u *User) IsUser() bool {
|
|
return u.Role.Name == "user"
|
|
}
|
|
|
|
// IsActive checks if user is active
|
|
func (u *User) IsActive() bool {
|
|
return u.Status == "active"
|
|
}
|
|
|
|
// IsBlocked checks if user is blocked
|
|
func (u *User) IsBlocked() bool {
|
|
return u.Status == "blocked"
|
|
}
|
|
|
|
// UserResponse represents user data for API responses (without sensitive info)
|
|
type UserResponse struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
NRP string `json:"nrp"`
|
|
Phone string `json:"phone"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ToResponse converts User to UserResponse
|
|
func (u *User) ToResponse() UserResponse {
|
|
roleName := ""
|
|
if u.Role.ID != 0 {
|
|
roleName = u.Role.Name
|
|
}
|
|
|
|
return UserResponse{
|
|
ID: u.ID,
|
|
Name: u.Name,
|
|
Email: u.Email,
|
|
NRP: u.NRP,
|
|
Phone: u.Phone,
|
|
Role: roleName,
|
|
Status: u.Status,
|
|
CreatedAt: u.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// internal/repositories/archive_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ArchiveRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewArchiveRepository(db *gorm.DB) *ArchiveRepository {
|
|
return &ArchiveRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new archive record
|
|
func (r *ArchiveRepository) Create(archive *models.Archive) error {
|
|
return r.db.Create(archive).Error
|
|
}
|
|
|
|
// FindByID finds archive by ID
|
|
func (r *ArchiveRepository) FindByID(id uint) (*models.Archive, error) {
|
|
var archive models.Archive
|
|
err := r.db.Preload("Category").Preload("Claimer").Preload("Claimer.Role").First(&archive, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("archive not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &archive, nil
|
|
}
|
|
|
|
// FindAll returns all archived items with filters
|
|
func (r *ArchiveRepository) FindAll(page, limit int, reason, search string) ([]models.Archive, int64, error) {
|
|
var archives []models.Archive
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.Archive{})
|
|
|
|
// Apply filters
|
|
if reason != "" {
|
|
query = query.Where("archived_reason = ?", reason)
|
|
}
|
|
if search != "" {
|
|
query = query.Where("name ILIKE ? OR location ILIKE ?", "%"+search+"%", "%"+search+"%")
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Category").Preload("Claimer").Preload("Claimer.Role").
|
|
Order("archived_at DESC").
|
|
Offset(offset).Limit(limit).Find(&archives).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return archives, total, nil
|
|
}
|
|
|
|
// FindByItemID finds archive by original item ID
|
|
func (r *ArchiveRepository) FindByItemID(itemID uint) (*models.Archive, error) {
|
|
var archive models.Archive
|
|
err := r.db.Where("item_id = ?", itemID).Preload("Category").Preload("Claimer").First(&archive).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("archive not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &archive, nil
|
|
}
|
|
|
|
// Delete permanently deletes an archive
|
|
func (r *ArchiveRepository) Delete(id uint) error {
|
|
return r.db.Unscoped().Delete(&models.Archive{}, id).Error
|
|
}
|
|
|
|
// CountByReason counts archives by reason
|
|
func (r *ArchiveRepository) CountByReason(reason string) (int64, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.Archive{}).Where("archived_reason = ?", reason).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// internal/repositories/audit_log_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AuditLogRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewAuditLogRepository(db *gorm.DB) *AuditLogRepository {
|
|
return &AuditLogRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new audit log
|
|
func (r *AuditLogRepository) Create(log *models.AuditLog) error {
|
|
return r.db.Create(log).Error
|
|
}
|
|
|
|
// FindByID finds audit log by ID
|
|
func (r *AuditLogRepository) FindByID(id uint) (*models.AuditLog, error) {
|
|
var log models.AuditLog
|
|
err := r.db.Preload("User").Preload("User.Role").First(&log, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("audit log not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &log, nil
|
|
}
|
|
|
|
// FindAll returns all audit logs with filters
|
|
func (r *AuditLogRepository) FindAll(page, limit int, action, entityType string, userID *uint) ([]models.AuditLog, int64, error) {
|
|
var logs []models.AuditLog
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.AuditLog{})
|
|
|
|
// Apply filters
|
|
if action != "" {
|
|
query = query.Where("action = ?", action)
|
|
}
|
|
if entityType != "" {
|
|
query = query.Where("entity_type = ?", entityType)
|
|
}
|
|
if userID != nil {
|
|
query = query.Where("user_id = ?", *userID)
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("User").Preload("User.Role").
|
|
Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(&logs).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return logs, total, nil
|
|
}
|
|
|
|
// FindByUser finds audit logs by user
|
|
func (r *AuditLogRepository) FindByUser(userID uint, page, limit int) ([]models.AuditLog, int64, error) {
|
|
return r.FindAll(page, limit, "", "", &userID)
|
|
}
|
|
|
|
// FindByEntity finds audit logs by entity
|
|
func (r *AuditLogRepository) FindByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLog, int64, error) {
|
|
var logs []models.AuditLog
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.AuditLog{}).
|
|
Where("entity_type = ? AND entity_id = ?", entityType, entityID)
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("User").Preload("User.Role").
|
|
Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(&logs).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return logs, total, nil
|
|
}
|
|
|
|
// Log creates a new audit log entry (helper method)
|
|
func (r *AuditLogRepository) Log(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
|
|
return models.CreateAuditLog(r.db, userID, action, entityType, entityID, details, ipAddress, userAgent)
|
|
}
|
|
|
|
// internal/repositories/category_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type CategoryRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
|
return &CategoryRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new category
|
|
func (r *CategoryRepository) Create(category *models.Category) error {
|
|
return r.db.Create(category).Error
|
|
}
|
|
|
|
// FindByID finds category by ID
|
|
func (r *CategoryRepository) FindByID(id uint) (*models.Category, error) {
|
|
var category models.Category
|
|
err := r.db.First(&category, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("category not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &category, nil
|
|
}
|
|
|
|
// FindBySlug finds category by slug
|
|
func (r *CategoryRepository) FindBySlug(slug string) (*models.Category, error) {
|
|
var category models.Category
|
|
err := r.db.Where("slug = ?", slug).First(&category).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("category not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &category, nil
|
|
}
|
|
|
|
// FindAll returns all categories
|
|
func (r *CategoryRepository) FindAll() ([]models.Category, error) {
|
|
var categories []models.Category
|
|
err := r.db.Order("name ASC").Find(&categories).Error
|
|
return categories, err
|
|
}
|
|
|
|
// Update updates category data
|
|
func (r *CategoryRepository) Update(category *models.Category) error {
|
|
return r.db.Save(category).Error
|
|
}
|
|
|
|
// Delete soft deletes a category
|
|
func (r *CategoryRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.Category{}, id).Error
|
|
}
|
|
|
|
// GetCategoryWithItemCount gets category with item count
|
|
func (r *CategoryRepository) GetCategoryWithItemCount(id uint) (*models.Category, int64, error) {
|
|
category, err := r.FindByID(id)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var count int64
|
|
if err := r.db.Model(&models.Item{}).Where("category_id = ?", id).Count(&count).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return category, count, nil
|
|
}
|
|
|
|
// GetAllWithItemCount gets all categories with item counts
|
|
func (r *CategoryRepository) GetAllWithItemCount() ([]models.CategoryResponse, error) {
|
|
var categories []models.Category
|
|
if err := r.db.Order("name ASC").Find(&categories).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var responses []models.CategoryResponse
|
|
for _, cat := range categories {
|
|
var count int64
|
|
if err := r.db.Model(&models.Item{}).Where("category_id = ?", cat.ID).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response := cat.ToResponse()
|
|
response.ItemCount = count
|
|
responses = append(responses, response)
|
|
}
|
|
|
|
return responses, nil
|
|
}
|
|
|
|
// internal/repositories/claim_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ClaimRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewClaimRepository(db *gorm.DB) *ClaimRepository {
|
|
return &ClaimRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new claim
|
|
func (r *ClaimRepository) Create(claim *models.Claim) error {
|
|
return r.db.Create(claim).Error
|
|
}
|
|
|
|
// FindByID finds claim by ID
|
|
func (r *ClaimRepository) FindByID(id uint) (*models.Claim, error) {
|
|
var claim models.Claim
|
|
err := r.db.Preload("Item").Preload("Item.Category").
|
|
Preload("User").Preload("User.Role").
|
|
Preload("Verifier").Preload("Verifier.Role").
|
|
Preload("Verification").
|
|
First(&claim, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("claim not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &claim, nil
|
|
}
|
|
|
|
// FindAll returns all claims with filters
|
|
func (r *ClaimRepository) FindAll(page, limit int, status string, itemID, userID *uint) ([]models.Claim, int64, error) {
|
|
var claims []models.Claim
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.Claim{})
|
|
|
|
// Apply filters
|
|
if status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if itemID != nil {
|
|
query = query.Where("item_id = ?", *itemID)
|
|
}
|
|
if userID != nil {
|
|
query = query.Where("user_id = ?", *userID)
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Item").Preload("Item.Category").
|
|
Preload("User").Preload("User.Role").
|
|
Preload("Verifier").Preload("Verifier.Role").
|
|
Preload("Verification").
|
|
Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(&claims).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return claims, total, nil
|
|
}
|
|
|
|
// Update updates claim data
|
|
func (r *ClaimRepository) Update(claim *models.Claim) error {
|
|
return r.db.Save(claim).Error
|
|
}
|
|
|
|
// Delete soft deletes a claim
|
|
func (r *ClaimRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.Claim{}, id).Error
|
|
}
|
|
|
|
// CheckExistingClaim checks if user already claimed an item
|
|
func (r *ClaimRepository) CheckExistingClaim(userID, itemID uint) (bool, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.Claim{}).
|
|
Where("user_id = ? AND item_id = ? AND status != ?", userID, itemID, models.ClaimStatusRejected).
|
|
Count(&count).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// FindByItem finds claims for an item
|
|
func (r *ClaimRepository) FindByItem(itemID uint) ([]models.Claim, error) {
|
|
var claims []models.Claim
|
|
err := r.db.Where("item_id = ?", itemID).
|
|
Preload("User").Preload("User.Role").
|
|
Preload("Verification").
|
|
Order("created_at DESC").Find(&claims).Error
|
|
return claims, err
|
|
}
|
|
|
|
// FindByUser finds claims by user
|
|
func (r *ClaimRepository) FindByUser(userID uint, page, limit int) ([]models.Claim, int64, error) {
|
|
var claims []models.Claim
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.Claim{}).Where("user_id = ?", userID)
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Item").Preload("Item.Category").
|
|
Preload("Verification").
|
|
Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(&claims).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return claims, total, nil
|
|
}
|
|
|
|
// CountByStatus counts claims by status
|
|
func (r *ClaimRepository) CountByStatus(status string) (int64, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.Claim{}).Where("status = ?", status).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// FindPendingClaims finds all pending claims
|
|
func (r *ClaimRepository) FindPendingClaims(page, limit int) ([]models.Claim, int64, error) {
|
|
return r.FindAll(page, limit, models.ClaimStatusPending, nil, nil)
|
|
}
|
|
|
|
// internal/repositories/claim_verification_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ClaimVerificationRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewClaimVerificationRepository(db *gorm.DB) *ClaimVerificationRepository {
|
|
return &ClaimVerificationRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new claim verification
|
|
func (r *ClaimVerificationRepository) Create(verification *models.ClaimVerification) error {
|
|
return r.db.Create(verification).Error
|
|
}
|
|
|
|
// FindByID finds claim verification by ID
|
|
func (r *ClaimVerificationRepository) FindByID(id uint) (*models.ClaimVerification, error) {
|
|
var verification models.ClaimVerification
|
|
err := r.db.Preload("Claim").First(&verification, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("claim verification not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &verification, nil
|
|
}
|
|
|
|
// FindByClaimID finds claim verification by claim ID
|
|
func (r *ClaimVerificationRepository) FindByClaimID(claimID uint) (*models.ClaimVerification, error) {
|
|
var verification models.ClaimVerification
|
|
err := r.db.Where("claim_id = ?", claimID).Preload("Claim").First(&verification).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil // Return nil if not found (not an error)
|
|
}
|
|
return nil, err
|
|
}
|
|
return &verification, nil
|
|
}
|
|
|
|
// Update updates claim verification
|
|
func (r *ClaimVerificationRepository) Update(verification *models.ClaimVerification) error {
|
|
return r.db.Save(verification).Error
|
|
}
|
|
|
|
// Delete deletes a claim verification
|
|
func (r *ClaimVerificationRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.ClaimVerification{}, id).Error
|
|
}
|
|
|
|
// FindHighMatches finds high match verifications (>= 70%)
|
|
func (r *ClaimVerificationRepository) FindHighMatches() ([]models.ClaimVerification, error) {
|
|
var verifications []models.ClaimVerification
|
|
err := r.db.Where("similarity_score >= ?", 70.0).
|
|
Preload("Claim").Preload("Claim.Item").Preload("Claim.User").
|
|
Order("similarity_score DESC").Find(&verifications).Error
|
|
return verifications, err
|
|
}
|
|
|
|
// internal/repositories/item_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ItemRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewItemRepository(db *gorm.DB) *ItemRepository {
|
|
return &ItemRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new item
|
|
func (r *ItemRepository) Create(item *models.Item) error {
|
|
return r.db.Create(item).Error
|
|
}
|
|
|
|
// FindByID finds item by ID
|
|
func (r *ItemRepository) FindByID(id uint) (*models.Item, error) {
|
|
var item models.Item
|
|
err := r.db.Preload("Category").Preload("Reporter").Preload("Reporter.Role").First(&item, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("item not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
// FindAll returns all items with filters
|
|
func (r *ItemRepository) FindAll(page, limit int, status, category, search string) ([]models.Item, int64, error) {
|
|
var items []models.Item
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.Item{})
|
|
|
|
// Apply filters
|
|
if status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if category != "" {
|
|
query = query.Joins("JOIN categories ON categories.id = items.category_id").Where("categories.slug = ?", category)
|
|
}
|
|
if search != "" {
|
|
query = query.Where("name ILIKE ? OR location ILIKE ?", "%"+search+"%", "%"+search+"%")
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Category").Preload("Reporter").Preload("Reporter.Role").
|
|
Order("date_found DESC").
|
|
Offset(offset).Limit(limit).Find(&items).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return items, total, nil
|
|
}
|
|
|
|
// Update updates item data
|
|
func (r *ItemRepository) Update(item *models.Item) error {
|
|
return r.db.Save(item).Error
|
|
}
|
|
|
|
// UpdateStatus updates item status
|
|
func (r *ItemRepository) UpdateStatus(id uint, status string) error {
|
|
return r.db.Model(&models.Item{}).Where("id = ?", id).Update("status", status).Error
|
|
}
|
|
|
|
// Delete soft deletes an item
|
|
func (r *ItemRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.Item{}, id).Error
|
|
}
|
|
|
|
// FindExpired finds expired items
|
|
func (r *ItemRepository) FindExpired() ([]models.Item, error) {
|
|
var items []models.Item
|
|
now := time.Now()
|
|
err := r.db.Where("expires_at <= ? AND status = ?", now, models.ItemStatusUnclaimed).
|
|
Preload("Category").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// ArchiveItem moves item to archive
|
|
func (r *ItemRepository) ArchiveItem(item *models.Item, reason string, claimedBy *uint) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
// Create archive record
|
|
archive := models.CreateFromItem(item, reason, claimedBy)
|
|
if err := tx.Create(archive).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update item status
|
|
if err := tx.Model(item).Updates(map[string]interface{}{
|
|
"status": models.ItemStatusExpired,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// CountByStatus counts items by status
|
|
func (r *ItemRepository) CountByStatus(status string) (int64, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.Item{}).Where("status = ?", status).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// FindByReporter finds items by reporter ID
|
|
func (r *ItemRepository) FindByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
|
|
var items []models.Item
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.Item{}).Where("reporter_id = ?", reporterID)
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Category").Order("date_found DESC").
|
|
Offset(offset).Limit(limit).Find(&items).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return items, total, nil
|
|
}
|
|
|
|
// SearchForMatching searches items for matching with lost items
|
|
func (r *ItemRepository) SearchForMatching(categoryID uint, name, color string) ([]models.Item, error) {
|
|
var items []models.Item
|
|
|
|
query := r.db.Where("status = ? AND category_id = ?", models.ItemStatusUnclaimed, categoryID)
|
|
|
|
if name != "" {
|
|
query = query.Where("name ILIKE ?", "%"+name+"%")
|
|
}
|
|
|
|
err := query.Preload("Category").Order("date_found DESC").Limit(10).Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// internal/repositories/lost_item_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type LostItemRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewLostItemRepository(db *gorm.DB) *LostItemRepository {
|
|
return &LostItemRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new lost item report
|
|
func (r *LostItemRepository) Create(lostItem *models.LostItem) error {
|
|
return r.db.Create(lostItem).Error
|
|
}
|
|
|
|
// FindByID finds lost item by ID
|
|
func (r *LostItemRepository) FindByID(id uint) (*models.LostItem, error) {
|
|
var lostItem models.LostItem
|
|
err := r.db.Preload("Category").Preload("User").Preload("User.Role").First(&lostItem, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("lost item not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &lostItem, nil
|
|
}
|
|
|
|
// FindAll returns all lost items with filters
|
|
func (r *LostItemRepository) FindAll(page, limit int, status, category, search string, userID *uint) ([]models.LostItem, int64, error) {
|
|
var lostItems []models.LostItem
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.LostItem{})
|
|
|
|
// Filter by user if specified
|
|
if userID != nil {
|
|
query = query.Where("user_id = ?", *userID)
|
|
}
|
|
|
|
// Apply filters
|
|
if status != "" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if category != "" {
|
|
query = query.Joins("JOIN categories ON categories.id = lost_items.category_id").Where("categories.slug = ?", category)
|
|
}
|
|
if search != "" {
|
|
query = query.Where("name ILIKE ? OR description ILIKE ?", "%"+search+"%", "%"+search+"%")
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Category").Preload("User").Preload("User.Role").
|
|
Order("date_lost DESC").
|
|
Offset(offset).Limit(limit).Find(&lostItems).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return lostItems, total, nil
|
|
}
|
|
|
|
// Update updates lost item data
|
|
func (r *LostItemRepository) Update(lostItem *models.LostItem) error {
|
|
return r.db.Save(lostItem).Error
|
|
}
|
|
|
|
// UpdateStatus updates lost item status
|
|
func (r *LostItemRepository) UpdateStatus(id uint, status string) error {
|
|
return r.db.Model(&models.LostItem{}).Where("id = ?", id).Update("status", status).Error
|
|
}
|
|
|
|
// Delete soft deletes a lost item
|
|
func (r *LostItemRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.LostItem{}, id).Error
|
|
}
|
|
|
|
// FindByUser finds lost items by user ID
|
|
func (r *LostItemRepository) FindByUser(userID uint, page, limit int) ([]models.LostItem, int64, error) {
|
|
var lostItems []models.LostItem
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.LostItem{}).Where("user_id = ?", userID)
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Category").Order("date_lost DESC").
|
|
Offset(offset).Limit(limit).Find(&lostItems).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return lostItems, total, nil
|
|
}
|
|
|
|
// CountByStatus counts lost items by status
|
|
func (r *LostItemRepository) CountByStatus(status string) (int64, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.LostItem{}).Where("status = ?", status).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// FindActiveForMatching finds active lost items for matching
|
|
func (r *LostItemRepository) FindActiveForMatching(categoryID uint) ([]models.LostItem, error) {
|
|
var lostItems []models.LostItem
|
|
err := r.db.Where("status = ? AND category_id = ?", models.LostItemStatusActive, categoryID).
|
|
Preload("User").Find(&lostItems).Error
|
|
return lostItems, err
|
|
}
|
|
|
|
// internal/repositories/match_result_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type MatchResultRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewMatchResultRepository(db *gorm.DB) *MatchResultRepository {
|
|
return &MatchResultRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new match result
|
|
func (r *MatchResultRepository) Create(match *models.MatchResult) error {
|
|
return r.db.Create(match).Error
|
|
}
|
|
|
|
// FindByID finds match result by ID
|
|
func (r *MatchResultRepository) FindByID(id uint) (*models.MatchResult, error) {
|
|
var match models.MatchResult
|
|
err := r.db.Preload("LostItem").Preload("LostItem.User").
|
|
Preload("Item").Preload("Item.Category").
|
|
First(&match, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("match result not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &match, nil
|
|
}
|
|
|
|
// FindAll returns all match results with filters
|
|
func (r *MatchResultRepository) FindAll(page, limit int, lostItemID, itemID *uint) ([]models.MatchResult, int64, error) {
|
|
var matches []models.MatchResult
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.MatchResult{})
|
|
|
|
// Apply filters
|
|
if lostItemID != nil {
|
|
query = query.Where("lost_item_id = ?", *lostItemID)
|
|
}
|
|
if itemID != nil {
|
|
query = query.Where("item_id = ?", *itemID)
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("LostItem").Preload("LostItem.User").
|
|
Preload("Item").Preload("Item.Category").
|
|
Order("similarity_score DESC").
|
|
Offset(offset).Limit(limit).Find(&matches).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return matches, total, nil
|
|
}
|
|
|
|
// FindByLostItem finds match results for a lost item
|
|
func (r *MatchResultRepository) FindByLostItem(lostItemID uint) ([]models.MatchResult, error) {
|
|
var matches []models.MatchResult
|
|
err := r.db.Where("lost_item_id = ?", lostItemID).
|
|
Preload("Item").Preload("Item.Category").
|
|
Order("similarity_score DESC").Find(&matches).Error
|
|
return matches, err
|
|
}
|
|
|
|
// FindByItem finds match results for an item
|
|
func (r *MatchResultRepository) FindByItem(itemID uint) ([]models.MatchResult, error) {
|
|
var matches []models.MatchResult
|
|
err := r.db.Where("item_id = ?", itemID).
|
|
Preload("LostItem").Preload("LostItem.User").
|
|
Order("similarity_score DESC").Find(&matches).Error
|
|
return matches, err
|
|
}
|
|
|
|
// Update updates match result
|
|
func (r *MatchResultRepository) Update(match *models.MatchResult) error {
|
|
return r.db.Save(match).Error
|
|
}
|
|
|
|
// MarkAsNotified marks match result as notified
|
|
func (r *MatchResultRepository) MarkAsNotified(id uint) error {
|
|
return r.db.Model(&models.MatchResult{}).Where("id = ?", id).Update("is_notified", true).Error
|
|
}
|
|
|
|
// FindUnnotifiedMatches finds match results that haven't been notified
|
|
func (r *MatchResultRepository) FindUnnotifiedMatches() ([]models.MatchResult, error) {
|
|
var matches []models.MatchResult
|
|
err := r.db.Where("is_notified = ?", false).
|
|
Preload("LostItem").Preload("LostItem.User").
|
|
Preload("Item").Preload("Item.Category").
|
|
Order("matched_at ASC").Find(&matches).Error
|
|
return matches, err
|
|
}
|
|
|
|
// Delete deletes a match result
|
|
func (r *MatchResultRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.MatchResult{}, id).Error
|
|
}
|
|
|
|
// CheckExistingMatch checks if a match already exists
|
|
func (r *MatchResultRepository) CheckExistingMatch(lostItemID, itemID uint) (bool, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.MatchResult{}).
|
|
Where("lost_item_id = ? AND item_id = ?", lostItemID, itemID).
|
|
Count(&count).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// internal/repositories/notification_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type NotificationRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewNotificationRepository(db *gorm.DB) *NotificationRepository {
|
|
return &NotificationRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new notification
|
|
func (r *NotificationRepository) Create(notification *models.Notification) error {
|
|
return r.db.Create(notification).Error
|
|
}
|
|
|
|
// FindByID finds notification by ID
|
|
func (r *NotificationRepository) FindByID(id uint) (*models.Notification, error) {
|
|
var notification models.Notification
|
|
err := r.db.Preload("User").First(¬ification, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("notification not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return ¬ification, nil
|
|
}
|
|
|
|
// FindByUser finds notifications for a user
|
|
func (r *NotificationRepository) FindByUser(userID uint, page, limit int, onlyUnread bool) ([]models.Notification, int64, error) {
|
|
var notifications []models.Notification
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID)
|
|
|
|
// Filter unread if specified
|
|
if onlyUnread {
|
|
query = query.Where("is_read = ?", false)
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(¬ifications).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return notifications, total, nil
|
|
}
|
|
|
|
// MarkAsRead marks a notification as read
|
|
func (r *NotificationRepository) MarkAsRead(id uint) error {
|
|
notification, err := r.FindByID(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
notification.MarkAsRead()
|
|
return r.db.Save(notification).Error
|
|
}
|
|
|
|
// MarkAllAsRead marks all notifications for a user as read
|
|
func (r *NotificationRepository) MarkAllAsRead(userID uint) error {
|
|
return r.db.Model(&models.Notification{}).
|
|
Where("user_id = ? AND is_read = ?", userID, false).
|
|
Update("is_read", true).Error
|
|
}
|
|
|
|
// Delete deletes a notification
|
|
func (r *NotificationRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.Notification{}, id).Error
|
|
}
|
|
|
|
// DeleteAllForUser deletes all notifications for a user
|
|
func (r *NotificationRepository) DeleteAllForUser(userID uint) error {
|
|
return r.db.Where("user_id = ?", userID).Delete(&models.Notification{}).Error
|
|
}
|
|
|
|
// CountUnread counts unread notifications for a user
|
|
func (r *NotificationRepository) CountUnread(userID uint) (int64, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.Notification{}).
|
|
Where("user_id = ? AND is_read = ?", userID, false).
|
|
Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// Notify creates a notification (helper method)
|
|
func (r *NotificationRepository) Notify(userID uint, notifType, title, message, entityType string, entityID *uint) error {
|
|
return models.CreateNotification(r.db, userID, notifType, title, message, entityType, entityID)
|
|
}
|
|
|
|
// internal/repositories/revision_log_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type RevisionLogRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewRevisionLogRepository(db *gorm.DB) *RevisionLogRepository {
|
|
return &RevisionLogRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new revision log
|
|
func (r *RevisionLogRepository) Create(log *models.RevisionLog) error {
|
|
return r.db.Create(log).Error
|
|
}
|
|
|
|
// FindByID finds revision log by ID
|
|
func (r *RevisionLogRepository) FindByID(id uint) (*models.RevisionLog, error) {
|
|
var log models.RevisionLog
|
|
err := r.db.Preload("Item").Preload("User").Preload("User.Role").First(&log, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("revision log not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &log, nil
|
|
}
|
|
|
|
// FindByItem finds revision logs for an item
|
|
func (r *RevisionLogRepository) FindByItem(itemID uint, page, limit int) ([]models.RevisionLog, int64, error) {
|
|
var logs []models.RevisionLog
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.RevisionLog{}).Where("item_id = ?", itemID)
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("User").Preload("User.Role").
|
|
Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(&logs).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return logs, total, nil
|
|
}
|
|
|
|
// FindAll returns all revision logs with filters
|
|
func (r *RevisionLogRepository) FindAll(page, limit int, userID *uint) ([]models.RevisionLog, int64, error) {
|
|
var logs []models.RevisionLog
|
|
var total int64
|
|
|
|
query := r.db.Model(&models.RevisionLog{})
|
|
|
|
// Apply filters
|
|
if userID != nil {
|
|
query = query.Where("user_id = ?", *userID)
|
|
}
|
|
|
|
// Count total
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := query.Preload("Item").Preload("User").Preload("User.Role").
|
|
Order("created_at DESC").
|
|
Offset(offset).Limit(limit).Find(&logs).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return logs, total, nil
|
|
}
|
|
|
|
// Log creates a new revision log entry (helper method)
|
|
func (r *RevisionLogRepository) Log(itemID, userID uint, fieldName, oldValue, newValue, reason string) error {
|
|
return models.CreateRevisionLog(r.db, itemID, userID, fieldName, oldValue, newValue, reason)
|
|
}
|
|
|
|
// internal/repositories/role_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type RoleRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewRoleRepository(db *gorm.DB) *RoleRepository {
|
|
return &RoleRepository{db: db}
|
|
}
|
|
|
|
// FindAll returns all roles
|
|
func (r *RoleRepository) FindAll() ([]models.Role, error) {
|
|
var roles []models.Role
|
|
err := r.db.Find(&roles).Error
|
|
return roles, err
|
|
}
|
|
|
|
// FindByID finds role by ID
|
|
func (r *RoleRepository) FindByID(id uint) (*models.Role, error) {
|
|
var role models.Role
|
|
err := r.db.First(&role, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("role not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &role, nil
|
|
}
|
|
|
|
// FindByName finds role by name
|
|
func (r *RoleRepository) FindByName(name string) (*models.Role, error) {
|
|
var role models.Role
|
|
err := r.db.Where("name = ?", name).First(&role).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("role not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &role, nil
|
|
}
|
|
|
|
// Create creates a new role
|
|
func (r *RoleRepository) Create(role *models.Role) error {
|
|
return r.db.Create(role).Error
|
|
}
|
|
|
|
// Update updates role data
|
|
func (r *RoleRepository) Update(role *models.Role) error {
|
|
return r.db.Save(role).Error
|
|
}
|
|
|
|
// Delete deletes a role
|
|
func (r *RoleRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.Role{}, id).Error
|
|
}
|
|
|
|
// internal/repositories/user_repo.go
|
|
package repositories
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type UserRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
|
return &UserRepository{db: db}
|
|
}
|
|
|
|
// Create creates a new user
|
|
func (r *UserRepository) Create(user *models.User) error {
|
|
return r.db.Create(user).Error
|
|
}
|
|
|
|
// FindByID finds user by ID
|
|
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
|
var user models.User
|
|
err := r.db.Preload("Role").First(&user, id).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("user not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindByEmail finds user by email
|
|
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
|
|
var user models.User
|
|
err := r.db.Preload("Role").Where("email = ?", email).First(&user).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, errors.New("user not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindByNRP finds user by NRP
|
|
func (r *UserRepository) FindByNRP(nrp string) (*models.User, error) {
|
|
var user models.User
|
|
err := r.db.Preload("Role").Where("nrp = ?", nrp).First(&user).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// FindAll returns all users
|
|
func (r *UserRepository) FindAll(page, limit int) ([]models.User, int64, error) {
|
|
var users []models.User
|
|
var total int64
|
|
|
|
// Count total
|
|
if err := r.db.Model(&models.User{}).Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get paginated results
|
|
offset := (page - 1) * limit
|
|
err := r.db.Preload("Role").Offset(offset).Limit(limit).Find(&users).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return users, total, nil
|
|
}
|
|
|
|
// Update updates user data
|
|
func (r *UserRepository) Update(user *models.User) error {
|
|
return r.db.Save(user).Error
|
|
}
|
|
|
|
// UpdateRole updates user role
|
|
func (r *UserRepository) UpdateRole(userID, roleID uint) error {
|
|
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("role_id", roleID).Error
|
|
}
|
|
|
|
// UpdateStatus updates user status
|
|
func (r *UserRepository) UpdateStatus(userID uint, status string) error {
|
|
return r.db.Model(&models.User{}).Where("id = ?", userID).Update("status", status).Error
|
|
}
|
|
|
|
// Delete soft deletes a user
|
|
func (r *UserRepository) Delete(id uint) error {
|
|
return r.db.Delete(&models.User{}, id).Error
|
|
}
|
|
|
|
// BlockUser blocks a user
|
|
func (r *UserRepository) BlockUser(id uint) error {
|
|
return r.UpdateStatus(id, "blocked")
|
|
}
|
|
|
|
// UnblockUser unblocks a user
|
|
func (r *UserRepository) UnblockUser(id uint) error {
|
|
return r.UpdateStatus(id, "active")
|
|
}
|
|
|
|
// CountByRole counts users by role
|
|
func (r *UserRepository) CountByRole(roleID uint) (int64, error) {
|
|
var count int64
|
|
err := r.db.Model(&models.User{}).Where("role_id = ?", roleID).Count(&count).Error
|
|
return count, err
|
|
}
|
|
|
|
// GetUserStats gets user statistics
|
|
func (r *UserRepository) GetUserStats(userID uint) (map[string]interface{}, error) {
|
|
var stats map[string]interface{} = make(map[string]interface{})
|
|
|
|
// Count items reported
|
|
var itemCount int64
|
|
if err := r.db.Model(&models.Item{}).Where("reporter_id = ?", userID).Count(&itemCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats["items_reported"] = itemCount
|
|
|
|
// Count lost items reported
|
|
var lostItemCount int64
|
|
if err := r.db.Model(&models.LostItem{}).Where("user_id = ?", userID).Count(&lostItemCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats["lost_items_reported"] = lostItemCount
|
|
|
|
// Count claims made
|
|
var claimCount int64
|
|
if err := r.db.Model(&models.Claim{}).Where("user_id = ?", userID).Count(&claimCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats["claims_made"] = claimCount
|
|
|
|
// Count approved claims
|
|
var approvedClaimCount int64
|
|
if err := r.db.Model(&models.Claim{}).Where("user_id = ? AND status = ?", userID, models.ClaimStatusApproved).Count(&approvedClaimCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats["claims_approved"] = approvedClaimCount
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// internal/routes/routes.go
|
|
package routes
|
|
|
|
import (
|
|
"lost-and-found/internal/controllers"
|
|
"lost-and-found/internal/middleware"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SetupRoutes configures all application routes
|
|
func SetupRoutes(router *gin.Engine, db *gorm.DB) {
|
|
// Initialize controllers
|
|
authController := controllers.NewAuthController(db)
|
|
userController := controllers.NewUserController(db)
|
|
itemController := controllers.NewItemController(db)
|
|
lostItemController := controllers.NewLostItemController(db)
|
|
claimController := controllers.NewClaimController(db)
|
|
matchController := controllers.NewMatchController(db)
|
|
categoryController := controllers.NewCategoryController(db)
|
|
archiveController := controllers.NewArchiveController(db)
|
|
adminController := controllers.NewAdminController(db)
|
|
reportController := controllers.NewReportController(db)
|
|
|
|
// API group
|
|
api := router.Group("/api")
|
|
{
|
|
// Public routes (no authentication required)
|
|
api.POST("/register", authController.Register)
|
|
api.POST("/login", authController.Login)
|
|
api.POST("/refresh-token", authController.RefreshToken)
|
|
|
|
// Public categories
|
|
api.GET("/categories", categoryController.GetAllCategories)
|
|
api.GET("/categories/:id", categoryController.GetCategoryByID)
|
|
|
|
// Public items (read-only, limited info)
|
|
api.GET("/items", itemController.GetAllItems)
|
|
api.GET("/items/:id", itemController.GetItemByID)
|
|
|
|
// Authenticated routes (all users)
|
|
authenticated := api.Group("")
|
|
authenticated.Use(middleware.JWTMiddleware(db))
|
|
authenticated.Use(middleware.RequireUser())
|
|
{
|
|
// User profile
|
|
authenticated.GET("/me", authController.GetMe)
|
|
authenticated.GET("/user/profile", userController.GetProfile)
|
|
authenticated.PUT("/user/profile", userController.UpdateProfile)
|
|
authenticated.POST("/user/change-password", userController.ChangePassword)
|
|
authenticated.GET("/user/stats", userController.GetStats)
|
|
|
|
// User items
|
|
authenticated.GET("/user/items", itemController.GetItemsByReporter)
|
|
authenticated.POST("/items", itemController.CreateItem)
|
|
|
|
// User lost items
|
|
authenticated.GET("/user/lost-items", lostItemController.GetLostItemsByUser)
|
|
authenticated.GET("/lost-items", lostItemController.GetAllLostItems)
|
|
authenticated.GET("/lost-items/:id", lostItemController.GetLostItemByID)
|
|
authenticated.POST("/lost-items", lostItemController.CreateLostItem)
|
|
authenticated.PUT("/lost-items/:id", lostItemController.UpdateLostItem)
|
|
authenticated.PATCH("/lost-items/:id/status", lostItemController.UpdateLostItemStatus)
|
|
authenticated.DELETE("/lost-items/:id", lostItemController.DeleteLostItem)
|
|
|
|
// User claims
|
|
authenticated.GET("/user/claims", claimController.GetClaimsByUser)
|
|
authenticated.GET("/claims", claimController.GetAllClaims)
|
|
authenticated.GET("/claims/:id", claimController.GetClaimByID)
|
|
authenticated.POST("/claims", claimController.CreateClaim)
|
|
authenticated.DELETE("/claims/:id", claimController.DeleteClaim)
|
|
|
|
// Matches (for lost items)
|
|
authenticated.GET("/lost-items/:id/matches", matchController.GetMatchesForLostItem)
|
|
authenticated.POST("/lost-items/:id/find-similar", matchController.FindSimilarItems)
|
|
}
|
|
|
|
// Manager routes (manager and admin)
|
|
manager := api.Group("")
|
|
manager.Use(middleware.JWTMiddleware(db))
|
|
manager.Use(middleware.RequireManager())
|
|
{
|
|
// Item management
|
|
manager.PUT("/items/:id", itemController.UpdateItem)
|
|
manager.PATCH("/items/:id/status", itemController.UpdateItemStatus)
|
|
manager.DELETE("/items/:id", itemController.DeleteItem)
|
|
manager.GET("/items/:id/revisions", itemController.GetItemRevisionHistory)
|
|
manager.GET("/items/:id/matches", matchController.GetMatchesForItem)
|
|
|
|
// Claim verification
|
|
manager.POST("/claims/:id/verify", claimController.VerifyClaim)
|
|
manager.GET("/claims/:id/verification", claimController.GetClaimVerification)
|
|
manager.POST("/claims/:id/close", claimController.CloseClaim)
|
|
|
|
// Archives
|
|
manager.GET("/archives", archiveController.GetAllArchives)
|
|
manager.GET("/archives/:id", archiveController.GetArchiveByID)
|
|
manager.GET("/archives/stats", archiveController.GetArchiveStats)
|
|
|
|
// Dashboard
|
|
manager.GET("/manager/dashboard", adminController.GetDashboardStats)
|
|
}
|
|
|
|
// Admin routes (admin only)
|
|
admin := api.Group("/admin")
|
|
admin.Use(middleware.JWTMiddleware(db))
|
|
admin.Use(middleware.RequireAdmin())
|
|
{
|
|
// User management
|
|
admin.GET("/users", userController.GetAllUsers)
|
|
admin.GET("/users/:id", userController.GetUserByID)
|
|
admin.PATCH("/users/:id/role", userController.UpdateUserRole)
|
|
admin.POST("/users/:id/block", userController.BlockUser)
|
|
admin.POST("/users/:id/unblock", userController.UnblockUser)
|
|
admin.DELETE("/users/:id", userController.DeleteUser)
|
|
|
|
// Category management
|
|
admin.POST("/categories", categoryController.CreateCategory)
|
|
admin.PUT("/categories/:id", categoryController.UpdateCategory)
|
|
admin.DELETE("/categories/:id", categoryController.DeleteCategory)
|
|
|
|
// Dashboard & Reports
|
|
admin.GET("/dashboard", adminController.GetDashboardStats)
|
|
admin.GET("/audit-logs", adminController.GetAuditLogs)
|
|
admin.POST("/reports/export", reportController.ExportReport)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// internal/services/archive_service.go
|
|
package services
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ArchiveService struct {
|
|
archiveRepo *repositories.ArchiveRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewArchiveService(db *gorm.DB) *ArchiveService {
|
|
return &ArchiveService{
|
|
archiveRepo: repositories.NewArchiveRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// GetAllArchives gets all archived items
|
|
func (s *ArchiveService) GetAllArchives(page, limit int, reason, search string) ([]models.ArchiveResponse, int64, error) {
|
|
archives, total, err := s.archiveRepo.FindAll(page, limit, reason, search)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.ArchiveResponse
|
|
for _, archive := range archives {
|
|
responses = append(responses, archive.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetArchiveByID gets archive by ID
|
|
func (s *ArchiveService) GetArchiveByID(id uint) (*models.Archive, error) {
|
|
return s.archiveRepo.FindByID(id)
|
|
}
|
|
|
|
// GetArchiveByItemID gets archive by original item ID
|
|
func (s *ArchiveService) GetArchiveByItemID(itemID uint) (*models.Archive, error) {
|
|
return s.archiveRepo.FindByItemID(itemID)
|
|
}
|
|
|
|
// GetArchiveStats gets archive statistics
|
|
func (s *ArchiveService) GetArchiveStats() (map[string]interface{}, error) {
|
|
stats := make(map[string]interface{})
|
|
|
|
// Count by reason
|
|
expiredCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonExpired)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["expired"] = expiredCount
|
|
|
|
caseClosedCount, err := s.archiveRepo.CountByReason(models.ArchiveReasonCaseClosed)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["case_closed"] = caseClosedCount
|
|
|
|
stats["total"] = expiredCount + caseClosedCount
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// internal/services/audit_service.go
|
|
package services
|
|
|
|
import (
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AuditService struct {
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewAuditService(db *gorm.DB) *AuditService {
|
|
return &AuditService{
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// GetAllAuditLogs gets all audit logs
|
|
func (s *AuditService) GetAllAuditLogs(page, limit int, action, entityType string, userID *uint) ([]models.AuditLogResponse, int64, error) {
|
|
logs, total, err := s.auditLogRepo.FindAll(page, limit, action, entityType, userID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.AuditLogResponse
|
|
for _, log := range logs {
|
|
responses = append(responses, log.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetAuditLogsByUser gets audit logs by user
|
|
func (s *AuditService) GetAuditLogsByUser(userID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
|
|
logs, total, err := s.auditLogRepo.FindByUser(userID, page, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.AuditLogResponse
|
|
for _, log := range logs {
|
|
responses = append(responses, log.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetAuditLogsByEntity gets audit logs by entity
|
|
func (s *AuditService) GetAuditLogsByEntity(entityType string, entityID uint, page, limit int) ([]models.AuditLogResponse, int64, error) {
|
|
logs, total, err := s.auditLogRepo.FindByEntity(entityType, entityID, page, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.AuditLogResponse
|
|
for _, log := range logs {
|
|
responses = append(responses, log.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// LogAction creates a new audit log entry
|
|
func (s *AuditService) LogAction(userID *uint, action, entityType string, entityID *uint, details, ipAddress, userAgent string) error {
|
|
return s.auditLogRepo.Log(userID, action, entityType, entityID, details, ipAddress, userAgent)
|
|
}
|
|
|
|
// internal/services/auth_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"lost-and-found/internal/config"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
type AuthService struct {
|
|
userRepo *repositories.UserRepository
|
|
roleRepo *repositories.RoleRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewAuthService(db *gorm.DB) *AuthService {
|
|
return &AuthService{
|
|
userRepo: repositories.NewUserRepository(db),
|
|
roleRepo: repositories.NewRoleRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// RegisterRequest represents registration data
|
|
type RegisterRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
NRP string `json:"nrp"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
|
|
// LoginRequest represents login data
|
|
type LoginRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
// AuthResponse represents authentication response
|
|
type AuthResponse struct {
|
|
Token string `json:"token"`
|
|
User models.UserResponse `json:"user"`
|
|
}
|
|
|
|
// Register registers a new user
|
|
func (s *AuthService) Register(req RegisterRequest, ipAddress, userAgent string) (*AuthResponse, error) {
|
|
// Check if email already exists
|
|
existingUser, _ := s.userRepo.FindByEmail(req.Email)
|
|
if existingUser != nil {
|
|
return nil, errors.New("email already registered")
|
|
}
|
|
|
|
// Check if NRP already exists
|
|
if req.NRP != "" {
|
|
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
|
|
if existingNRP != nil {
|
|
return nil, errors.New("NRP already registered")
|
|
}
|
|
}
|
|
|
|
// Hash password
|
|
hashedPassword, err := utils.HashPassword(req.Password)
|
|
if err != nil {
|
|
return nil, errors.New("failed to hash password")
|
|
}
|
|
|
|
// Get user role ID
|
|
userRole, err := s.roleRepo.FindByName(models.RoleUser)
|
|
if err != nil {
|
|
return nil, errors.New("failed to get user role")
|
|
}
|
|
|
|
// Create user
|
|
user := &models.User{
|
|
Name: req.Name,
|
|
Email: req.Email,
|
|
Password: hashedPassword,
|
|
NRP: req.NRP,
|
|
Phone: req.Phone,
|
|
RoleID: userRole.ID,
|
|
Status: "active",
|
|
}
|
|
|
|
if err := s.userRepo.Create(user); err != nil {
|
|
return nil, errors.New("failed to create user")
|
|
}
|
|
|
|
// Load user with role
|
|
user, err = s.userRepo.FindByID(user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
|
|
if err != nil {
|
|
return nil, errors.New("failed to generate token")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&user.ID, models.ActionCreate, models.EntityUser, &user.ID,
|
|
"User registered", ipAddress, userAgent)
|
|
|
|
return &AuthResponse{
|
|
Token: token,
|
|
User: user.ToResponse(),
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) Login(req LoginRequest, ipAddress, userAgent string) (*AuthResponse, error) {
|
|
// DEBUG: Print request info
|
|
log.Printf("🔍 [LOGIN] Attempting login for email: %s", req.Email)
|
|
log.Printf("🔍 [LOGIN] Password length: %d characters", len(req.Password))
|
|
|
|
// Find user by email
|
|
user, err := s.userRepo.FindByEmail(req.Email)
|
|
if err != nil {
|
|
log.Printf("❌ [LOGIN] User not found in database: %s - Error: %v", req.Email, err)
|
|
return nil, errors.New("invalid email or password")
|
|
}
|
|
|
|
log.Printf("✅ [LOGIN] User found in DB - ID: %d, Name: %s, Email: %s", user.ID, user.Name, user.Email)
|
|
log.Printf("🔍 [LOGIN] User role ID: %d", user.RoleID)
|
|
log.Printf("🔍 [LOGIN] User status: %s", user.Status)
|
|
log.Printf("🔍 [LOGIN] Password hash from DB: %s", user.Password)
|
|
|
|
// Check if user is blocked
|
|
if user.IsBlocked() {
|
|
log.Printf("❌ [LOGIN] User is blocked: %s", user.Email)
|
|
return nil, errors.New("account is blocked")
|
|
}
|
|
|
|
log.Printf("✅ [LOGIN] User is active, checking password...")
|
|
|
|
// Verify password
|
|
log.Printf("🔍 [LOGIN] Checking password: '%s' against hash", req.Password)
|
|
passwordMatch := utils.CheckPasswordHash(req.Password, user.Password)
|
|
log.Printf("🔍 [LOGIN] Password match result: %v", passwordMatch)
|
|
|
|
if !passwordMatch {
|
|
log.Printf("❌ [LOGIN] Password mismatch for user: %s", user.Email)
|
|
return nil, errors.New("invalid email or password")
|
|
}
|
|
|
|
log.Printf("✅ [LOGIN] Password correct! Generating token...")
|
|
|
|
// Check if Role is loaded
|
|
if user.Role.ID == 0 {
|
|
log.Printf("⚠️ [LOGIN] Role not loaded! RoleID: %d, Role.Name: %s", user.RoleID, user.Role.Name)
|
|
log.Printf("🔧 [LOGIN] Attempting to load role manually...")
|
|
// Reload user with role
|
|
user, err = s.userRepo.FindByID(user.ID)
|
|
if err != nil {
|
|
log.Printf("❌ [LOGIN] Failed to reload user with role: %v", err)
|
|
return nil, errors.New("failed to load user data")
|
|
}
|
|
log.Printf("✅ [LOGIN] Role reloaded - Role.ID: %d, Role.Name: %s", user.Role.ID, user.Role.Name)
|
|
} else {
|
|
log.Printf("✅ [LOGIN] Role loaded - ID: %d, Name: %s", user.Role.ID, user.Role.Name)
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := config.GenerateToken(user.ID, user.Email, user.Role.Name)
|
|
if err != nil {
|
|
log.Printf("❌ [LOGIN] Failed to generate token: %v", err)
|
|
return nil, errors.New("failed to generate token")
|
|
}
|
|
|
|
log.Printf("✅ [LOGIN] Token generated successfully for %s (Role: %s)", user.Email, user.Role.Name)
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&user.ID, models.ActionLogin, models.EntityUser, &user.ID,
|
|
"User logged in", ipAddress, userAgent)
|
|
|
|
log.Printf("✅ [LOGIN] Audit log created")
|
|
log.Printf("🎉 [LOGIN] Login successful for %s - Returning response", user.Email)
|
|
|
|
return &AuthResponse{
|
|
Token: token,
|
|
User: user.ToResponse(),
|
|
}, nil
|
|
}
|
|
|
|
// ValidateToken validates JWT token and returns user
|
|
func (s *AuthService) ValidateToken(tokenString string) (*models.User, error) {
|
|
// Validate token
|
|
claims, err := config.ValidateToken(tokenString)
|
|
if err != nil {
|
|
return nil, errors.New("invalid token")
|
|
}
|
|
|
|
// Get user
|
|
user, err := s.userRepo.FindByID(claims.UserID)
|
|
if err != nil {
|
|
return nil, errors.New("user not found")
|
|
}
|
|
|
|
// Check if user is blocked
|
|
if user.IsBlocked() {
|
|
return nil, errors.New("account is blocked")
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// RefreshToken refreshes JWT token
|
|
func (s *AuthService) RefreshToken(oldToken string) (string, error) {
|
|
return config.RefreshToken(oldToken)
|
|
}
|
|
|
|
// internal/services/category_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type CategoryService struct {
|
|
categoryRepo *repositories.CategoryRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewCategoryService(db *gorm.DB) *CategoryService {
|
|
return &CategoryService{
|
|
categoryRepo: repositories.NewCategoryRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// CreateCategoryRequest represents category creation data
|
|
type CreateCategoryRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// UpdateCategoryRequest represents category update data
|
|
type UpdateCategoryRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// GetAllCategories gets all categories
|
|
func (s *CategoryService) GetAllCategories() ([]models.CategoryResponse, error) {
|
|
return s.categoryRepo.GetAllWithItemCount()
|
|
}
|
|
|
|
// GetCategoryByID gets category by ID
|
|
func (s *CategoryService) GetCategoryByID(id uint) (*models.Category, error) {
|
|
return s.categoryRepo.FindByID(id)
|
|
}
|
|
|
|
// GetCategoryBySlug gets category by slug
|
|
func (s *CategoryService) GetCategoryBySlug(slug string) (*models.Category, error) {
|
|
return s.categoryRepo.FindBySlug(slug)
|
|
}
|
|
|
|
// CreateCategory creates a new category (admin only)
|
|
func (s *CategoryService) CreateCategory(adminID uint, req CreateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
|
|
// Generate slug from name
|
|
slug := s.generateSlug(req.Name)
|
|
|
|
// Check if slug already exists
|
|
existing, _ := s.categoryRepo.FindBySlug(slug)
|
|
if existing != nil {
|
|
return nil, errors.New("category with similar name already exists")
|
|
}
|
|
|
|
category := &models.Category{
|
|
Name: req.Name,
|
|
Slug: slug,
|
|
Description: req.Description,
|
|
}
|
|
|
|
if err := s.categoryRepo.Create(category); err != nil {
|
|
return nil, errors.New("failed to create category")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionCreate, models.EntityCategory, &category.ID,
|
|
"Category created: "+category.Name, ipAddress, userAgent)
|
|
|
|
return category, nil
|
|
}
|
|
|
|
// UpdateCategory updates a category (admin only)
|
|
func (s *CategoryService) UpdateCategory(adminID, categoryID uint, req UpdateCategoryRequest, ipAddress, userAgent string) (*models.Category, error) {
|
|
category, err := s.categoryRepo.FindByID(categoryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update fields
|
|
if req.Name != "" {
|
|
category.Name = req.Name
|
|
category.Slug = s.generateSlug(req.Name)
|
|
}
|
|
if req.Description != "" {
|
|
category.Description = req.Description
|
|
}
|
|
|
|
if err := s.categoryRepo.Update(category); err != nil {
|
|
return nil, errors.New("failed to update category")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityCategory, &categoryID,
|
|
"Category updated: "+category.Name, ipAddress, userAgent)
|
|
|
|
return category, nil
|
|
}
|
|
|
|
// DeleteCategory deletes a category (admin only)
|
|
func (s *CategoryService) DeleteCategory(adminID, categoryID uint, ipAddress, userAgent string) error {
|
|
category, err := s.categoryRepo.FindByID(categoryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if category has items
|
|
_, count, err := s.categoryRepo.GetCategoryWithItemCount(categoryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return errors.New("cannot delete category with existing items")
|
|
}
|
|
|
|
if err := s.categoryRepo.Delete(categoryID); err != nil {
|
|
return errors.New("failed to delete category")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityCategory, &categoryID,
|
|
"Category deleted: "+category.Name, ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// generateSlug generates URL-friendly slug from name
|
|
func (s *CategoryService) generateSlug(name string) string {
|
|
slug := strings.ToLower(name)
|
|
slug = strings.ReplaceAll(slug, " ", "_")
|
|
slug = strings.ReplaceAll(slug, "/", "_")
|
|
// Remove special characters
|
|
validChars := "abcdefghijklmnopqrstuvwxyz0123456789_"
|
|
result := ""
|
|
for _, char := range slug {
|
|
if strings.ContainsRune(validChars, char) {
|
|
result += string(char)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// internal/services/claim_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ClaimService struct {
|
|
db *gorm.DB // Tambahkan ini
|
|
claimRepo *repositories.ClaimRepository
|
|
itemRepo *repositories.ItemRepository
|
|
verificationRepo *repositories.ClaimVerificationRepository
|
|
notificationRepo *repositories.NotificationRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewClaimService(db *gorm.DB) *ClaimService {
|
|
return &ClaimService{
|
|
db: db, // Tambahkan ini
|
|
claimRepo: repositories.NewClaimRepository(db),
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
verificationRepo: repositories.NewClaimVerificationRepository(db),
|
|
notificationRepo: repositories.NewNotificationRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// CreateClaimRequest represents claim creation data
|
|
type CreateClaimRequest struct {
|
|
ItemID uint `json:"item_id" binding:"required"`
|
|
Description string `json:"description" binding:"required"`
|
|
ProofURL string `json:"proof_url"`
|
|
Contact string `json:"contact" binding:"required"`
|
|
}
|
|
|
|
// VerifyClaimRequest represents claim verification data
|
|
type VerifyClaimRequest struct {
|
|
Status string `json:"status" binding:"required"` // approved or rejected
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
// CreateClaim creates a new claim
|
|
func (s *ClaimService) CreateClaim(userID uint, req CreateClaimRequest, ipAddress, userAgent string) (*models.Claim, error) {
|
|
// Check if item exists
|
|
item, err := s.itemRepo.FindByID(req.ItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if item can be claimed
|
|
if !item.CanBeClaimed() {
|
|
return nil, errors.New("item cannot be claimed")
|
|
}
|
|
|
|
// Check if user already claimed this item
|
|
exists, err := s.claimRepo.CheckExistingClaim(userID, req.ItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exists {
|
|
return nil, errors.New("you already claimed this item")
|
|
}
|
|
|
|
// Create claim
|
|
claim := &models.Claim{
|
|
ItemID: req.ItemID,
|
|
UserID: userID,
|
|
Description: req.Description,
|
|
ProofURL: req.ProofURL,
|
|
Contact: req.Contact,
|
|
Status: models.ClaimStatusPending,
|
|
}
|
|
|
|
if err := s.claimRepo.Create(claim); err != nil {
|
|
return nil, errors.New("failed to create claim")
|
|
}
|
|
|
|
// Update item status to pending claim
|
|
s.itemRepo.UpdateStatus(req.ItemID, models.ItemStatusPendingClaim)
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityClaim, &claim.ID,
|
|
"Claim created for item: "+item.Name, ipAddress, userAgent)
|
|
|
|
// Load claim with relations
|
|
return s.claimRepo.FindByID(claim.ID)
|
|
}
|
|
|
|
// GetAllClaims gets all claims
|
|
func (s *ClaimService) GetAllClaims(page, limit int, status string, itemID, userID *uint) ([]models.ClaimResponse, int64, error) {
|
|
claims, total, err := s.claimRepo.FindAll(page, limit, status, itemID, userID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.ClaimResponse
|
|
for _, claim := range claims {
|
|
responses = append(responses, claim.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetClaimByID gets claim by ID
|
|
func (s *ClaimService) GetClaimByID(id uint, isManager bool) (interface{}, error) {
|
|
claim, err := s.claimRepo.FindByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Manager can see full details including item description
|
|
if isManager {
|
|
return claim.ToDetailResponse(), nil
|
|
}
|
|
|
|
return claim.ToResponse(), nil
|
|
}
|
|
|
|
// GetClaimsByUser gets claims by user
|
|
func (s *ClaimService) GetClaimsByUser(userID uint, page, limit int) ([]models.ClaimResponse, int64, error) {
|
|
claims, total, err := s.claimRepo.FindByUser(userID, page, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.ClaimResponse
|
|
for _, claim := range claims {
|
|
responses = append(responses, claim.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// VerifyClaim verifies a claim (manager only)
|
|
func (s *ClaimService) VerifyClaim(managerID, claimID uint, req VerifyClaimRequest, similarityScore float64, matchedKeywords string, ipAddress, userAgent string) error {
|
|
claim, err := s.claimRepo.FindByID(claimID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !claim.IsPending() {
|
|
return errors.New("claim is not pending")
|
|
}
|
|
|
|
// Create or update verification record
|
|
verification, _ := s.verificationRepo.FindByClaimID(claimID)
|
|
if verification == nil {
|
|
verification = &models.ClaimVerification{
|
|
ClaimID: claimID,
|
|
SimilarityScore: similarityScore,
|
|
MatchedKeywords: matchedKeywords,
|
|
VerificationNotes: req.Notes,
|
|
IsAutoMatched: false,
|
|
}
|
|
s.verificationRepo.Create(verification)
|
|
} else {
|
|
verification.VerificationNotes = req.Notes
|
|
s.verificationRepo.Update(verification)
|
|
}
|
|
|
|
// Update claim status
|
|
if req.Status == models.ClaimStatusApproved {
|
|
claim.Approve(managerID, req.Notes)
|
|
|
|
// Update item status to verified
|
|
s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusVerified)
|
|
|
|
// Send approval notification - PERBAIKAN DI SINI
|
|
item, _ := s.itemRepo.FindByID(claim.ItemID)
|
|
models.CreateClaimApprovedNotification(s.db, claim.UserID, item.Name, claimID)
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&managerID, models.ActionApprove, models.EntityClaim, &claimID,
|
|
"Claim approved", ipAddress, userAgent)
|
|
} else if req.Status == models.ClaimStatusRejected {
|
|
claim.Reject(managerID, req.Notes)
|
|
|
|
// Check if there are other pending claims for this item
|
|
otherClaims, _ := s.claimRepo.FindByItem(claim.ItemID)
|
|
hasPendingClaims := false
|
|
for _, c := range otherClaims {
|
|
if c.ID != claimID && c.IsPending() {
|
|
hasPendingClaims = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// If no other pending claims, set item back to unclaimed
|
|
if !hasPendingClaims {
|
|
s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusUnclaimed)
|
|
}
|
|
|
|
// Send rejection notification - PERBAIKAN DI SINI
|
|
item, _ := s.itemRepo.FindByID(claim.ItemID)
|
|
models.CreateClaimRejectedNotification(s.db, claim.UserID, item.Name, req.Notes, claimID)
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&managerID, models.ActionReject, models.EntityClaim, &claimID,
|
|
"Claim rejected: "+req.Notes, ipAddress, userAgent)
|
|
} else {
|
|
return errors.New("invalid status")
|
|
}
|
|
|
|
if err := s.claimRepo.Update(claim); err != nil {
|
|
return errors.New("failed to verify claim")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CloseClaim closes a claim and moves item to archive (manager only)
|
|
func (s *ClaimService) CloseClaim(managerID, claimID uint, ipAddress, userAgent string) error {
|
|
claim, err := s.claimRepo.FindByID(claimID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !claim.IsApproved() {
|
|
return errors.New("only approved claims can be closed")
|
|
}
|
|
|
|
// Update item status to case_closed
|
|
if err := s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusCaseClosed); err != nil {
|
|
return errors.New("failed to close case")
|
|
}
|
|
|
|
// Archive the item
|
|
item, _ := s.itemRepo.FindByID(claim.ItemID)
|
|
s.itemRepo.ArchiveItem(item, models.ArchiveReasonCaseClosed, &claim.UserID)
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&managerID, models.ActionUpdate, models.EntityItem, &item.ID,
|
|
"Case closed and archived", ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteClaim deletes a claim
|
|
func (s *ClaimService) DeleteClaim(userID, claimID uint, ipAddress, userAgent string) error {
|
|
claim, err := s.claimRepo.FindByID(claimID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only pending claims can be deleted by users
|
|
if !claim.IsPending() && claim.UserID == userID {
|
|
return errors.New("cannot delete non-pending claim")
|
|
}
|
|
|
|
if err := s.claimRepo.Delete(claimID); err != nil {
|
|
return errors.New("failed to delete claim")
|
|
}
|
|
|
|
// Check if item should go back to unclaimed
|
|
otherClaims, _ := s.claimRepo.FindByItem(claim.ItemID)
|
|
if len(otherClaims) == 0 {
|
|
s.itemRepo.UpdateStatus(claim.ItemID, models.ItemStatusUnclaimed)
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityClaim, &claimID,
|
|
"Claim deleted", ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// internal/services/export_service.go
|
|
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/utils"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ExportService struct {
|
|
itemRepo *repositories.ItemRepository
|
|
archiveRepo *repositories.ArchiveRepository
|
|
claimRepo *repositories.ClaimRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewExportService(db *gorm.DB) *ExportService {
|
|
return &ExportService{
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
archiveRepo: repositories.NewArchiveRepository(db),
|
|
claimRepo: repositories.NewClaimRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// ExportRequest represents export request data
|
|
type ExportRequest struct {
|
|
Type string `json:"type"` // items, archives, claims, audit_logs
|
|
Format string `json:"format"` // pdf, excel
|
|
StartDate *time.Time `json:"start_date"`
|
|
EndDate *time.Time `json:"end_date"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ExportItemsToPDF exports items to PDF
|
|
func (s *ExportService) ExportItemsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
|
// Get items
|
|
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter by date range if provided
|
|
var filteredItems []models.Item
|
|
for _, item := range items {
|
|
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
|
|
continue
|
|
}
|
|
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
|
|
continue
|
|
}
|
|
filteredItems = append(filteredItems, item)
|
|
}
|
|
|
|
// Generate PDF
|
|
pdf := utils.NewPDFExporter()
|
|
pdf.AddTitle("Laporan Barang Ditemukan")
|
|
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
|
|
formatDate(req.StartDate),
|
|
formatDate(req.EndDate)))
|
|
pdf.AddNewLine()
|
|
|
|
// Add table
|
|
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Tanggal Ditemukan", "Status"}
|
|
var data [][]string
|
|
for i, item := range filteredItems {
|
|
data = append(data, []string{
|
|
fmt.Sprintf("%d", i+1),
|
|
item.Name,
|
|
item.Category.Name,
|
|
item.Location,
|
|
item.DateFound.Format("02 Jan 2006"),
|
|
item.Status,
|
|
})
|
|
}
|
|
pdf.AddTable(headers, data)
|
|
|
|
// Add footer
|
|
pdf.AddNewLine()
|
|
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredItems)))
|
|
pdf.AddText(fmt.Sprintf("Dicetak pada: %s", time.Now().Format("02 January 2006 15:04")))
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
|
fmt.Sprintf("Exported items report (PDF, %d items)", len(filteredItems)),
|
|
ipAddress, userAgent)
|
|
|
|
return pdf.Output(), nil
|
|
}
|
|
|
|
// ExportItemsToExcel exports items to Excel
|
|
func (s *ExportService) ExportItemsToExcel(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
|
// Get items
|
|
items, _, err := s.itemRepo.FindAll(1, 10000, req.Status, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter by date range if provided
|
|
var filteredItems []models.Item
|
|
for _, item := range items {
|
|
if req.StartDate != nil && item.DateFound.Before(*req.StartDate) {
|
|
continue
|
|
}
|
|
if req.EndDate != nil && item.DateFound.After(*req.EndDate) {
|
|
continue
|
|
}
|
|
filteredItems = append(filteredItems, item)
|
|
}
|
|
|
|
// Generate Excel
|
|
excel := utils.NewExcelExporter()
|
|
excel.SetSheetName("Barang Ditemukan")
|
|
|
|
// Add headers
|
|
headers := []string{"No", "Nama Barang", "Kategori", "Lokasi", "Deskripsi",
|
|
"Tanggal Ditemukan", "Status", "Pelapor", "Kontak"}
|
|
excel.AddRow(headers)
|
|
|
|
// Add data
|
|
for i, item := range filteredItems {
|
|
excel.AddRow([]string{
|
|
fmt.Sprintf("%d", i+1),
|
|
item.Name,
|
|
item.Category.Name,
|
|
item.Location,
|
|
item.Description,
|
|
item.DateFound.Format("02 Jan 2006"),
|
|
item.Status,
|
|
item.ReporterName,
|
|
item.ReporterContact,
|
|
})
|
|
}
|
|
|
|
// Auto-size columns
|
|
excel.AutoSizeColumns(len(headers))
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
|
fmt.Sprintf("Exported items report (Excel, %d items)", len(filteredItems)),
|
|
ipAddress, userAgent)
|
|
|
|
return excel.Output()
|
|
}
|
|
|
|
// ExportArchivesToPDF exports archives to PDF
|
|
func (s *ExportService) ExportArchivesToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
|
archives, _, err := s.archiveRepo.FindAll(1, 10000, "", "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter by date range
|
|
var filteredArchives []models.Archive
|
|
for _, archive := range archives {
|
|
if req.StartDate != nil && archive.ArchivedAt.Before(*req.StartDate) {
|
|
continue
|
|
}
|
|
if req.EndDate != nil && archive.ArchivedAt.After(*req.EndDate) {
|
|
continue
|
|
}
|
|
filteredArchives = append(filteredArchives, archive)
|
|
}
|
|
|
|
pdf := utils.NewPDFExporter()
|
|
pdf.AddTitle("Laporan Barang yang Diarsipkan")
|
|
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
|
|
formatDate(req.StartDate),
|
|
formatDate(req.EndDate)))
|
|
pdf.AddNewLine()
|
|
|
|
headers := []string{"No", "Nama Barang", "Kategori", "Alasan Arsip", "Tanggal Arsip"}
|
|
var data [][]string
|
|
for i, archive := range filteredArchives {
|
|
data = append(data, []string{
|
|
fmt.Sprintf("%d", i+1),
|
|
archive.Name,
|
|
archive.Category.Name,
|
|
archive.ArchivedReason,
|
|
archive.ArchivedAt.Format("02 Jan 2006"),
|
|
})
|
|
}
|
|
pdf.AddTable(headers, data)
|
|
|
|
pdf.AddNewLine()
|
|
pdf.AddText(fmt.Sprintf("Total: %d barang", len(filteredArchives)))
|
|
|
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
|
fmt.Sprintf("Exported archives report (PDF, %d items)", len(filteredArchives)),
|
|
ipAddress, userAgent)
|
|
|
|
return pdf.Output(), nil
|
|
}
|
|
|
|
// ExportClaimsToPDF exports claims to PDF
|
|
func (s *ExportService) ExportClaimsToPDF(req ExportRequest, userID uint, ipAddress, userAgent string) (*bytes.Buffer, error) {
|
|
claims, _, err := s.claimRepo.FindAll(1, 10000, req.Status, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Filter by date range
|
|
var filteredClaims []models.Claim
|
|
for _, claim := range claims {
|
|
if req.StartDate != nil && claim.CreatedAt.Before(*req.StartDate) {
|
|
continue
|
|
}
|
|
if req.EndDate != nil && claim.CreatedAt.After(*req.EndDate) {
|
|
continue
|
|
}
|
|
filteredClaims = append(filteredClaims, claim)
|
|
}
|
|
|
|
pdf := utils.NewPDFExporter()
|
|
pdf.AddTitle("Laporan Klaim Barang")
|
|
pdf.AddSubtitle(fmt.Sprintf("Periode: %s - %s",
|
|
formatDate(req.StartDate),
|
|
formatDate(req.EndDate)))
|
|
pdf.AddNewLine()
|
|
|
|
headers := []string{"No", "Barang", "Pengklaim", "Status", "Tanggal Klaim"}
|
|
var data [][]string
|
|
for i, claim := range filteredClaims {
|
|
data = append(data, []string{
|
|
fmt.Sprintf("%d", i+1),
|
|
claim.Item.Name,
|
|
claim.User.Name,
|
|
claim.Status,
|
|
claim.CreatedAt.Format("02 Jan 2006"),
|
|
})
|
|
}
|
|
pdf.AddTable(headers, data)
|
|
|
|
pdf.AddNewLine()
|
|
pdf.AddText(fmt.Sprintf("Total: %d klaim", len(filteredClaims)))
|
|
|
|
s.auditLogRepo.Log(&userID, models.ActionExport, "report", nil,
|
|
fmt.Sprintf("Exported claims report (PDF, %d claims)", len(filteredClaims)),
|
|
ipAddress, userAgent)
|
|
|
|
return pdf.Output(), nil
|
|
}
|
|
|
|
// Helper function to format date
|
|
func formatDate(date *time.Time) string {
|
|
if date == nil {
|
|
return "N/A"
|
|
}
|
|
return date.Format("02 Jan 2006")
|
|
}
|
|
|
|
// internal/services/item_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ItemService struct {
|
|
itemRepo *repositories.ItemRepository
|
|
categoryRepo *repositories.CategoryRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
revisionRepo *repositories.RevisionLogRepository
|
|
}
|
|
|
|
func NewItemService(db *gorm.DB) *ItemService {
|
|
return &ItemService{
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
categoryRepo: repositories.NewCategoryRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
revisionRepo: repositories.NewRevisionLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// CreateItemRequest represents create item data
|
|
type CreateItemRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
CategoryID uint `json:"category_id" binding:"required"`
|
|
PhotoURL string `json:"photo_url"`
|
|
Location string `json:"location" binding:"required"`
|
|
Description string `json:"description" binding:"required"`
|
|
DateFound time.Time `json:"date_found" binding:"required"`
|
|
ReporterName string `json:"reporter_name" binding:"required"`
|
|
ReporterContact string `json:"reporter_contact" binding:"required"`
|
|
}
|
|
|
|
// UpdateItemRequest represents update item data
|
|
type UpdateItemRequest struct {
|
|
Name string `json:"name"`
|
|
CategoryID uint `json:"category_id"`
|
|
Location string `json:"location"`
|
|
Description string `json:"description"`
|
|
DateFound time.Time `json:"date_found"`
|
|
ReporterName string `json:"reporter_name"`
|
|
ReporterContact string `json:"reporter_contact"`
|
|
Reason string `json:"reason"` // Reason for edit
|
|
}
|
|
|
|
// GetAllItems gets all items (public view)
|
|
func (s *ItemService) GetAllItems(page, limit int, status, category, search string) ([]models.ItemPublicResponse, int64, error) {
|
|
items, total, err := s.itemRepo.FindAll(page, limit, status, category, search)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.ItemPublicResponse
|
|
for _, item := range items {
|
|
responses = append(responses, item.ToPublicResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetItemByID gets item by ID
|
|
func (s *ItemService) GetItemByID(id uint, isManager bool) (interface{}, error) {
|
|
item, err := s.itemRepo.FindByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Manager can see full details
|
|
if isManager {
|
|
return item.ToDetailResponse(), nil
|
|
}
|
|
|
|
// Public can only see limited info
|
|
return item.ToPublicResponse(), nil
|
|
}
|
|
|
|
// CreateItem creates a new item
|
|
func (s *ItemService) CreateItem(reporterID uint, req CreateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
|
|
// Verify category exists
|
|
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
|
|
return nil, errors.New("invalid category")
|
|
}
|
|
|
|
item := &models.Item{
|
|
Name: req.Name,
|
|
CategoryID: req.CategoryID,
|
|
PhotoURL: req.PhotoURL,
|
|
Location: req.Location,
|
|
Description: req.Description,
|
|
DateFound: req.DateFound,
|
|
Status: models.ItemStatusUnclaimed,
|
|
ReporterID: reporterID,
|
|
ReporterName: req.ReporterName,
|
|
ReporterContact: req.ReporterContact,
|
|
}
|
|
|
|
if err := s.itemRepo.Create(item); err != nil {
|
|
return nil, errors.New("failed to create item")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&reporterID, models.ActionCreate, models.EntityItem, &item.ID,
|
|
"Item created: "+item.Name, ipAddress, userAgent)
|
|
|
|
return item, nil
|
|
}
|
|
|
|
// UpdateItem updates an item
|
|
func (s *ItemService) UpdateItem(userID, itemID uint, req UpdateItemRequest, ipAddress, userAgent string) (*models.Item, error) {
|
|
item, err := s.itemRepo.FindByID(itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if item can be edited
|
|
if !item.CanBeEdited() {
|
|
return nil, errors.New("cannot edit item with status: " + item.Status)
|
|
}
|
|
|
|
// Track changes for revision log
|
|
if req.Name != "" && req.Name != item.Name {
|
|
s.revisionRepo.Log(itemID, userID, "name", item.Name, req.Name, req.Reason)
|
|
item.Name = req.Name
|
|
}
|
|
if req.CategoryID != 0 && req.CategoryID != item.CategoryID {
|
|
oldCat, _ := s.categoryRepo.FindByID(item.CategoryID)
|
|
newCat, _ := s.categoryRepo.FindByID(req.CategoryID)
|
|
s.revisionRepo.Log(itemID, userID, "category", oldCat.Name, newCat.Name, req.Reason)
|
|
item.CategoryID = req.CategoryID
|
|
}
|
|
if req.Location != "" && req.Location != item.Location {
|
|
s.revisionRepo.Log(itemID, userID, "location", item.Location, req.Location, req.Reason)
|
|
item.Location = req.Location
|
|
}
|
|
if req.Description != "" && req.Description != item.Description {
|
|
s.revisionRepo.Log(itemID, userID, "description", item.Description, req.Description, req.Reason)
|
|
item.Description = req.Description
|
|
}
|
|
|
|
if err := s.itemRepo.Update(item); err != nil {
|
|
return nil, errors.New("failed to update item")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityItem, &itemID,
|
|
"Item updated: "+item.Name, ipAddress, userAgent)
|
|
|
|
return item, nil
|
|
}
|
|
|
|
// UpdateItemStatus updates item status
|
|
func (s *ItemService) UpdateItemStatus(userID, itemID uint, status string, ipAddress, userAgent string) error {
|
|
if err := s.itemRepo.UpdateStatus(itemID, status); err != nil {
|
|
return errors.New("failed to update item status")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityItem, &itemID,
|
|
"Item status updated to: "+status, ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteItem deletes an item
|
|
func (s *ItemService) DeleteItem(userID, itemID uint, ipAddress, userAgent string) error {
|
|
item, err := s.itemRepo.FindByID(itemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Cannot delete verified or case closed items
|
|
if item.Status == models.ItemStatusVerified || item.Status == models.ItemStatusCaseClosed {
|
|
return errors.New("cannot delete item with status: " + item.Status)
|
|
}
|
|
|
|
if err := s.itemRepo.Delete(itemID); err != nil {
|
|
return errors.New("failed to delete item")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityItem, &itemID,
|
|
"Item deleted: "+item.Name, ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetItemsByReporter gets items by reporter
|
|
func (s *ItemService) GetItemsByReporter(reporterID uint, page, limit int) ([]models.Item, int64, error) {
|
|
return s.itemRepo.FindByReporter(reporterID, page, limit)
|
|
}
|
|
|
|
// GetItemRevisionHistory gets revision history for an item
|
|
func (s *ItemService) GetItemRevisionHistory(itemID uint, page, limit int) ([]models.RevisionLogResponse, int64, error) {
|
|
logs, total, err := s.revisionRepo.FindByItem(itemID, page, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.RevisionLogResponse
|
|
for _, log := range logs {
|
|
responses = append(responses, log.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// internal/services/lost_item_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type LostItemService struct {
|
|
lostItemRepo *repositories.LostItemRepository
|
|
categoryRepo *repositories.CategoryRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewLostItemService(db *gorm.DB) *LostItemService {
|
|
return &LostItemService{
|
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
|
categoryRepo: repositories.NewCategoryRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// CreateLostItemRequest represents create lost item data
|
|
type CreateLostItemRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
CategoryID uint `json:"category_id" binding:"required"`
|
|
Color string `json:"color"`
|
|
Location string `json:"location"`
|
|
Description string `json:"description" binding:"required"`
|
|
DateLost time.Time `json:"date_lost" binding:"required"`
|
|
}
|
|
|
|
// UpdateLostItemRequest represents update lost item data
|
|
type UpdateLostItemRequest struct {
|
|
Name string `json:"name"`
|
|
CategoryID uint `json:"category_id"`
|
|
Color string `json:"color"`
|
|
Location string `json:"location"`
|
|
Description string `json:"description"`
|
|
DateLost time.Time `json:"date_lost"`
|
|
}
|
|
|
|
// GetAllLostItems gets all lost items
|
|
func (s *LostItemService) GetAllLostItems(page, limit int, status, category, search string, userID *uint) ([]models.LostItemResponse, int64, error) {
|
|
lostItems, total, err := s.lostItemRepo.FindAll(page, limit, status, category, search, userID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.LostItemResponse
|
|
for _, lostItem := range lostItems {
|
|
responses = append(responses, lostItem.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetLostItemByID gets lost item by ID
|
|
func (s *LostItemService) GetLostItemByID(id uint) (*models.LostItem, error) {
|
|
return s.lostItemRepo.FindByID(id)
|
|
}
|
|
|
|
// CreateLostItem creates a new lost item report
|
|
func (s *LostItemService) CreateLostItem(userID uint, req CreateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
|
|
// Verify category exists
|
|
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
|
|
return nil, errors.New("invalid category")
|
|
}
|
|
|
|
lostItem := &models.LostItem{
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
CategoryID: req.CategoryID,
|
|
Color: req.Color,
|
|
Location: req.Location,
|
|
Description: req.Description,
|
|
DateLost: req.DateLost,
|
|
Status: models.LostItemStatusActive,
|
|
}
|
|
|
|
if err := s.lostItemRepo.Create(lostItem); err != nil {
|
|
return nil, errors.New("failed to create lost item report")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionCreate, models.EntityLostItem, &lostItem.ID,
|
|
"Lost item report created: "+lostItem.Name, ipAddress, userAgent)
|
|
|
|
// Load with relations
|
|
return s.lostItemRepo.FindByID(lostItem.ID)
|
|
}
|
|
|
|
// UpdateLostItem updates a lost item report
|
|
func (s *LostItemService) UpdateLostItem(userID, lostItemID uint, req UpdateLostItemRequest, ipAddress, userAgent string) (*models.LostItem, error) {
|
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Only owner can update
|
|
if lostItem.UserID != userID {
|
|
return nil, errors.New("unauthorized to update this lost item report")
|
|
}
|
|
|
|
// Only active reports can be updated
|
|
if !lostItem.IsActive() {
|
|
return nil, errors.New("cannot update non-active lost item report")
|
|
}
|
|
|
|
// Update fields
|
|
if req.Name != "" {
|
|
lostItem.Name = req.Name
|
|
}
|
|
if req.CategoryID != 0 {
|
|
// Verify category exists
|
|
if _, err := s.categoryRepo.FindByID(req.CategoryID); err != nil {
|
|
return nil, errors.New("invalid category")
|
|
}
|
|
lostItem.CategoryID = req.CategoryID
|
|
}
|
|
if req.Color != "" {
|
|
lostItem.Color = req.Color
|
|
}
|
|
if req.Location != "" {
|
|
lostItem.Location = req.Location
|
|
}
|
|
if req.Description != "" {
|
|
lostItem.Description = req.Description
|
|
}
|
|
if !req.DateLost.IsZero() {
|
|
lostItem.DateLost = req.DateLost
|
|
}
|
|
|
|
if err := s.lostItemRepo.Update(lostItem); err != nil {
|
|
return nil, errors.New("failed to update lost item report")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
|
|
"Lost item report updated: "+lostItem.Name, ipAddress, userAgent)
|
|
|
|
return lostItem, nil
|
|
}
|
|
|
|
// UpdateLostItemStatus updates lost item status
|
|
func (s *LostItemService) UpdateLostItemStatus(userID, lostItemID uint, status string, ipAddress, userAgent string) error {
|
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only owner can update
|
|
if lostItem.UserID != userID {
|
|
return errors.New("unauthorized to update this lost item report")
|
|
}
|
|
|
|
if err := s.lostItemRepo.UpdateStatus(lostItemID, status); err != nil {
|
|
return errors.New("failed to update lost item status")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityLostItem, &lostItemID,
|
|
"Lost item status updated to: "+status, ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteLostItem deletes a lost item report
|
|
func (s *LostItemService) DeleteLostItem(userID, lostItemID uint, ipAddress, userAgent string) error {
|
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only owner can delete
|
|
if lostItem.UserID != userID {
|
|
return errors.New("unauthorized to delete this lost item report")
|
|
}
|
|
|
|
if err := s.lostItemRepo.Delete(lostItemID); err != nil {
|
|
return errors.New("failed to delete lost item report")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionDelete, models.EntityLostItem, &lostItemID,
|
|
"Lost item report deleted: "+lostItem.Name, ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetLostItemsByUser gets lost items by user
|
|
func (s *LostItemService) GetLostItemsByUser(userID uint, page, limit int) ([]models.LostItemResponse, int64, error) {
|
|
lostItems, total, err := s.lostItemRepo.FindByUser(userID, page, limit)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.LostItemResponse
|
|
for _, lostItem := range lostItems {
|
|
responses = append(responses, lostItem.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// internal/services/match_service.go
|
|
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type MatchService struct {
|
|
db *gorm.DB // Tambahkan ini
|
|
matchRepo *repositories.MatchResultRepository
|
|
itemRepo *repositories.ItemRepository
|
|
lostItemRepo *repositories.LostItemRepository
|
|
notificationRepo *repositories.NotificationRepository
|
|
}
|
|
|
|
func NewMatchService(db *gorm.DB) *MatchService {
|
|
return &MatchService{
|
|
db: db, // Tambahkan ini
|
|
matchRepo: repositories.NewMatchResultRepository(db),
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
|
notificationRepo: repositories.NewNotificationRepository(db),
|
|
}
|
|
}
|
|
|
|
// MatchedField represents a matched field between items
|
|
type MatchedField struct {
|
|
Field string `json:"field"`
|
|
LostValue string `json:"lost_value"`
|
|
FoundValue string `json:"found_value"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
// FindSimilarItems finds similar items for a lost item report
|
|
func (s *MatchService) FindSimilarItems(lostItemID uint) ([]models.MatchResultResponse, error) {
|
|
lostItem, err := s.lostItemRepo.FindByID(lostItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Search for items in same category
|
|
items, err := s.itemRepo.SearchForMatching(lostItem.CategoryID, lostItem.Name, lostItem.Color)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var results []models.MatchResultResponse
|
|
for _, item := range items {
|
|
// Calculate similarity
|
|
score, matchedFields := s.calculateSimilarity(lostItem, &item)
|
|
|
|
// Only include if score is reasonable (>= 30%)
|
|
if score >= 30.0 {
|
|
// Check if match already exists
|
|
exists, _ := s.matchRepo.CheckExistingMatch(lostItemID, item.ID)
|
|
if !exists {
|
|
// Create match result
|
|
matchedFieldsJSON, _ := json.Marshal(matchedFields)
|
|
match := &models.MatchResult{
|
|
LostItemID: lostItemID,
|
|
ItemID: item.ID,
|
|
SimilarityScore: score,
|
|
MatchedFields: string(matchedFieldsJSON),
|
|
IsNotified: false,
|
|
}
|
|
s.matchRepo.Create(match)
|
|
|
|
// Reload with relations
|
|
match, _ = s.matchRepo.FindByID(match.ID)
|
|
results = append(results, match.ToResponse())
|
|
}
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// GetMatchesForLostItem gets all matches for a lost item
|
|
func (s *MatchService) GetMatchesForLostItem(lostItemID uint) ([]models.MatchResultResponse, error) {
|
|
matches, err := s.matchRepo.FindByLostItem(lostItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var responses []models.MatchResultResponse
|
|
for _, match := range matches {
|
|
responses = append(responses, match.ToResponse())
|
|
}
|
|
|
|
return responses, nil
|
|
}
|
|
|
|
// GetMatchesForItem gets all matches for an item
|
|
func (s *MatchService) GetMatchesForItem(itemID uint) ([]models.MatchResultResponse, error) {
|
|
matches, err := s.matchRepo.FindByItem(itemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var responses []models.MatchResultResponse
|
|
for _, match := range matches {
|
|
responses = append(responses, match.ToResponse())
|
|
}
|
|
|
|
return responses, nil
|
|
}
|
|
|
|
// AutoMatchNewItem automatically matches a new item with lost items
|
|
func (s *MatchService) AutoMatchNewItem(itemID uint) error {
|
|
item, err := s.itemRepo.FindByID(itemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find active lost items in same category
|
|
lostItems, err := s.lostItemRepo.FindActiveForMatching(item.CategoryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, lostItem := range lostItems {
|
|
// Calculate similarity
|
|
score, matchedFields := s.calculateSimilarity(&lostItem, item)
|
|
|
|
// Create match if score is high enough (>= 50% for auto-match)
|
|
if score >= 50.0 {
|
|
// Check if match already exists
|
|
exists, _ := s.matchRepo.CheckExistingMatch(lostItem.ID, itemID)
|
|
if !exists {
|
|
matchedFieldsJSON, _ := json.Marshal(matchedFields)
|
|
match := &models.MatchResult{
|
|
LostItemID: lostItem.ID,
|
|
ItemID: itemID,
|
|
SimilarityScore: score,
|
|
MatchedFields: string(matchedFieldsJSON),
|
|
IsNotified: false,
|
|
}
|
|
s.matchRepo.Create(match)
|
|
|
|
// Send notification to lost item owner - PERBAIKAN DI SINI
|
|
models.CreateMatchNotification(s.db, lostItem.UserID, item.Name, match.ID)
|
|
s.matchRepo.MarkAsNotified(match.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// calculateSimilarity calculates similarity between lost item and found item
|
|
func (s *MatchService) calculateSimilarity(lostItem *models.LostItem, item *models.Item) (float64, []MatchedField) {
|
|
var matchedFields []MatchedField
|
|
totalScore := 0.0
|
|
maxScore := 0.0
|
|
|
|
// Category match (20 points)
|
|
maxScore += 20
|
|
if lostItem.CategoryID == item.CategoryID {
|
|
totalScore += 20
|
|
matchedFields = append(matchedFields, MatchedField{
|
|
Field: "category",
|
|
LostValue: lostItem.Category.Name,
|
|
FoundValue: item.Category.Name,
|
|
Score: 20,
|
|
})
|
|
}
|
|
|
|
// Name similarity (30 points)
|
|
maxScore += 30
|
|
nameSimilarity := utils.CalculateStringSimilarity(lostItem.Name, item.Name)
|
|
nameScore := nameSimilarity * 30
|
|
totalScore += nameScore
|
|
if nameScore > 10 {
|
|
matchedFields = append(matchedFields, MatchedField{
|
|
Field: "name",
|
|
LostValue: lostItem.Name,
|
|
FoundValue: item.Name,
|
|
Score: nameScore,
|
|
})
|
|
}
|
|
|
|
// Color match (15 points)
|
|
if lostItem.Color != "" {
|
|
maxScore += 15
|
|
colorSimilarity := utils.CalculateStringSimilarity(lostItem.Color, item.Name+" "+item.Description)
|
|
colorScore := colorSimilarity * 15
|
|
totalScore += colorScore
|
|
if colorScore > 5 {
|
|
matchedFields = append(matchedFields, MatchedField{
|
|
Field: "color",
|
|
LostValue: lostItem.Color,
|
|
FoundValue: "matched in description",
|
|
Score: colorScore,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Location match (20 points)
|
|
if lostItem.Location != "" {
|
|
maxScore += 20
|
|
locationSimilarity := utils.CalculateStringSimilarity(lostItem.Location, item.Location)
|
|
locationScore := locationSimilarity * 20
|
|
totalScore += locationScore
|
|
if locationScore > 10 {
|
|
matchedFields = append(matchedFields, MatchedField{
|
|
Field: "location",
|
|
LostValue: lostItem.Location,
|
|
FoundValue: item.Location,
|
|
Score: locationScore,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Description keywords match (15 points)
|
|
maxScore += 15
|
|
descSimilarity := utils.CalculateStringSimilarity(lostItem.Description, item.Description)
|
|
descScore := descSimilarity * 15
|
|
totalScore += descScore
|
|
if descScore > 5 {
|
|
matchedFields = append(matchedFields, MatchedField{
|
|
Field: "description",
|
|
LostValue: "keywords matched",
|
|
FoundValue: "keywords matched",
|
|
Score: descScore,
|
|
})
|
|
}
|
|
|
|
// Calculate percentage
|
|
percentage := (totalScore / maxScore) * 100
|
|
if percentage > 100 {
|
|
percentage = 100
|
|
}
|
|
|
|
return percentage, matchedFields
|
|
}
|
|
|
|
// internal/services/notification_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type NotificationService struct {
|
|
db *gorm.DB // Tambahkan ini
|
|
notificationRepo *repositories.NotificationRepository
|
|
}
|
|
|
|
func NewNotificationService(db *gorm.DB) *NotificationService {
|
|
return &NotificationService{
|
|
db: db, // Tambahkan ini
|
|
notificationRepo: repositories.NewNotificationRepository(db),
|
|
}
|
|
}
|
|
// GetUserNotifications gets notifications for a user
|
|
func (s *NotificationService) GetUserNotifications(userID uint, page, limit int, onlyUnread bool) ([]models.NotificationResponse, int64, error) {
|
|
notifications, total, err := s.notificationRepo.FindByUser(userID, page, limit, onlyUnread)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var responses []models.NotificationResponse
|
|
for _, notification := range notifications {
|
|
responses = append(responses, notification.ToResponse())
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// GetNotificationByID gets notification by ID
|
|
func (s *NotificationService) GetNotificationByID(userID, notificationID uint) (*models.Notification, error) {
|
|
notification, err := s.notificationRepo.FindByID(notificationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check ownership
|
|
if notification.UserID != userID {
|
|
return nil, errors.New("unauthorized")
|
|
}
|
|
|
|
return notification, nil
|
|
}
|
|
|
|
// MarkAsRead marks a notification as read
|
|
func (s *NotificationService) MarkAsRead(userID, notificationID uint) error {
|
|
notification, err := s.notificationRepo.FindByID(notificationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check ownership
|
|
if notification.UserID != userID {
|
|
return errors.New("unauthorized")
|
|
}
|
|
|
|
return s.notificationRepo.MarkAsRead(notificationID)
|
|
}
|
|
|
|
// MarkAllAsRead marks all notifications as read for a user
|
|
func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
|
return s.notificationRepo.MarkAllAsRead(userID)
|
|
}
|
|
|
|
// DeleteNotification deletes a notification
|
|
func (s *NotificationService) DeleteNotification(userID, notificationID uint) error {
|
|
notification, err := s.notificationRepo.FindByID(notificationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check ownership
|
|
if notification.UserID != userID {
|
|
return errors.New("unauthorized")
|
|
}
|
|
|
|
return s.notificationRepo.Delete(notificationID)
|
|
}
|
|
|
|
// DeleteAllNotifications deletes all notifications for a user
|
|
func (s *NotificationService) DeleteAllNotifications(userID uint) error {
|
|
return s.notificationRepo.DeleteAllForUser(userID)
|
|
}
|
|
|
|
// CountUnread counts unread notifications for a user
|
|
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
|
|
return s.notificationRepo.CountUnread(userID)
|
|
}
|
|
|
|
// CreateNotification creates a new notification
|
|
func (s *NotificationService) CreateNotification(userID uint, notifType, title, message, entityType string, entityID *uint) error {
|
|
return s.notificationRepo.Notify(userID, notifType, title, message, entityType, entityID)
|
|
}
|
|
|
|
// SendMatchNotification sends notification when a match is found
|
|
func (s *NotificationService) SendMatchNotification(userID uint, itemName string, matchID uint) error {
|
|
return models.CreateMatchNotification(s.db, userID, itemName, matchID)
|
|
}
|
|
|
|
// SendClaimApprovedNotification sends notification when claim is approved
|
|
func (s *NotificationService) SendClaimApprovedNotification(userID uint, itemName string, claimID uint) error {
|
|
return models.CreateClaimApprovedNotification(s.db, userID, itemName, claimID)
|
|
}
|
|
|
|
// SendClaimRejectedNotification sends notification when claim is rejected
|
|
func (s *NotificationService) SendClaimRejectedNotification(userID uint, itemName, reason string, claimID uint) error {
|
|
return models.CreateClaimRejectedNotification(s.db, userID, itemName, reason, claimID)
|
|
}
|
|
|
|
// internal/services/user_service.go
|
|
package services
|
|
|
|
import (
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type UserService struct {
|
|
userRepo *repositories.UserRepository
|
|
roleRepo *repositories.RoleRepository
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
}
|
|
|
|
func NewUserService(db *gorm.DB) *UserService {
|
|
return &UserService{
|
|
userRepo: repositories.NewUserRepository(db),
|
|
roleRepo: repositories.NewRoleRepository(db),
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
}
|
|
}
|
|
|
|
// UpdateProfileRequest represents profile update data
|
|
type UpdateProfileRequest struct {
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
NRP string `json:"nrp"`
|
|
}
|
|
|
|
// ChangePasswordRequest represents password change data
|
|
type ChangePasswordRequest struct {
|
|
OldPassword string `json:"old_password" binding:"required"`
|
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
|
}
|
|
|
|
// GetProfile gets user profile
|
|
func (s *UserService) GetProfile(userID uint) (*models.User, error) {
|
|
return s.userRepo.FindByID(userID)
|
|
}
|
|
|
|
// UpdateProfile updates user profile
|
|
func (s *UserService) UpdateProfile(userID uint, req UpdateProfileRequest, ipAddress, userAgent string) (*models.User, error) {
|
|
user, err := s.userRepo.FindByID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update fields
|
|
if req.Name != "" {
|
|
user.Name = req.Name
|
|
}
|
|
if req.Phone != "" {
|
|
user.Phone = req.Phone
|
|
}
|
|
if req.NRP != "" {
|
|
// Check if NRP already exists for another user
|
|
existingNRP, _ := s.userRepo.FindByNRP(req.NRP)
|
|
if existingNRP != nil && existingNRP.ID != userID {
|
|
return nil, errors.New("NRP already used by another user")
|
|
}
|
|
user.NRP = req.NRP
|
|
}
|
|
|
|
if err := s.userRepo.Update(user); err != nil {
|
|
return nil, errors.New("failed to update profile")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
|
|
"Profile updated", ipAddress, userAgent)
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// ChangePassword changes user password
|
|
func (s *UserService) ChangePassword(userID uint, req ChangePasswordRequest, ipAddress, userAgent string) error {
|
|
user, err := s.userRepo.FindByID(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify old password
|
|
if !utils.CheckPasswordHash(req.OldPassword, user.Password) {
|
|
return errors.New("invalid old password")
|
|
}
|
|
|
|
// Hash new password
|
|
hashedPassword, err := utils.HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
return errors.New("failed to hash password")
|
|
}
|
|
|
|
user.Password = hashedPassword
|
|
if err := s.userRepo.Update(user); err != nil {
|
|
return errors.New("failed to change password")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&userID, models.ActionUpdate, models.EntityUser, &userID,
|
|
"Password changed", ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUserStats gets user statistics
|
|
func (s *UserService) GetUserStats(userID uint) (map[string]interface{}, error) {
|
|
return s.userRepo.GetUserStats(userID)
|
|
}
|
|
|
|
// GetAllUsers gets all users (admin only)
|
|
func (s *UserService) GetAllUsers(page, limit int) ([]models.User, int64, error) {
|
|
return s.userRepo.FindAll(page, limit)
|
|
}
|
|
|
|
// GetUserByID gets user by ID (admin only)
|
|
func (s *UserService) GetUserByID(id uint) (*models.User, error) {
|
|
return s.userRepo.FindByID(id)
|
|
}
|
|
|
|
// UpdateUserRole updates user role (admin only)
|
|
func (s *UserService) UpdateUserRole(adminID, userID, roleID uint, ipAddress, userAgent string) error {
|
|
// Verify role exists
|
|
role, err := s.roleRepo.FindByID(roleID)
|
|
if err != nil {
|
|
return errors.New("invalid role")
|
|
}
|
|
|
|
// Update role
|
|
if err := s.userRepo.UpdateRole(userID, roleID); err != nil {
|
|
return errors.New("failed to update user role")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionUpdate, models.EntityUser, &userID,
|
|
"Role updated to: "+role.Name, ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// BlockUser blocks a user (admin only)
|
|
func (s *UserService) BlockUser(adminID, userID uint, ipAddress, userAgent string) error {
|
|
// Cannot block self
|
|
if adminID == userID {
|
|
return errors.New("cannot block yourself")
|
|
}
|
|
|
|
if err := s.userRepo.BlockUser(userID); err != nil {
|
|
return errors.New("failed to block user")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionBlock, models.EntityUser, &userID,
|
|
"User blocked", ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnblockUser unblocks a user (admin only)
|
|
func (s *UserService) UnblockUser(adminID, userID uint, ipAddress, userAgent string) error {
|
|
if err := s.userRepo.UnblockUser(userID); err != nil {
|
|
return errors.New("failed to unblock user")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionUnblock, models.EntityUser, &userID,
|
|
"User unblocked", ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteUser deletes a user (admin only)
|
|
func (s *UserService) DeleteUser(adminID, userID uint, ipAddress, userAgent string) error {
|
|
// Cannot delete self
|
|
if adminID == userID {
|
|
return errors.New("cannot delete yourself")
|
|
}
|
|
|
|
if err := s.userRepo.Delete(userID); err != nil {
|
|
return errors.New("failed to delete user")
|
|
}
|
|
|
|
// Log audit
|
|
s.auditLogRepo.Log(&adminID, models.ActionDelete, models.EntityUser, &userID,
|
|
"User deleted", ipAddress, userAgent)
|
|
|
|
return nil
|
|
}
|
|
|
|
// internal/services/verification_service.go
|
|
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type VerificationService struct {
|
|
verificationRepo *repositories.ClaimVerificationRepository
|
|
claimRepo *repositories.ClaimRepository
|
|
itemRepo *repositories.ItemRepository
|
|
}
|
|
|
|
func NewVerificationService(db *gorm.DB) *VerificationService {
|
|
return &VerificationService{
|
|
verificationRepo: repositories.NewClaimVerificationRepository(db),
|
|
claimRepo: repositories.NewClaimRepository(db),
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
}
|
|
}
|
|
|
|
// VerificationResult represents the verification result
|
|
type VerificationResult struct {
|
|
SimilarityScore float64 `json:"similarity_score"`
|
|
MatchLevel string `json:"match_level"`
|
|
MatchedKeywords []string `json:"matched_keywords"`
|
|
Details map[string]string `json:"details"`
|
|
Recommendation string `json:"recommendation"`
|
|
}
|
|
|
|
// VerifyClaimDescription verifies claim description against item description
|
|
func (s *VerificationService) VerifyClaimDescription(claimID uint) (*VerificationResult, error) {
|
|
claim, err := s.claimRepo.FindByID(claimID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
item, err := s.itemRepo.FindByID(claim.ItemID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Calculate similarity between claim description and item description
|
|
similarity := utils.CalculateStringSimilarity(claim.Description, item.Description)
|
|
similarityPercent := similarity * 100
|
|
|
|
// Extract matched keywords
|
|
claimKeywords := utils.ExtractKeywords(claim.Description)
|
|
itemKeywords := utils.ExtractKeywords(item.Description)
|
|
matchedKeywords := utils.FindMatchedKeywords(claimKeywords, itemKeywords)
|
|
|
|
// Determine match level
|
|
matchLevel := "low"
|
|
recommendation := "REJECT - Deskripsi tidak cocok"
|
|
|
|
if similarityPercent >= 70.0 {
|
|
matchLevel = "high"
|
|
recommendation = "APPROVE - Deskripsi sangat cocok"
|
|
} else if similarityPercent >= 50.0 {
|
|
matchLevel = "medium"
|
|
recommendation = "REVIEW - Perlu verifikasi lebih lanjut"
|
|
}
|
|
|
|
// Create or update verification record
|
|
verification, _ := s.verificationRepo.FindByClaimID(claimID)
|
|
if verification == nil {
|
|
verification = &models.ClaimVerification{
|
|
ClaimID: claimID,
|
|
SimilarityScore: similarityPercent,
|
|
MatchedKeywords: stringSliceToJSON(matchedKeywords),
|
|
IsAutoMatched: false,
|
|
}
|
|
s.verificationRepo.Create(verification)
|
|
} else {
|
|
verification.SimilarityScore = similarityPercent
|
|
verification.MatchedKeywords = stringSliceToJSON(matchedKeywords)
|
|
s.verificationRepo.Update(verification)
|
|
}
|
|
|
|
return &VerificationResult{
|
|
SimilarityScore: similarityPercent,
|
|
MatchLevel: matchLevel,
|
|
MatchedKeywords: matchedKeywords,
|
|
Details: map[string]string{
|
|
"claim_description": claim.Description,
|
|
"item_description": item.Description,
|
|
"matched_count": string(len(matchedKeywords)),
|
|
},
|
|
Recommendation: recommendation,
|
|
}, nil
|
|
}
|
|
|
|
// GetVerificationByClaimID gets verification data for a claim
|
|
func (s *VerificationService) GetVerificationByClaimID(claimID uint) (*models.ClaimVerification, error) {
|
|
verification, err := s.verificationRepo.FindByClaimID(claimID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if verification == nil {
|
|
return nil, errors.New("verification not found")
|
|
}
|
|
return verification, nil
|
|
}
|
|
|
|
// GetHighMatchVerifications gets all high match verifications
|
|
func (s *VerificationService) GetHighMatchVerifications() ([]models.ClaimVerificationResponse, error) {
|
|
verifications, err := s.verificationRepo.FindHighMatches()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var responses []models.ClaimVerificationResponse
|
|
for _, v := range verifications {
|
|
responses = append(responses, v.ToResponse())
|
|
}
|
|
|
|
return responses, nil
|
|
}
|
|
|
|
// CompareDescriptions provides detailed comparison between two descriptions
|
|
func (s *VerificationService) CompareDescriptions(desc1, desc2 string) map[string]interface{} {
|
|
similarity := utils.CalculateStringSimilarity(desc1, desc2)
|
|
|
|
keywords1 := utils.ExtractKeywords(desc1)
|
|
keywords2 := utils.ExtractKeywords(desc2)
|
|
matchedKeywords := utils.FindMatchedKeywords(keywords1, keywords2)
|
|
|
|
return map[string]interface{}{
|
|
"similarity_score": similarity * 100,
|
|
"description_1": desc1,
|
|
"description_2": desc2,
|
|
"keywords_1": keywords1,
|
|
"keywords_2": keywords2,
|
|
"matched_keywords": matchedKeywords,
|
|
"total_keywords_1": len(keywords1),
|
|
"total_keywords_2": len(keywords2),
|
|
"matched_count": len(matchedKeywords),
|
|
}
|
|
}
|
|
|
|
// Helper function to convert string slice to JSON
|
|
func stringSliceToJSON(slice []string) string {
|
|
if len(slice) == 0 {
|
|
return "[]"
|
|
}
|
|
data, _ := json.Marshal(slice)
|
|
return string(data)
|
|
}
|
|
|
|
// internal/utils/error.go
|
|
package utils
|
|
|
|
import "fmt"
|
|
|
|
// AppError represents a custom application error
|
|
type AppError struct {
|
|
Code string
|
|
Message string
|
|
Err error
|
|
}
|
|
|
|
func (e *AppError) Error() string {
|
|
if e.Err != nil {
|
|
return fmt.Sprintf("%s: %s (%v)", e.Code, e.Message, e.Err)
|
|
}
|
|
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
|
}
|
|
|
|
// NewAppError creates a new application error
|
|
func NewAppError(code, message string, err error) *AppError {
|
|
return &AppError{
|
|
Code: code,
|
|
Message: message,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
// Common error codes
|
|
const (
|
|
ErrCodeValidation = "VALIDATION_ERROR"
|
|
ErrCodeNotFound = "NOT_FOUND"
|
|
ErrCodeUnauthorized = "UNAUTHORIZED"
|
|
ErrCodeForbidden = "FORBIDDEN"
|
|
ErrCodeInternal = "INTERNAL_ERROR"
|
|
ErrCodeDuplicate = "DUPLICATE_ERROR"
|
|
ErrCodeBadRequest = "BAD_REQUEST"
|
|
)
|
|
|
|
// Error constructors
|
|
func ValidationError(message string) *AppError {
|
|
return NewAppError(ErrCodeValidation, message, nil)
|
|
}
|
|
|
|
func NotFoundError(message string) *AppError {
|
|
return NewAppError(ErrCodeNotFound, message, nil)
|
|
}
|
|
|
|
func UnauthorizedError(message string) *AppError {
|
|
return NewAppError(ErrCodeUnauthorized, message, nil)
|
|
}
|
|
|
|
func ForbiddenError(message string) *AppError {
|
|
return NewAppError(ErrCodeForbidden, message, nil)
|
|
}
|
|
|
|
func InternalError(message string, err error) *AppError {
|
|
return NewAppError(ErrCodeInternal, message, err)
|
|
}
|
|
|
|
func DuplicateError(message string) *AppError {
|
|
return NewAppError(ErrCodeDuplicate, message, nil)
|
|
}
|
|
|
|
func BadRequestError(message string) *AppError {
|
|
return NewAppError(ErrCodeBadRequest, message, nil)
|
|
}
|
|
|
|
// internal/utils/excel_export.go
|
|
package utils
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
|
|
"github.com/xuri/excelize/v2"
|
|
)
|
|
|
|
// ExcelExporter handles Excel file generation
|
|
type ExcelExporter struct {
|
|
file *excelize.File
|
|
sheetName string
|
|
rowIndex int
|
|
}
|
|
|
|
// NewExcelExporter creates a new Excel exporter
|
|
func NewExcelExporter() *ExcelExporter {
|
|
f := excelize.NewFile()
|
|
return &ExcelExporter{
|
|
file: f,
|
|
sheetName: "Sheet1",
|
|
rowIndex: 1,
|
|
}
|
|
}
|
|
|
|
// SetSheetName sets the active sheet name
|
|
func (e *ExcelExporter) SetSheetName(name string) {
|
|
e.file.SetSheetName("Sheet1", name)
|
|
e.sheetName = name
|
|
}
|
|
|
|
// AddRow adds a row of data
|
|
func (e *ExcelExporter) AddRow(data []string) error {
|
|
for colIndex, value := range data {
|
|
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
|
|
if err := e.file.SetCellValue(e.sheetName, cell, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
e.rowIndex++
|
|
return nil
|
|
}
|
|
|
|
// AddHeader adds a header row with bold style
|
|
func (e *ExcelExporter) AddHeader(headers []string) error {
|
|
style, err := e.file.NewStyle(&excelize.Style{
|
|
Font: &excelize.Font{
|
|
Bold: true,
|
|
},
|
|
Fill: excelize.Fill{
|
|
Type: "pattern",
|
|
Color: []string{"#D3D3D3"},
|
|
Pattern: 1,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for colIndex, header := range headers {
|
|
cell := fmt.Sprintf("%s%d", getColumnName(colIndex), e.rowIndex)
|
|
if err := e.file.SetCellValue(e.sheetName, cell, header); err != nil {
|
|
return err
|
|
}
|
|
if err := e.file.SetCellStyle(e.sheetName, cell, cell, style); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
e.rowIndex++
|
|
return nil
|
|
}
|
|
|
|
// AutoSizeColumns auto-sizes columns
|
|
func (e *ExcelExporter) AutoSizeColumns(columnCount int) {
|
|
for i := 0; i < columnCount; i++ {
|
|
col := getColumnName(i)
|
|
e.file.SetColWidth(e.sheetName, col, col, 15)
|
|
}
|
|
}
|
|
|
|
// Output returns the Excel file as bytes
|
|
func (e *ExcelExporter) Output() (*bytes.Buffer, error) {
|
|
buf := new(bytes.Buffer)
|
|
if err := e.file.Write(buf); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
// getColumnName converts column index to Excel column name (A, B, C, ..., AA, AB, ...)
|
|
func getColumnName(index int) string {
|
|
name := ""
|
|
for index >= 0 {
|
|
name = string(rune('A'+(index%26))) + name
|
|
index = index/26 - 1
|
|
}
|
|
return name
|
|
}
|
|
|
|
// internal/utils/hash.go
|
|
package utils
|
|
|
|
import (
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// HashPassword hashes a password using bcrypt
|
|
func HashPassword(password string) (string, error) {
|
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
return string(bytes), err
|
|
}
|
|
|
|
// CheckPasswordHash compares a password with a hash
|
|
func CheckPasswordHash(password, hash string) bool {
|
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
|
return err == nil
|
|
}
|
|
|
|
// internal/utils/image_handler.go
|
|
package utils
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"mime/multipart"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nfnt/resize"
|
|
)
|
|
|
|
// ImageHandler handles image upload and processing
|
|
type ImageHandler struct {
|
|
uploadPath string
|
|
maxSize int64
|
|
allowedTypes []string
|
|
maxWidth uint
|
|
maxHeight uint
|
|
}
|
|
|
|
// NewImageHandler creates a new image handler
|
|
func NewImageHandler(uploadPath string) *ImageHandler {
|
|
return &ImageHandler{
|
|
uploadPath: uploadPath,
|
|
maxSize: 10 * 1024 * 1024, // 10MB
|
|
allowedTypes: []string{"image/jpeg", "image/jpg", "image/png"},
|
|
maxWidth: 1920,
|
|
maxHeight: 1080,
|
|
}
|
|
}
|
|
|
|
// UploadImage uploads and processes an image
|
|
func (h *ImageHandler) UploadImage(file *multipart.FileHeader, subfolder string) (string, error) {
|
|
// Check file size
|
|
if file.Size > h.maxSize {
|
|
return "", errors.New("file size exceeds maximum allowed size")
|
|
}
|
|
|
|
// Check file type
|
|
if !h.isAllowedType(file.Header.Get("Content-Type")) {
|
|
return "", errors.New("file type not allowed")
|
|
}
|
|
|
|
// Open uploaded file
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer src.Close()
|
|
|
|
// Generate unique filename
|
|
ext := filepath.Ext(file.Filename)
|
|
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext)
|
|
|
|
// Create upload directory if not exists
|
|
uploadDir := filepath.Join(h.uploadPath, subfolder)
|
|
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Full file path
|
|
filePath := filepath.Join(uploadDir, filename)
|
|
|
|
// Decode image
|
|
img, format, err := image.Decode(src)
|
|
if err != nil {
|
|
return "", errors.New("invalid image file")
|
|
}
|
|
|
|
// Resize if necessary
|
|
if uint(img.Bounds().Dx()) > h.maxWidth || uint(img.Bounds().Dy()) > h.maxHeight {
|
|
img = resize.Thumbnail(h.maxWidth, h.maxHeight, img, resize.Lanczos3)
|
|
}
|
|
|
|
// Create destination file
|
|
dst, err := os.Create(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer dst.Close()
|
|
|
|
// Encode and save image
|
|
switch format {
|
|
case "jpeg", "jpg":
|
|
if err := jpeg.Encode(dst, img, &jpeg.Options{Quality: 90}); err != nil {
|
|
return "", err
|
|
}
|
|
case "png":
|
|
if err := png.Encode(dst, img); err != nil {
|
|
return "", err
|
|
}
|
|
default:
|
|
return "", errors.New("unsupported image format")
|
|
}
|
|
|
|
// Return relative path
|
|
return filepath.Join(subfolder, filename), nil
|
|
}
|
|
|
|
// UploadImageSimple uploads image without processing
|
|
func (h *ImageHandler) UploadImageSimple(file *multipart.FileHeader, subfolder string) (string, error) {
|
|
// Check file size
|
|
if file.Size > h.maxSize {
|
|
return "", errors.New("file size exceeds maximum allowed size")
|
|
}
|
|
|
|
// Check file type
|
|
if !h.isAllowedType(file.Header.Get("Content-Type")) {
|
|
return "", errors.New("file type not allowed")
|
|
}
|
|
|
|
// Open uploaded file
|
|
src, err := file.Open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer src.Close()
|
|
|
|
// Generate unique filename
|
|
ext := filepath.Ext(file.Filename)
|
|
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), generateRandomString(8), ext)
|
|
|
|
// Create upload directory if not exists
|
|
uploadDir := filepath.Join(h.uploadPath, subfolder)
|
|
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Full file path
|
|
filePath := filepath.Join(uploadDir, filename)
|
|
|
|
// Create destination file
|
|
dst, err := os.Create(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer dst.Close()
|
|
|
|
// Copy file
|
|
if _, err := io.Copy(dst, src); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Return relative path
|
|
return filepath.Join(subfolder, filename), nil
|
|
}
|
|
|
|
// DeleteImage deletes an image file
|
|
func (h *ImageHandler) DeleteImage(relativePath string) error {
|
|
if relativePath == "" {
|
|
return nil
|
|
}
|
|
|
|
filePath := filepath.Join(h.uploadPath, relativePath)
|
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
|
return nil // File doesn't exist, no error
|
|
}
|
|
|
|
return os.Remove(filePath)
|
|
}
|
|
|
|
// isAllowedType checks if file type is allowed
|
|
func (h *ImageHandler) isAllowedType(contentType string) bool {
|
|
for _, allowed := range h.allowedTypes {
|
|
if strings.EqualFold(contentType, allowed) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// generateRandomString generates a random string
|
|
func generateRandomString(length int) string {
|
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
b := make([]byte, length)
|
|
for i := range b {
|
|
b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// internal/utils/matching.go
|
|
package utils
|
|
|
|
import (
|
|
"strings"
|
|
)
|
|
|
|
// CalculateMatchScore calculates match score between two items
|
|
func CalculateMatchScore(item1, item2 map[string]interface{}) float64 {
|
|
totalScore := 0.0
|
|
maxScore := 0.0
|
|
|
|
// Name matching (30%)
|
|
maxScore += 30
|
|
if name1, ok1 := item1["name"].(string); ok1 {
|
|
if name2, ok2 := item2["name"].(string); ok2 {
|
|
similarity := CalculateStringSimilarity(name1, name2)
|
|
totalScore += similarity * 30
|
|
}
|
|
}
|
|
|
|
// Category matching (20%)
|
|
maxScore += 20
|
|
if cat1, ok1 := item1["category"].(string); ok1 {
|
|
if cat2, ok2 := item2["category"].(string); ok2 {
|
|
if strings.EqualFold(cat1, cat2) {
|
|
totalScore += 20
|
|
}
|
|
}
|
|
}
|
|
|
|
// Color matching (15%)
|
|
if color1, ok1 := item1["color"].(string); ok1 {
|
|
if color1 != "" {
|
|
maxScore += 15
|
|
if color2, ok2 := item2["color"].(string); ok2 {
|
|
if strings.Contains(strings.ToLower(color2), strings.ToLower(color1)) {
|
|
totalScore += 15
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Location matching (20%)
|
|
if loc1, ok1 := item1["location"].(string); ok1 {
|
|
if loc1 != "" {
|
|
maxScore += 20
|
|
if loc2, ok2 := item2["location"].(string); ok2 {
|
|
similarity := CalculateStringSimilarity(loc1, loc2)
|
|
totalScore += similarity * 20
|
|
}
|
|
}
|
|
}
|
|
|
|
// Description matching (15%)
|
|
maxScore += 15
|
|
if desc1, ok1 := item1["description"].(string); ok1 {
|
|
if desc2, ok2 := item2["description"].(string); ok2 {
|
|
similarity := CalculateStringSimilarity(desc1, desc2)
|
|
totalScore += similarity * 15
|
|
}
|
|
}
|
|
|
|
if maxScore == 0 {
|
|
return 0
|
|
}
|
|
|
|
return (totalScore / maxScore) * 100
|
|
}
|
|
|
|
// MatchItems matches items based on criteria
|
|
func MatchItems(lostItem, foundItems []map[string]interface{}, threshold float64) []map[string]interface{} {
|
|
var matches []map[string]interface{}
|
|
|
|
if len(lostItem) == 0 || len(foundItems) == 0 {
|
|
return matches
|
|
}
|
|
|
|
lost := lostItem[0]
|
|
|
|
for _, found := range foundItems {
|
|
score := CalculateMatchScore(lost, found)
|
|
if score >= threshold {
|
|
match := make(map[string]interface{})
|
|
match["item"] = found
|
|
match["score"] = score
|
|
match["level"] = getMatchLevel(score)
|
|
matches = append(matches, match)
|
|
}
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
// getMatchLevel returns match level based on score
|
|
func getMatchLevel(score float64) string {
|
|
if score >= 70 {
|
|
return "high"
|
|
} else if score >= 50 {
|
|
return "medium"
|
|
}
|
|
return "low"
|
|
}
|
|
|
|
// internal/utils/pdf_export.go
|
|
package utils
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
|
|
"github.com/jung-kurt/gofpdf"
|
|
)
|
|
|
|
// PDFExporter handles PDF generation
|
|
type PDFExporter struct {
|
|
pdf *gofpdf.Fpdf
|
|
}
|
|
|
|
// NewPDFExporter creates a new PDF exporter
|
|
func NewPDFExporter() *PDFExporter {
|
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
|
pdf.AddPage()
|
|
pdf.SetFont("Arial", "", 12)
|
|
|
|
return &PDFExporter{
|
|
pdf: pdf,
|
|
}
|
|
}
|
|
|
|
// AddTitle adds a title to the PDF
|
|
func (e *PDFExporter) AddTitle(title string) {
|
|
e.pdf.SetFont("Arial", "B", 16)
|
|
e.pdf.Cell(0, 10, title)
|
|
e.pdf.Ln(12)
|
|
e.pdf.SetFont("Arial", "", 12)
|
|
}
|
|
|
|
// AddSubtitle adds a subtitle to the PDF
|
|
func (e *PDFExporter) AddSubtitle(subtitle string) {
|
|
e.pdf.SetFont("Arial", "I", 11)
|
|
e.pdf.Cell(0, 8, subtitle)
|
|
e.pdf.Ln(10)
|
|
e.pdf.SetFont("Arial", "", 12)
|
|
}
|
|
|
|
// AddText adds regular text
|
|
func (e *PDFExporter) AddText(text string) {
|
|
e.pdf.SetFont("Arial", "", 10)
|
|
e.pdf.MultiCell(0, 6, text, "", "", false)
|
|
e.pdf.Ln(4)
|
|
}
|
|
|
|
// AddNewLine adds a new line
|
|
func (e *PDFExporter) AddNewLine() {
|
|
e.pdf.Ln(6)
|
|
}
|
|
|
|
// AddTable adds a table to the PDF
|
|
func (e *PDFExporter) AddTable(headers []string, data [][]string) {
|
|
// Calculate column widths
|
|
pageWidth, _ := e.pdf.GetPageSize()
|
|
margins := 20.0 // Left + Right margins
|
|
tableWidth := pageWidth - margins
|
|
colWidth := tableWidth / float64(len(headers))
|
|
|
|
// Add headers
|
|
e.pdf.SetFont("Arial", "B", 10)
|
|
e.pdf.SetFillColor(200, 200, 200)
|
|
for _, header := range headers {
|
|
e.pdf.CellFormat(colWidth, 8, header, "1", 0, "C", true, 0, "")
|
|
}
|
|
e.pdf.Ln(-1)
|
|
|
|
// Add data rows
|
|
e.pdf.SetFont("Arial", "", 9)
|
|
e.pdf.SetFillColor(255, 255, 255)
|
|
|
|
fill := false
|
|
for _, row := range data {
|
|
for _, cell := range row {
|
|
if fill {
|
|
e.pdf.SetFillColor(245, 245, 245)
|
|
} else {
|
|
e.pdf.SetFillColor(255, 255, 255)
|
|
}
|
|
e.pdf.CellFormat(colWidth, 7, cell, "1", 0, "L", true, 0, "")
|
|
}
|
|
e.pdf.Ln(-1)
|
|
fill = !fill
|
|
}
|
|
}
|
|
|
|
// AddPageNumber adds page numbers
|
|
func (e *PDFExporter) AddPageNumber() {
|
|
e.pdf.AliasNbPages("")
|
|
e.pdf.SetY(-15)
|
|
e.pdf.SetFont("Arial", "I", 8)
|
|
e.pdf.CellFormat(0, 10, fmt.Sprintf("Halaman %d/{nb}", e.pdf.PageNo()), "", 0, "C", false, 0, "")
|
|
}
|
|
|
|
// Output returns the PDF as bytes
|
|
func (e *PDFExporter) Output() *bytes.Buffer {
|
|
var buf bytes.Buffer
|
|
err := e.pdf.Output(&buf)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
// internal/utils/response.go
|
|
package utils
|
|
|
|
import (
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Response represents a standard API response
|
|
type Response struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PaginatedResponse represents a paginated API response
|
|
type PaginatedResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
Data interface{} `json:"data"`
|
|
Pagination Pagination `json:"pagination"`
|
|
}
|
|
|
|
// Pagination represents pagination metadata
|
|
type Pagination struct {
|
|
CurrentPage int `json:"current_page"`
|
|
PerPage int `json:"per_page"`
|
|
TotalPages int `json:"total_pages"`
|
|
TotalRecords int64 `json:"total_records"`
|
|
}
|
|
|
|
// SuccessResponse sends a success response
|
|
func SuccessResponse(ctx *gin.Context, statusCode int, message string, data interface{}) {
|
|
ctx.JSON(statusCode, Response{
|
|
Success: true,
|
|
Message: message,
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
// ErrorResponse sends an error response
|
|
func ErrorResponse(ctx *gin.Context, statusCode int, message string, error string) {
|
|
ctx.JSON(statusCode, Response{
|
|
Success: false,
|
|
Message: message,
|
|
Error: error,
|
|
})
|
|
}
|
|
|
|
// SendPaginatedResponse sends a paginated response (nama fungsi diubah untuk menghindari konflik)
|
|
func SendPaginatedResponse(ctx *gin.Context, statusCode int, message string, data interface{}, total int64, page, limit int) {
|
|
totalPages := int(total) / limit
|
|
if int(total)%limit != 0 {
|
|
totalPages++
|
|
}
|
|
|
|
ctx.JSON(statusCode, PaginatedResponse{
|
|
Success: true,
|
|
Message: message,
|
|
Data: data,
|
|
Pagination: Pagination{
|
|
CurrentPage: page,
|
|
PerPage: limit,
|
|
TotalPages: totalPages,
|
|
TotalRecords: total,
|
|
},
|
|
})
|
|
}
|
|
|
|
// internal/utils/similarity.go
|
|
package utils
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// CalculateStringSimilarity calculates similarity between two strings using Levenshtein distance
|
|
func CalculateStringSimilarity(s1, s2 string) float64 {
|
|
// Normalize strings
|
|
s1 = normalizeString(s1)
|
|
s2 = normalizeString(s2)
|
|
|
|
if s1 == s2 {
|
|
return 1.0
|
|
}
|
|
|
|
if len(s1) == 0 || len(s2) == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
// Calculate Levenshtein distance
|
|
distance := levenshteinDistance(s1, s2)
|
|
maxLen := math.Max(float64(len(s1)), float64(len(s2)))
|
|
|
|
similarity := 1.0 - (float64(distance) / maxLen)
|
|
return math.Max(0, similarity)
|
|
}
|
|
|
|
// levenshteinDistance calculates the Levenshtein distance between two strings
|
|
func levenshteinDistance(s1, s2 string) int {
|
|
len1 := len(s1)
|
|
len2 := len(s2)
|
|
|
|
// Create a 2D slice for dynamic programming
|
|
dp := make([][]int, len1+1)
|
|
for i := range dp {
|
|
dp[i] = make([]int, len2+1)
|
|
}
|
|
|
|
// Initialize first row and column
|
|
for i := 0; i <= len1; i++ {
|
|
dp[i][0] = i
|
|
}
|
|
for j := 0; j <= len2; j++ {
|
|
dp[0][j] = j
|
|
}
|
|
|
|
// Fill the dp table
|
|
for i := 1; i <= len1; i++ {
|
|
for j := 1; j <= len2; j++ {
|
|
cost := 0
|
|
if s1[i-1] != s2[j-1] {
|
|
cost = 1
|
|
}
|
|
|
|
dp[i][j] = min3(
|
|
dp[i-1][j]+1, // deletion
|
|
dp[i][j-1]+1, // insertion
|
|
dp[i-1][j-1]+cost, // substitution
|
|
)
|
|
}
|
|
}
|
|
|
|
return dp[len1][len2]
|
|
}
|
|
|
|
// ExtractKeywords extracts keywords from a string
|
|
func ExtractKeywords(text string) []string {
|
|
// Normalize and split text
|
|
text = normalizeString(text)
|
|
words := strings.Fields(text)
|
|
|
|
// Filter stopwords and short words
|
|
var keywords []string
|
|
stopwords := getStopwords()
|
|
|
|
for _, word := range words {
|
|
if len(word) > 2 && !contains(stopwords, word) {
|
|
keywords = append(keywords, word)
|
|
}
|
|
}
|
|
|
|
return keywords
|
|
}
|
|
|
|
// FindMatchedKeywords finds common keywords between two lists
|
|
func FindMatchedKeywords(keywords1, keywords2 []string) []string {
|
|
var matched []string
|
|
|
|
for _, k1 := range keywords1 {
|
|
for _, k2 := range keywords2 {
|
|
if strings.EqualFold(k1, k2) || CalculateStringSimilarity(k1, k2) > 0.8 {
|
|
if !contains(matched, k1) {
|
|
matched = append(matched, k1)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return matched
|
|
}
|
|
|
|
// normalizeString normalizes a string (lowercase, remove extra spaces)
|
|
func normalizeString(s string) string {
|
|
// Convert to lowercase
|
|
s = strings.ToLower(s)
|
|
|
|
// Remove punctuation and extra spaces
|
|
var result strings.Builder
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) {
|
|
result.WriteRune(r)
|
|
} else {
|
|
result.WriteRune(' ')
|
|
}
|
|
}
|
|
|
|
// Remove multiple spaces
|
|
s = strings.Join(strings.Fields(result.String()), " ")
|
|
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// getStopwords returns common Indonesian stopwords
|
|
func getStopwords() []string {
|
|
return []string{
|
|
"dan", "atau", "dengan", "untuk", "dari", "ke", "di", "yang", "ini", "itu",
|
|
"ada", "adalah", "akan", "telah", "sudah", "pada", "oleh", "sebagai", "dalam",
|
|
"juga", "saya", "kamu", "dia", "kita", "mereka", "kami", "the", "a", "an",
|
|
"of", "to", "in", "for", "on", "at", "by", "with", "from",
|
|
}
|
|
}
|
|
|
|
// contains checks if a slice contains a string
|
|
func contains(slice []string, str string) bool {
|
|
for _, s := range slice {
|
|
if strings.EqualFold(s, str) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// min3 returns the minimum of three integers
|
|
func min3(a, b, c int) int {
|
|
if a < b {
|
|
if a < c {
|
|
return a
|
|
}
|
|
return c
|
|
}
|
|
if b < c {
|
|
return b
|
|
}
|
|
return c
|
|
}
|
|
|
|
// internal/utils/validator.go
|
|
package utils
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
)
|
|
|
|
var validate *validator.Validate
|
|
|
|
// InitValidator initializes the validator
|
|
func InitValidator() {
|
|
validate = validator.New()
|
|
|
|
// Register custom validators
|
|
validate.RegisterValidation("phone", validatePhone)
|
|
validate.RegisterValidation("nrp", validateNRP)
|
|
}
|
|
|
|
// ValidateStruct validates a struct
|
|
func ValidateStruct(s interface{}) error {
|
|
if validate == nil {
|
|
InitValidator()
|
|
}
|
|
return validate.Struct(s)
|
|
}
|
|
|
|
// validatePhone validates Indonesian phone numbers
|
|
func validatePhone(fl validator.FieldLevel) bool {
|
|
phone := fl.Field().String()
|
|
if phone == "" {
|
|
return true // Allow empty (use required tag separately)
|
|
}
|
|
|
|
// Remove spaces and dashes
|
|
phone = strings.ReplaceAll(phone, " ", "")
|
|
phone = strings.ReplaceAll(phone, "-", "")
|
|
|
|
// Check if starts with 0 or +62 or 62
|
|
pattern := `^(0|\+?62)[0-9]{8,12}$`
|
|
matched, _ := regexp.MatchString(pattern, phone)
|
|
return matched
|
|
}
|
|
|
|
// validateNRP validates NRP (Nomor Registrasi Pokok)
|
|
func validateNRP(fl validator.FieldLevel) bool {
|
|
nrp := fl.Field().String()
|
|
if nrp == "" {
|
|
return true // Allow empty (use required tag separately)
|
|
}
|
|
|
|
// NRP format: 10 digits
|
|
pattern := `^[0-9]{10}$`
|
|
matched, _ := regexp.MatchString(pattern, nrp)
|
|
return matched
|
|
}
|
|
|
|
// IsValidEmail checks if email is valid
|
|
func IsValidEmail(email string) bool {
|
|
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
|
matched, _ := regexp.MatchString(pattern, email)
|
|
return matched
|
|
}
|
|
|
|
// IsValidURL checks if URL is valid
|
|
func IsValidURL(url string) bool {
|
|
pattern := `^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$`
|
|
matched, _ := regexp.MatchString(pattern, url)
|
|
return matched
|
|
}
|
|
|
|
// SanitizeString removes potentially dangerous characters
|
|
func SanitizeString(s string) string {
|
|
// Remove control characters
|
|
s = strings.Map(func(r rune) rune {
|
|
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// internal/workers/audit_worker.go
|
|
package workers
|
|
|
|
import (
|
|
"log"
|
|
"lost-and-found/internal/repositories"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AuditWorker handles audit log background tasks
|
|
type AuditWorker struct {
|
|
db *gorm.DB
|
|
auditLogRepo *repositories.AuditLogRepository
|
|
stopChan chan bool
|
|
}
|
|
|
|
// NewAuditWorker creates a new audit worker
|
|
func NewAuditWorker(db *gorm.DB) *AuditWorker {
|
|
return &AuditWorker{
|
|
db: db,
|
|
auditLogRepo: repositories.NewAuditLogRepository(db),
|
|
stopChan: make(chan bool),
|
|
}
|
|
}
|
|
|
|
// Start starts the audit worker
|
|
func (w *AuditWorker) Start() {
|
|
log.Println("🔍 Audit Worker started")
|
|
|
|
ticker := time.NewTicker(24 * time.Hour) // Run daily
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
w.cleanupOldLogs()
|
|
case <-w.stopChan:
|
|
log.Println("🔍 Audit Worker stopped")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the audit worker
|
|
func (w *AuditWorker) Stop() {
|
|
w.stopChan <- true
|
|
}
|
|
|
|
// cleanupOldLogs removes audit logs older than 1 year
|
|
func (w *AuditWorker) cleanupOldLogs() {
|
|
log.Println("🧹 Cleaning up old audit logs...")
|
|
|
|
cutoffDate := time.Now().AddDate(-1, 0, 0) // 1 year ago
|
|
|
|
result := w.db.Unscoped().Where("created_at < ?", cutoffDate).Delete(&struct {
|
|
tableName struct{} `gorm:"audit_logs"`
|
|
}{})
|
|
|
|
if result.Error != nil {
|
|
log.Printf("❌ Failed to cleanup audit logs: %v", result.Error)
|
|
return
|
|
}
|
|
|
|
log.Printf("✅ Cleaned up %d old audit log entries", result.RowsAffected)
|
|
}
|
|
|
|
// RunNow runs cleanup immediately (for testing)
|
|
func (w *AuditWorker) RunNow() {
|
|
log.Println("▶️ Running audit cleanup manually...")
|
|
w.cleanupOldLogs()
|
|
}
|
|
|
|
// internal/workers/expire_worker.go
|
|
package workers
|
|
|
|
import (
|
|
"log"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ExpireWorker handles item expiration background tasks
|
|
type ExpireWorker struct {
|
|
db *gorm.DB
|
|
itemRepo *repositories.ItemRepository
|
|
archiveRepo *repositories.ArchiveRepository
|
|
stopChan chan bool
|
|
}
|
|
|
|
// NewExpireWorker creates a new expire worker
|
|
func NewExpireWorker(db *gorm.DB) *ExpireWorker {
|
|
return &ExpireWorker{
|
|
db: db,
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
archiveRepo: repositories.NewArchiveRepository(db),
|
|
stopChan: make(chan bool),
|
|
}
|
|
}
|
|
|
|
// Start starts the expire worker
|
|
func (w *ExpireWorker) Start() {
|
|
log.Println("⏰ Expire Worker started")
|
|
|
|
// Run immediately on start
|
|
w.expireItems()
|
|
|
|
// Then run every hour
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
w.expireItems()
|
|
case <-w.stopChan:
|
|
log.Println("⏰ Expire Worker stopped")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the expire worker
|
|
func (w *ExpireWorker) Stop() {
|
|
w.stopChan <- true
|
|
}
|
|
|
|
// expireItems finds and expires items that have passed their expiration date
|
|
func (w *ExpireWorker) expireItems() {
|
|
log.Println("🔍 Checking for expired items...")
|
|
|
|
// Find expired items
|
|
expiredItems, err := w.itemRepo.FindExpired()
|
|
if err != nil {
|
|
log.Printf("❌ Error finding expired items: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(expiredItems) == 0 {
|
|
log.Println("✅ No expired items found")
|
|
return
|
|
}
|
|
|
|
log.Printf("📦 Found %d expired items", len(expiredItems))
|
|
|
|
// Process each expired item
|
|
expiredCount := 0
|
|
for _, item := range expiredItems {
|
|
if err := w.archiveExpiredItem(&item); err != nil {
|
|
log.Printf("❌ Failed to archive item ID %d: %v", item.ID, err)
|
|
continue
|
|
}
|
|
expiredCount++
|
|
}
|
|
|
|
log.Printf("✅ Successfully archived %d expired items", expiredCount)
|
|
}
|
|
|
|
// archiveExpiredItem archives an expired item
|
|
func (w *ExpireWorker) archiveExpiredItem(item *models.Item) error {
|
|
// Archive the item
|
|
if err := w.itemRepo.ArchiveItem(item, models.ArchiveReasonExpired, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("📦 Archived expired item: %s (ID: %d)", item.Name, item.ID)
|
|
return nil
|
|
}
|
|
|
|
// RunNow runs expiration check immediately (for testing)
|
|
func (w *ExpireWorker) RunNow() {
|
|
log.Println("▶️ Running expiration check manually...")
|
|
w.expireItems()
|
|
}
|
|
|
|
// internal/workers/matching_worker.go
|
|
package workers
|
|
|
|
import (
|
|
"log"
|
|
"lost-and-found/internal/repositories"
|
|
"lost-and-found/internal/services"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// MatchingWorker handles automatic matching of lost and found items
|
|
type MatchingWorker struct {
|
|
db *gorm.DB
|
|
matchService *services.MatchService
|
|
itemRepo *repositories.ItemRepository
|
|
lostItemRepo *repositories.LostItemRepository
|
|
stopChan chan bool
|
|
}
|
|
|
|
// NewMatchingWorker creates a new matching worker
|
|
func NewMatchingWorker(db *gorm.DB) *MatchingWorker {
|
|
return &MatchingWorker{
|
|
db: db,
|
|
matchService: services.NewMatchService(db),
|
|
itemRepo: repositories.NewItemRepository(db),
|
|
lostItemRepo: repositories.NewLostItemRepository(db),
|
|
stopChan: make(chan bool),
|
|
}
|
|
}
|
|
|
|
// Start starts the matching worker
|
|
func (w *MatchingWorker) Start() {
|
|
log.Println("🔗 Matching Worker started")
|
|
|
|
// Run every 30 minutes
|
|
ticker := time.NewTicker(30 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
w.performMatching()
|
|
case <-w.stopChan:
|
|
log.Println("🔗 Matching Worker stopped")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the matching worker
|
|
func (w *MatchingWorker) Stop() {
|
|
w.stopChan <- true
|
|
}
|
|
|
|
// performMatching performs automatic matching between lost and found items
|
|
func (w *MatchingWorker) performMatching() {
|
|
log.Println("🔍 Performing automatic matching...")
|
|
|
|
// Get all unclaimed items
|
|
items, _, err := w.itemRepo.FindAll(1, 1000, "unclaimed", "", "")
|
|
if err != nil {
|
|
log.Printf("❌ Error fetching items: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
log.Println("✅ No unclaimed items to match")
|
|
return
|
|
}
|
|
|
|
matchCount := 0
|
|
for _, item := range items {
|
|
// Auto-match with lost items
|
|
if err := w.matchService.AutoMatchNewItem(item.ID); err != nil {
|
|
log.Printf("❌ Failed to match item ID %d: %v", item.ID, err)
|
|
continue
|
|
}
|
|
matchCount++
|
|
}
|
|
|
|
log.Printf("✅ Completed matching for %d items", matchCount)
|
|
}
|
|
|
|
// RunNow runs matching immediately (for testing)
|
|
func (w *MatchingWorker) RunNow() {
|
|
log.Println("▶️ Running matching manually...")
|
|
w.performMatching()
|
|
}
|
|
|
|
// internal/workers/notification_worker.go
|
|
package workers
|
|
|
|
import (
|
|
"log"
|
|
"lost-and-found/internal/models"
|
|
"lost-and-found/internal/repositories"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// NotificationWorker handles sending notifications asynchronously
|
|
type NotificationWorker struct {
|
|
db *gorm.DB
|
|
notificationRepo *repositories.NotificationRepository
|
|
matchRepo *repositories.MatchResultRepository
|
|
stopChan chan bool
|
|
}
|
|
|
|
// NewNotificationWorker creates a new notification worker
|
|
func NewNotificationWorker(db *gorm.DB) *NotificationWorker {
|
|
return &NotificationWorker{
|
|
db: db,
|
|
notificationRepo: repositories.NewNotificationRepository(db),
|
|
matchRepo: repositories.NewMatchResultRepository(db),
|
|
stopChan: make(chan bool),
|
|
}
|
|
}
|
|
|
|
// Start starts the notification worker
|
|
func (w *NotificationWorker) Start() {
|
|
log.Println("📬 Notification Worker started")
|
|
|
|
// Run every 5 minutes
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
w.sendPendingNotifications()
|
|
case <-w.stopChan:
|
|
log.Println("📬 Notification Worker stopped")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the notification worker
|
|
func (w *NotificationWorker) Stop() {
|
|
w.stopChan <- true
|
|
}
|
|
|
|
// sendPendingNotifications sends notifications for unnotified matches
|
|
func (w *NotificationWorker) sendPendingNotifications() {
|
|
log.Println("🔍 Checking for pending notifications...")
|
|
|
|
// Get unnotified matches
|
|
matches, err := w.matchRepo.FindUnnotifiedMatches()
|
|
if err != nil {
|
|
log.Printf("❌ Error fetching unnotified matches: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
log.Println("✅ No pending notifications")
|
|
return
|
|
}
|
|
|
|
log.Printf("📧 Found %d pending notifications", len(matches))
|
|
|
|
sentCount := 0
|
|
for _, match := range matches {
|
|
// Send notification to lost item owner
|
|
if err := w.sendMatchNotification(&match); err != nil {
|
|
log.Printf("❌ Failed to send notification for match ID %d: %v", match.ID, err)
|
|
continue
|
|
}
|
|
|
|
// Mark as notified
|
|
if err := w.matchRepo.MarkAsNotified(match.ID); err != nil {
|
|
log.Printf("❌ Failed to mark match ID %d as notified: %v", match.ID, err)
|
|
continue
|
|
}
|
|
|
|
sentCount++
|
|
}
|
|
|
|
log.Printf("✅ Sent %d notifications", sentCount)
|
|
}
|
|
|
|
// sendMatchNotification sends a match notification
|
|
func (w *NotificationWorker) sendMatchNotification(match *models.MatchResult) error {
|
|
// Create notification
|
|
err := models.CreateMatchNotification(
|
|
w.db,
|
|
match.LostItem.UserID,
|
|
match.Item.Name,
|
|
match.ID,
|
|
)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("📧 Sent match notification to user ID %d for item: %s",
|
|
match.LostItem.UserID, match.Item.Name)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RunNow runs notification sending immediately (for testing)
|
|
func (w *NotificationWorker) RunNow() {
|
|
log.Println("▶️ Running notification sending manually...")
|
|
w.sendPendingNotifications()
|
|
}
|
|
|
|
/* web/css/style.css */
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-dark: #1e40af;
|
|
--danger: #ef4444;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--light: #f8fafc;
|
|
--dark: #1e293b;
|
|
--secondary: #64748b;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
/* Navbar */
|
|
.navbar {
|
|
background: white;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
padding: 15px 30px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.navbar-menu {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-link {
|
|
text-decoration: none;
|
|
color: var(--dark);
|
|
font-weight: 500;
|
|
padding: 8px 15px;
|
|
border-radius: 8px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.nav-link:hover {
|
|
background: var(--light);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: var(--primary);
|
|
color: white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.user-role {
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn-logout {
|
|
background: var(--danger);
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 20px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-logout:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
/* Container */
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 30px auto;
|
|
padding: 0 20px;
|
|
}
|
|
|
|
.page-header {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
color: var(--dark);
|
|
font-size: 2rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
/* Stats Grid */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stats-grid-4 {
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.stat-card h3 {
|
|
color: var(--secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.stat-success {
|
|
color: var(--success);
|
|
}
|
|
|
|
.stat-warning {
|
|
color: var(--warning);
|
|
}
|
|
|
|
.stat-danger {
|
|
color: var(--danger);
|
|
}
|
|
|
|
/* Tabs */
|
|
.tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 30px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 12px 25px;
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
color: var(--secondary);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
/* Card */
|
|
.card {
|
|
background: white;
|
|
border-radius: 15px;
|
|
padding: 25px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 1.3rem;
|
|
color: var(--dark);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--success);
|
|
color: white;
|
|
}
|
|
|
|
.btn-success:hover {
|
|
background: #059669;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.btn-warning {
|
|
background: var(--warning);
|
|
color: white;
|
|
}
|
|
|
|
.btn-warning:hover {
|
|
background: #d97706;
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Search Box */
|
|
.search-box {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.filter-select {
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* Items Grid */
|
|
.items-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.item-card {
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 15px;
|
|
overflow: hidden;
|
|
transition: all 0.3s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.item-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.item-image {
|
|
width: 100%;
|
|
height: 200px;
|
|
object-fit: cover;
|
|
background: var(--light);
|
|
}
|
|
|
|
.item-body {
|
|
padding: 15px;
|
|
}
|
|
|
|
.item-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: var(--dark);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.item-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
color: var(--secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.item-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
/* Badge */
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 5px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-success {
|
|
background: #d1fae5;
|
|
color: var(--success);
|
|
}
|
|
|
|
.badge-warning {
|
|
background: #fef3c7;
|
|
color: var(--warning);
|
|
}
|
|
|
|
.badge-danger {
|
|
background: #fee2e2;
|
|
color: var(--danger);
|
|
}
|
|
|
|
.badge-primary {
|
|
background: #dbeafe;
|
|
color: var(--primary);
|
|
}
|
|
|
|
/* Modal */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 1000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
animation: fadeIn 0.3s;
|
|
}
|
|
|
|
.modal.active {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 20px;
|
|
max-width: 600px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
animation: slideUp 0.3s;
|
|
}
|
|
|
|
.modal-large {
|
|
max-width: 900px;
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 25px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.close-btn {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
color: var(--secondary);
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 25px;
|
|
}
|
|
|
|
/* Form */
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: var(--dark);
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group textarea,
|
|
.form-group select {
|
|
width: 100%;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-group textarea {
|
|
resize: vertical;
|
|
min-height: 100px;
|
|
}
|
|
|
|
/* Table */
|
|
.table-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.data-table thead {
|
|
background: var(--light);
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
padding: 15px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.data-table th {
|
|
font-weight: 600;
|
|
color: var(--dark);
|
|
}
|
|
|
|
.data-table tr:hover {
|
|
background: var(--light);
|
|
}
|
|
|
|
/* Claims List */
|
|
.claims-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.claim-card {
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.claim-card:hover {
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.claim-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.claim-info {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.claim-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
/* Categories Grid */
|
|
.categories-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.category-card {
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.category-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.category-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
/* Report Section */
|
|
.report-section {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 30px;
|
|
}
|
|
|
|
.report-filters,
|
|
.report-preview {
|
|
background: var(--light);
|
|
padding: 20px;
|
|
border-radius: 15px;
|
|
}
|
|
|
|
.report-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.report-stats {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.report-stat-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 10px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--secondary);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 4rem;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Loading */
|
|
.loading {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid rgba(255,255,255,.3);
|
|
border-radius: 50%;
|
|
border-top-color: white;
|
|
animation: spin 1s ease-in-out infinite;
|
|
}
|
|
|
|
/* Animations */
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
transform: translateY(50px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.navbar {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.navbar-menu {
|
|
flex-direction: column;
|
|
width: 100%;
|
|
}
|
|
|
|
.items-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.search-box {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
}
|
|
|
|
.report-section {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.data-table {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
padding: 10px;
|
|
}
|
|
}
|
|
|
|
// web/js/admin.js
|
|
// Dashboard Admin JavaScript - FIXED ENDPOINTS
|
|
|
|
let allUsers = [];
|
|
let allCategories = [];
|
|
let allAuditLogs = [];
|
|
|
|
// Initialize dashboard
|
|
window.addEventListener("DOMContentLoaded", async () => {
|
|
const user = checkAuth();
|
|
if (!user || user.role !== "admin") {
|
|
window.location.href = "/login";
|
|
return;
|
|
}
|
|
|
|
await loadStats();
|
|
await loadUsers();
|
|
|
|
setupSearchAndFilters();
|
|
setupReportFilters();
|
|
});
|
|
|
|
// Load statistics - FIXED
|
|
async function loadStats() {
|
|
try {
|
|
const stats = await apiCall("/api/admin/dashboard");
|
|
document.getElementById("statTotalUsers").textContent =
|
|
stats.total_users || 0;
|
|
document.getElementById("statTotalItems").textContent =
|
|
stats.total_items || 0;
|
|
document.getElementById("statTotalClaims").textContent =
|
|
stats.total_claims || 0;
|
|
document.getElementById("statTotalArchive").textContent =
|
|
stats.total_archive || 0;
|
|
} catch (error) {
|
|
console.error("Error loading stats:", error);
|
|
}
|
|
}
|
|
|
|
// Load users - CORRECT (sudah sesuai)
|
|
async function loadUsers() {
|
|
try {
|
|
const response = await apiCall("/api/admin/users");
|
|
allUsers = response.data || [];
|
|
renderUsers(allUsers);
|
|
} catch (error) {
|
|
console.error("Error loading users:", error);
|
|
showAlert("Gagal memuat data user", "danger");
|
|
}
|
|
}
|
|
|
|
// Render users
|
|
function renderUsers(users) {
|
|
const tbody = document.getElementById("usersTableBody");
|
|
|
|
if (!users || users.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" style="text-align: center; padding: 40px; color: #64748b;">
|
|
Belum ada data user
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = users
|
|
.map(
|
|
(user) => `
|
|
<tr>
|
|
<td>${user.name}</td>
|
|
<td>${user.email}</td>
|
|
<td>${user.nrp}</td>
|
|
<td>${getRoleBadge(user.role)}</td>
|
|
<td>${getStatusBadge(user.status || "active")}</td>
|
|
<td>
|
|
<button class="btn btn-warning btn-sm" onclick="editUser(${
|
|
user.id
|
|
})">Edit</button>
|
|
${
|
|
user.status === "active"
|
|
? `<button class="btn btn-danger btn-sm" onclick="blockUser(${user.id})">Block</button>`
|
|
: `<button class="btn btn-success btn-sm" onclick="unblockUser(${user.id})">Unblock</button>`
|
|
}
|
|
</td>
|
|
</tr>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Edit user
|
|
async function editUser(userId) {
|
|
try {
|
|
const user = await apiCall(`/api/admin/users/${userId}`);
|
|
|
|
const form = document.getElementById("editUserForm");
|
|
form.elements.user_id.value = user.id;
|
|
form.elements.name.value = user.name;
|
|
form.elements.email.value = user.email;
|
|
form.elements.nrp.value = user.nrp;
|
|
form.elements.phone.value = user.phone || "";
|
|
form.elements.role.value = user.role;
|
|
|
|
openModal("editUserModal");
|
|
} catch (error) {
|
|
console.error("Error loading user:", error);
|
|
showAlert("Gagal memuat data user", "danger");
|
|
}
|
|
}
|
|
|
|
// Submit edit user
|
|
document
|
|
.getElementById("editUserForm")
|
|
?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const userId = formData.get("user_id");
|
|
const role = formData.get("role");
|
|
|
|
try {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Menyimpan...';
|
|
|
|
// Update role
|
|
await apiCall(`/api/admin/users/${userId}/role`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
|
|
showAlert("User berhasil diupdate!", "success");
|
|
closeModal("editUserModal");
|
|
await loadUsers();
|
|
} catch (error) {
|
|
console.error("Error updating user:", error);
|
|
showAlert(error.message || "Gagal update user", "danger");
|
|
} finally {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Update User";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Block user
|
|
async function blockUser(userId) {
|
|
if (!confirmAction("Block user ini?")) return;
|
|
|
|
try {
|
|
await apiCall(`/api/admin/users/${userId}/block`, {
|
|
method: "POST",
|
|
});
|
|
|
|
showAlert("User berhasil diblock!", "success");
|
|
await loadUsers();
|
|
} catch (error) {
|
|
console.error("Error blocking user:", error);
|
|
showAlert(error.message || "Gagal block user", "danger");
|
|
}
|
|
}
|
|
|
|
// Unblock user
|
|
async function unblockUser(userId) {
|
|
if (!confirmAction("Unblock user ini?")) return;
|
|
|
|
try {
|
|
await apiCall(`/api/admin/users/${userId}/unblock`, {
|
|
method: "POST",
|
|
});
|
|
|
|
showAlert("User berhasil di-unblock!", "success");
|
|
await loadUsers();
|
|
} catch (error) {
|
|
console.error("Error unblocking user:", error);
|
|
showAlert(error.message || "Gagal unblock user", "danger");
|
|
}
|
|
}
|
|
|
|
// Load categories
|
|
async function loadCategories() {
|
|
try {
|
|
const response = await apiCall("/api/categories");
|
|
allCategories = response.data || [];
|
|
renderCategories(allCategories);
|
|
} catch (error) {
|
|
console.error("Error loading categories:", error);
|
|
showAlert("Gagal memuat data kategori", "danger");
|
|
}
|
|
}
|
|
|
|
// Render categories
|
|
function renderCategories(categories) {
|
|
const grid = document.getElementById("categoriesGrid");
|
|
|
|
if (!categories || categories.length === 0) {
|
|
grid.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🏷️</div>
|
|
<p>Belum ada kategori</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const icons = {
|
|
pakaian: "👕",
|
|
alat_makan: "🍽️",
|
|
aksesoris: "👓",
|
|
elektronik: "💻",
|
|
alat_tulis: "✏️",
|
|
lainnya: "📦",
|
|
};
|
|
|
|
grid.innerHTML = categories
|
|
.map(
|
|
(cat) => `
|
|
<div class="category-card">
|
|
<div class="category-icon">${icons[cat.slug] || "📦"}</div>
|
|
<h3>${cat.name}</h3>
|
|
${
|
|
cat.description
|
|
? `<p style="color: #64748b; font-size: 0.9rem; margin-top: 8px;">${cat.description}</p>`
|
|
: ""
|
|
}
|
|
<div style="display: flex; gap: 8px; margin-top: 15px; justify-content: center;">
|
|
<button class="btn btn-warning btn-sm" onclick="editCategory(${
|
|
cat.id
|
|
})">Edit</button>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteCategory(${
|
|
cat.id
|
|
})">Hapus</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Submit add category
|
|
document
|
|
.getElementById("addCategoryForm")
|
|
?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const data = Object.fromEntries(formData);
|
|
|
|
try {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Menyimpan...';
|
|
|
|
await apiCall("/api/admin/categories", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
showAlert("Kategori berhasil ditambahkan!", "success");
|
|
closeModal("addCategoryModal");
|
|
e.target.reset();
|
|
await loadCategories();
|
|
} catch (error) {
|
|
console.error("Error adding category:", error);
|
|
showAlert(error.message || "Gagal menambahkan kategori", "danger");
|
|
} finally {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Tambah Kategori";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Edit category
|
|
async function editCategory(catId) {
|
|
const newName = prompt("Nama kategori baru:");
|
|
if (!newName) return;
|
|
|
|
try {
|
|
await apiCall(`/api/admin/categories/${catId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ name: newName }),
|
|
});
|
|
|
|
showAlert("Kategori berhasil diupdate!", "success");
|
|
await loadCategories();
|
|
} catch (error) {
|
|
console.error("Error updating category:", error);
|
|
showAlert(error.message || "Gagal update kategori", "danger");
|
|
}
|
|
}
|
|
|
|
// Delete category
|
|
async function deleteCategory(catId) {
|
|
if (!confirmAction("Hapus kategori ini?")) return;
|
|
|
|
try {
|
|
await apiCall(`/api/admin/categories/${catId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
showAlert("Kategori berhasil dihapus!", "success");
|
|
await loadCategories();
|
|
} catch (error) {
|
|
console.error("Error deleting category:", error);
|
|
showAlert(error.message || "Gagal hapus kategori", "danger");
|
|
}
|
|
}
|
|
|
|
// Setup report filters - SIMPLIFIED (remove preview)
|
|
function setupReportFilters() {
|
|
// Remove preview functionality since endpoint doesn't exist
|
|
// Just setup the export buttons
|
|
}
|
|
|
|
// Export report
|
|
async function exportReport(format) {
|
|
const period = document.getElementById("reportPeriod")?.value;
|
|
const type = document.getElementById("reportType")?.value;
|
|
const startDate = document.getElementById("reportStartDate")?.value;
|
|
const endDate = document.getElementById("reportEndDate")?.value;
|
|
|
|
let url = `/api/admin/reports/export?format=${format}&period=${period}&type=${type}`;
|
|
|
|
if (period === "custom" && startDate && endDate) {
|
|
url += `&start_date=${startDate}&end_date=${endDate}`;
|
|
}
|
|
|
|
try {
|
|
const token = getToken();
|
|
const response = await fetch(`${API_URL}${url}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Export failed");
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const downloadUrl = window.URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = downloadUrl;
|
|
a.download = `laporan_${type}_${period}.${
|
|
format === "pdf" ? "pdf" : "xlsx"
|
|
}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(downloadUrl);
|
|
a.remove();
|
|
|
|
showAlert(
|
|
`Laporan ${format.toUpperCase()} berhasil didownload!`,
|
|
"success"
|
|
);
|
|
} catch (error) {
|
|
console.error("Error exporting report:", error);
|
|
showAlert("Gagal export laporan", "danger");
|
|
}
|
|
}
|
|
|
|
// Load audit logs
|
|
async function loadAudit() {
|
|
try {
|
|
const response = await apiCall("/api/admin/audit-logs");
|
|
allAuditLogs = response.data || [];
|
|
renderAuditLogs(allAuditLogs);
|
|
} catch (error) {
|
|
console.error("Error loading audit logs:", error);
|
|
showAlert("Gagal memuat audit log", "danger");
|
|
}
|
|
}
|
|
|
|
// Render audit logs
|
|
function renderAuditLogs(logs) {
|
|
const tbody = document.getElementById("auditTableBody");
|
|
|
|
if (!logs || logs.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="5" style="text-align: center; padding: 40px; color: #64748b;">
|
|
Belum ada audit log
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = logs
|
|
.map(
|
|
(log) => `
|
|
<tr>
|
|
<td>${formatDateTime(log.created_at)}</td>
|
|
<td>${log.user_name}</td>
|
|
<td><span class="badge badge-primary">${log.action}</span></td>
|
|
<td>${log.details || "-"}</td>
|
|
<td>${log.ip_address || "-"}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Setup search and filters
|
|
function setupSearchAndFilters() {
|
|
const searchUsers = document.getElementById("searchUsers");
|
|
const roleFilter = document.getElementById("roleFilter");
|
|
const statusFilter = document.getElementById("statusFilter");
|
|
|
|
const performUsersSearch = debounce(() => {
|
|
const searchTerm = searchUsers?.value.toLowerCase() || "";
|
|
const role = roleFilter?.value || "";
|
|
const status = statusFilter?.value || "";
|
|
|
|
let filtered = allUsers.filter((user) => {
|
|
const matchesSearch =
|
|
user.name.toLowerCase().includes(searchTerm) ||
|
|
user.email.toLowerCase().includes(searchTerm) ||
|
|
user.nrp.includes(searchTerm);
|
|
const matchesRole = !role || user.role === role;
|
|
const matchesStatus = !status || user.status === status;
|
|
return matchesSearch && matchesRole && matchesStatus;
|
|
});
|
|
|
|
renderUsers(filtered);
|
|
}, 300);
|
|
|
|
searchUsers?.addEventListener("input", performUsersSearch);
|
|
roleFilter?.addEventListener("change", performUsersSearch);
|
|
statusFilter?.addEventListener("change", performUsersSearch);
|
|
}
|
|
|
|
|
|
// web/js/main.js
|
|
// Main.js - Shared functions across all dashboards
|
|
const API_URL = "http://localhost:8080";
|
|
|
|
// Auth utilities
|
|
function getToken() {
|
|
return localStorage.getItem("token");
|
|
}
|
|
|
|
function getCurrentUser() {
|
|
const user = localStorage.getItem("user");
|
|
return user ? JSON.parse(user) : null;
|
|
}
|
|
|
|
function setAuth(token, user) {
|
|
localStorage.setItem("token", token);
|
|
localStorage.setItem("user", JSON.stringify(user));
|
|
}
|
|
|
|
function clearAuth() {
|
|
localStorage.removeItem("token");
|
|
localStorage.removeItem("user");
|
|
}
|
|
|
|
// Check authentication - FIXED
|
|
function checkAuth() {
|
|
const token = getToken();
|
|
const user = getCurrentUser();
|
|
|
|
if (!token || !user) {
|
|
// FIXED: Jangan langsung redirect di sini
|
|
// Biarkan halaman yang memanggil yang handle redirect
|
|
return null;
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
// Logout - FIXED
|
|
function logout() {
|
|
if (confirm("Apakah Anda yakin ingin logout?")) {
|
|
clearAuth();
|
|
window.location.href = "/login"; // FIXED: tambah /
|
|
}
|
|
}
|
|
|
|
// API call helper
|
|
async function apiCall(endpoint, options = {}) {
|
|
const token = getToken();
|
|
|
|
const defaultOptions = {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
},
|
|
};
|
|
|
|
const finalOptions = {
|
|
...defaultOptions,
|
|
...options,
|
|
headers: {
|
|
...defaultOptions.headers,
|
|
...options.headers,
|
|
},
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}${endpoint}`, finalOptions);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
// Handle 401 Unauthorized
|
|
if (response.status === 401) {
|
|
showAlert("Session expired. Please login again.", "danger");
|
|
setTimeout(() => {
|
|
clearAuth();
|
|
window.location.href = "/login"; // FIXED: tambah /
|
|
}, 1500);
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
throw new Error(data.error || "Request failed");
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("API call error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// API call for file upload - FIXED: support PUT method
|
|
async function apiUpload(endpoint, formData, method = "POST") {
|
|
const token = getToken();
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
method: method, // FIXED: bisa POST atau PUT
|
|
headers: {
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
// JANGAN set Content-Type untuk FormData, browser akan set otomatis dengan boundary
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
showAlert("Session expired. Please login again.", "danger");
|
|
setTimeout(() => {
|
|
clearAuth();
|
|
window.location.href = "/login"; // FIXED: tambah /
|
|
}, 1500);
|
|
throw new Error("Unauthorized");
|
|
}
|
|
|
|
throw new Error(data.error || "Upload failed");
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("Upload error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Tab switching
|
|
function switchTab(tabName) {
|
|
// Remove active class from all tabs
|
|
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
|
btn.classList.remove("active");
|
|
});
|
|
document.querySelectorAll(".tab-content").forEach((content) => {
|
|
content.classList.remove("active");
|
|
});
|
|
|
|
// Add active class to selected tab
|
|
event.target.classList.add("active");
|
|
document.getElementById(tabName + "Tab").classList.add("active");
|
|
|
|
// Trigger load function for specific tab if exists
|
|
const loadFunctionName = `load${capitalize(tabName)}`;
|
|
if (typeof window[loadFunctionName] === "function") {
|
|
window[loadFunctionName]();
|
|
}
|
|
}
|
|
|
|
// Modal utilities
|
|
function openModal(modalId) {
|
|
document.getElementById(modalId).classList.add("active");
|
|
}
|
|
|
|
function closeModal(modalId) {
|
|
document.getElementById(modalId).classList.remove("active");
|
|
}
|
|
|
|
// Close modal when clicking outside
|
|
window.addEventListener("click", (e) => {
|
|
if (e.target.classList.contains("modal")) {
|
|
e.target.classList.remove("active");
|
|
}
|
|
});
|
|
|
|
// Alert notification
|
|
function showAlert(message, type = "info") {
|
|
const alertDiv = document.createElement("div");
|
|
alertDiv.className = `alert alert-${type}`;
|
|
alertDiv.textContent = message;
|
|
alertDiv.style.cssText = `
|
|
position: fixed;
|
|
top: 80px;
|
|
right: 20px;
|
|
padding: 15px 20px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
z-index: 9999;
|
|
animation: slideInRight 0.3s;
|
|
`;
|
|
|
|
// Set colors based on type
|
|
const colors = {
|
|
success: { bg: "#d1fae5", color: "#10b981", border: "#10b981" },
|
|
danger: { bg: "#fee2e2", color: "#ef4444", border: "#ef4444" },
|
|
warning: { bg: "#fef3c7", color: "#f59e0b", border: "#f59e0b" },
|
|
info: { bg: "#dbeafe", color: "#2563eb", border: "#2563eb" },
|
|
};
|
|
|
|
const colorScheme = colors[type] || colors.info;
|
|
alertDiv.style.background = colorScheme.bg;
|
|
alertDiv.style.color = colorScheme.color;
|
|
alertDiv.style.border = `2px solid ${colorScheme.border}`;
|
|
|
|
document.body.appendChild(alertDiv);
|
|
|
|
setTimeout(() => {
|
|
alertDiv.style.animation = "slideOutRight 0.3s";
|
|
setTimeout(() => alertDiv.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
// Format date
|
|
function formatDate(dateString) {
|
|
const date = new Date(dateString);
|
|
const options = { year: "numeric", month: "long", day: "numeric" };
|
|
return date.toLocaleDateString("id-ID", options);
|
|
}
|
|
|
|
// Format datetime
|
|
function formatDateTime(dateString) {
|
|
const date = new Date(dateString);
|
|
const options = {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
};
|
|
return date.toLocaleDateString("id-ID", options);
|
|
}
|
|
|
|
// Capitalize first letter
|
|
function capitalize(str) {
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
}
|
|
|
|
// Get status badge HTML
|
|
function getStatusBadge(status) {
|
|
const statusMap = {
|
|
unclaimed: { label: "Unclaimed", class: "badge-primary" },
|
|
pending_claim: { label: "Pending Claim", class: "badge-warning" },
|
|
verified: { label: "Verified", class: "badge-success" },
|
|
case_closed: { label: "Case Closed", class: "badge-success" },
|
|
expired: { label: "Expired", class: "badge-danger" },
|
|
pending: { label: "Pending", class: "badge-warning" },
|
|
approved: { label: "Approved", class: "badge-success" },
|
|
rejected: { label: "Rejected", class: "badge-danger" },
|
|
active: { label: "Active", class: "badge-success" },
|
|
blocked: { label: "Blocked", class: "badge-danger" },
|
|
};
|
|
|
|
const statusInfo = statusMap[status] || {
|
|
label: status,
|
|
class: "badge-primary",
|
|
};
|
|
return `<span class="badge ${statusInfo.class}">${statusInfo.label}</span>`;
|
|
}
|
|
|
|
// Get role badge HTML
|
|
function getRoleBadge(role) {
|
|
const roleMap = {
|
|
admin: { label: "Admin", class: "badge-danger" },
|
|
manager: { label: "Manager", class: "badge-warning" },
|
|
user: { label: "User", class: "badge-primary" },
|
|
};
|
|
|
|
const roleInfo = roleMap[role] || { label: role, class: "badge-primary" };
|
|
return `<span class="badge ${roleInfo.class}">${roleInfo.label}</span>`;
|
|
}
|
|
|
|
// Confirm dialog
|
|
function confirmAction(message) {
|
|
return confirm(message);
|
|
}
|
|
|
|
// Loading state
|
|
function setLoading(elementId, isLoading) {
|
|
const element = document.getElementById(elementId);
|
|
if (!element) return;
|
|
|
|
if (isLoading) {
|
|
element.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="loading" style="width: 50px; height: 50px; border-width: 5px;"></div>
|
|
<p style="margin-top: 20px;">Loading...</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Empty state
|
|
function showEmptyState(elementId, icon, message) {
|
|
const element = document.getElementById(elementId);
|
|
if (!element) return;
|
|
|
|
element.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">${icon}</div>
|
|
<h3>Tidak ada data</h3>
|
|
<p>${message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Debounce function for search
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Add CSS animation styles
|
|
const style = document.createElement("style");
|
|
style.textContent = `
|
|
@keyframes slideInRight {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes slideOutRight {
|
|
from {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
to {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Initialize user info in navbar
|
|
function initUserInfo() {
|
|
const user = getCurrentUser();
|
|
if (!user) return;
|
|
|
|
const userNameEl = document.getElementById("userName");
|
|
const userAvatarEl = document.getElementById("userAvatar");
|
|
|
|
if (userNameEl) userNameEl.textContent = user.name;
|
|
if (userAvatarEl)
|
|
userAvatarEl.textContent = user.name.charAt(0).toUpperCase();
|
|
}
|
|
|
|
// Initialize on page load
|
|
window.addEventListener("DOMContentLoaded", () => {
|
|
initUserInfo();
|
|
});
|
|
|
|
// web/js/manager.js
|
|
// Dashboard Manager JavaScript - FIXED ENDPOINTS
|
|
|
|
let allItems = [];
|
|
let allClaims = [];
|
|
let allLostItems = [];
|
|
let allArchive = [];
|
|
|
|
// Initialize dashboard
|
|
window.addEventListener("DOMContentLoaded", async () => {
|
|
const user = checkAuth();
|
|
if (!user || user.role !== "manager") {
|
|
window.location.href = "/login";
|
|
return;
|
|
}
|
|
|
|
await loadStats();
|
|
await loadItems();
|
|
setupSearchAndFilters();
|
|
});
|
|
|
|
// Load statistics - FIXED
|
|
async function loadStats() {
|
|
try {
|
|
const stats = await apiCall("/api/manager/dashboard");
|
|
document.getElementById("statTotalItems").textContent =
|
|
stats.total_items || 0;
|
|
document.getElementById("statPendingClaims").textContent =
|
|
stats.pending_claims || 0;
|
|
document.getElementById("statVerified").textContent = stats.verified || 0;
|
|
document.getElementById("statExpired").textContent = stats.expired || 0;
|
|
} catch (error) {
|
|
console.error("Error loading stats:", error);
|
|
}
|
|
}
|
|
|
|
// Load items - FIXED
|
|
async function loadItems() {
|
|
setLoading("itemsGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/items");
|
|
allItems = response.data || [];
|
|
renderItems(allItems);
|
|
} catch (error) {
|
|
console.error("Error loading items:", error);
|
|
showEmptyState("itemsGrid", "📦", "Gagal memuat data barang");
|
|
}
|
|
}
|
|
|
|
// Render items
|
|
function renderItems(items) {
|
|
const grid = document.getElementById("itemsGrid");
|
|
|
|
if (!items || items.length === 0) {
|
|
showEmptyState("itemsGrid", "📦", "Belum ada barang");
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<div class="item-card">
|
|
<img src="${
|
|
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
|
|
}"
|
|
alt="${item.name}"
|
|
class="item-image"
|
|
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${item.name}</h3>
|
|
<div class="item-meta">
|
|
<span>📍 ${item.location}</span>
|
|
<span>📅 ${formatDate(item.date_found)}</span>
|
|
<span>${getStatusBadge(item.status)}</span>
|
|
</div>
|
|
<div class="item-actions">
|
|
<button class="btn btn-primary btn-sm" onclick="viewItemDetail(${
|
|
item.id
|
|
})">Detail</button>
|
|
${
|
|
item.status !== "case_closed" && item.status !== "expired"
|
|
? `<button class="btn btn-warning btn-sm" onclick="editItem(${item.id})">Edit</button>`
|
|
: ""
|
|
}
|
|
${
|
|
item.status === "verified"
|
|
? `<button class="btn btn-success btn-sm" onclick="closeCase(${item.id})">Close Case</button>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// View item detail - FIXED
|
|
async function viewItemDetail(itemId) {
|
|
try {
|
|
const item = await apiCall(`/api/items/${itemId}`);
|
|
|
|
const modalContent = document.getElementById("itemDetailContent");
|
|
modalContent.innerHTML = `
|
|
<img src="${
|
|
item.photo_url || "https://via.placeholder.com/600x400?text=No+Image"
|
|
}"
|
|
alt="${item.name}"
|
|
style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 10px; margin-bottom: 20px;"
|
|
onerror="this.src='https://via.placeholder.com/600x400?text=No+Image'">
|
|
<h3>${item.name}</h3>
|
|
<div style="display: grid; gap: 15px; margin-top: 20px;">
|
|
<div><strong>Kategori:</strong> ${item.category}</div>
|
|
<div><strong>Lokasi Ditemukan:</strong> ${item.location}</div>
|
|
<div><strong>Tanggal Ditemukan:</strong> ${formatDate(
|
|
item.date_found
|
|
)}</div>
|
|
<div><strong>Status:</strong> ${getStatusBadge(item.status)}</div>
|
|
<div><strong>Pelapor:</strong> ${item.reporter_name}</div>
|
|
<div><strong>Kontak:</strong> ${item.reporter_contact}</div>
|
|
<div style="background: #f8fafc; padding: 15px; border-radius: 10px;">
|
|
<strong>Deskripsi Keunikan (Rahasia):</strong><br>
|
|
${item.description}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
openModal("itemDetailModal");
|
|
} catch (error) {
|
|
console.error("Error loading item detail:", error);
|
|
showAlert("Gagal memuat detail barang", "danger");
|
|
}
|
|
}
|
|
|
|
// Edit item - FIXED
|
|
async function editItem(itemId) {
|
|
try {
|
|
const item = await apiCall(`/api/items/${itemId}`);
|
|
|
|
const form = document.getElementById("editItemForm");
|
|
form.elements.item_id.value = item.id;
|
|
form.elements.name.value = item.name;
|
|
form.elements.category.value = item.category;
|
|
form.elements.location.value = item.location;
|
|
form.elements.description.value = item.description;
|
|
form.elements.reporter_name.value = item.reporter_name;
|
|
form.elements.reporter_contact.value = item.reporter_contact;
|
|
form.elements.date_found.value = item.date_found.split("T")[0];
|
|
|
|
openModal("editItemModal");
|
|
} catch (error) {
|
|
console.error("Error loading item:", error);
|
|
showAlert("Gagal memuat data barang", "danger");
|
|
}
|
|
}
|
|
|
|
// Submit edit item - FIXED
|
|
document
|
|
.getElementById("editItemForm")
|
|
?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const itemId = formData.get("item_id");
|
|
formData.delete("item_id");
|
|
|
|
try {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Menyimpan...';
|
|
|
|
await apiUpload(`/api/items/${itemId}`, formData, "PUT");
|
|
|
|
showAlert("Barang berhasil diupdate!", "success");
|
|
closeModal("editItemModal");
|
|
await loadItems();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error updating item:", error);
|
|
showAlert(error.message || "Gagal update barang", "danger");
|
|
} finally {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Update";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Close case - FIXED
|
|
async function closeCase(itemId) {
|
|
if (!confirmAction("Tutup kasus ini? Barang akan dipindahkan ke arsip."))
|
|
return;
|
|
|
|
try {
|
|
await apiCall(`/api/items/${itemId}/status`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: "case_closed" }),
|
|
});
|
|
|
|
showAlert("Kasus berhasil ditutup!", "success");
|
|
await loadItems();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error closing case:", error);
|
|
showAlert(error.message || "Gagal menutup kasus", "danger");
|
|
}
|
|
}
|
|
|
|
// Load claims - FIXED
|
|
async function loadClaims() {
|
|
setLoading("claimsList", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/claims");
|
|
allClaims = response.data || [];
|
|
renderClaims(allClaims);
|
|
} catch (error) {
|
|
console.error("Error loading claims:", error);
|
|
document.getElementById("claimsList").innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🤝</div>
|
|
<p>Gagal memuat data klaim</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Render claims
|
|
function renderClaims(claims) {
|
|
const list = document.getElementById("claimsList");
|
|
|
|
if (!claims || claims.length === 0) {
|
|
list.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🤝</div>
|
|
<p>Belum ada klaim yang masuk</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = claims
|
|
.map(
|
|
(claim) => `
|
|
<div class="claim-card">
|
|
<div class="claim-header">
|
|
<h3>${claim.item_name}</h3>
|
|
${getStatusBadge(claim.status)}
|
|
</div>
|
|
<div class="claim-info">
|
|
<div><strong>Pengklaim:</strong> ${claim.user_name}</div>
|
|
<div><strong>Kontak:</strong> ${claim.contact}</div>
|
|
<div><strong>Tanggal Klaim:</strong> ${formatDate(
|
|
claim.created_at
|
|
)}</div>
|
|
${
|
|
claim.match_percentage
|
|
? `
|
|
<div><strong>Match:</strong>
|
|
<span style="color: ${
|
|
claim.match_percentage >= 70 ? "#10b981" : "#f59e0b"
|
|
}; font-weight: 600;">
|
|
${claim.match_percentage}%
|
|
</span>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
<div style="background: #f8fafc; padding: 15px; border-radius: 10px; margin-bottom: 15px;">
|
|
<strong>Deskripsi dari Pengklaim:</strong><br>
|
|
${claim.description}
|
|
</div>
|
|
${
|
|
claim.status === "pending"
|
|
? `
|
|
<div class="claim-actions">
|
|
<button class="btn btn-primary btn-sm" onclick="verifyClaim(${claim.id})">Verifikasi</button>
|
|
<button class="btn btn-success btn-sm" onclick="approveClaim(${claim.id})">Approve</button>
|
|
<button class="btn btn-danger btn-sm" onclick="rejectClaim(${claim.id})">Reject</button>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
${
|
|
claim.status === "rejected" && claim.notes
|
|
? `
|
|
<div style="color: #ef4444; margin-top: 10px;">
|
|
<strong>Alasan:</strong> ${claim.notes}
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Verify claim - FIXED
|
|
async function verifyClaim(claimId) {
|
|
try {
|
|
const claim = await apiCall(`/api/claims/${claimId}`);
|
|
|
|
const modalContent = document.getElementById("verifyClaimContent");
|
|
modalContent.innerHTML = `
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 30px;">
|
|
<div>
|
|
<h4>Deskripsi Asli Barang</h4>
|
|
<div style="background: #f8fafc; padding: 15px; border-radius: 10px; margin-top: 10px;">
|
|
${claim.item_description}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4>Deskripsi dari Pengklaim</h4>
|
|
<div style="background: #fef3c7; padding: 15px; border-radius: 10px; margin-top: 10px;">
|
|
${claim.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
${
|
|
claim.proof_url
|
|
? `
|
|
<div style="margin-top: 20px;">
|
|
<h4>Bukti Pendukung</h4>
|
|
<img src="${claim.proof_url}" style="width: 100%; max-height: 300px; object-fit: contain; border-radius: 10px; margin-top: 10px;">
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
${
|
|
claim.match_percentage
|
|
? `
|
|
<div style="margin-top: 20px; padding: 15px; background: ${
|
|
claim.match_percentage >= 70 ? "#d1fae5" : "#fef3c7"
|
|
}; border-radius: 10px; text-align: center;">
|
|
<strong>Similarity Match:</strong>
|
|
<span style="font-size: 2rem; font-weight: 700; color: ${
|
|
claim.match_percentage >= 70 ? "#10b981" : "#f59e0b"
|
|
};">
|
|
${claim.match_percentage}%
|
|
</span>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
<div style="margin-top: 20px;">
|
|
<strong>Info Pengklaim:</strong>
|
|
<div style="margin-top: 10px;">
|
|
<div>Nama: ${claim.user_name}</div>
|
|
<div>Kontak: ${claim.contact}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 10px; margin-top: 30px;">
|
|
<button class="btn btn-success" onclick="approveClaim(${claimId})" style="flex: 1;">✓ Approve Klaim</button>
|
|
<button class="btn btn-danger" onclick="rejectClaim(${claimId})" style="flex: 1;">✗ Reject Klaim</button>
|
|
</div>
|
|
`;
|
|
|
|
openModal("verifyClaimModal");
|
|
} catch (error) {
|
|
console.error("Error loading claim:", error);
|
|
showAlert("Gagal memuat data klaim", "danger");
|
|
}
|
|
}
|
|
|
|
// Approve claim - FIXED
|
|
async function approveClaim(claimId) {
|
|
const notes = prompt("Catatan (opsional):");
|
|
|
|
try {
|
|
await apiCall(`/api/claims/${claimId}/verify`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
approved: true,
|
|
notes: notes || "",
|
|
}),
|
|
});
|
|
|
|
showAlert("Klaim berhasil diapprove!", "success");
|
|
closeModal("verifyClaimModal");
|
|
await loadClaims();
|
|
await loadItems();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error approving claim:", error);
|
|
showAlert(error.message || "Gagal approve klaim", "danger");
|
|
}
|
|
}
|
|
|
|
// Reject claim - FIXED
|
|
async function rejectClaim(claimId) {
|
|
const notes = prompt("Alasan penolakan (wajib):");
|
|
if (!notes) {
|
|
showAlert("Alasan penolakan harus diisi!", "warning");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiCall(`/api/claims/${claimId}/verify`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
approved: false,
|
|
notes,
|
|
}),
|
|
});
|
|
|
|
showAlert("Klaim berhasil ditolak!", "success");
|
|
closeModal("verifyClaimModal");
|
|
await loadClaims();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error rejecting claim:", error);
|
|
showAlert(error.message || "Gagal reject klaim", "danger");
|
|
}
|
|
}
|
|
|
|
// Load lost items - FIXED
|
|
async function loadLost() {
|
|
setLoading("lostItemsGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/lost-items");
|
|
allLostItems = response.data || [];
|
|
renderLostItems(allLostItems);
|
|
} catch (error) {
|
|
console.error("Error loading lost items:", error);
|
|
showEmptyState("lostItemsGrid", "😢", "Gagal memuat data barang hilang");
|
|
}
|
|
}
|
|
|
|
// Render lost items
|
|
function renderLostItems(items) {
|
|
const grid = document.getElementById("lostItemsGrid");
|
|
|
|
if (!items || items.length === 0) {
|
|
showEmptyState("lostItemsGrid", "😢", "Belum ada laporan barang hilang");
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<div class="item-card">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${item.name}</h3>
|
|
<div class="item-meta">
|
|
<span>🏷️ ${item.category}</span>
|
|
<span>🎨 ${item.color}</span>
|
|
<span>📅 ${formatDate(item.date_lost)}</span>
|
|
${item.location ? `<span>📍 ${item.location}</span>` : ""}
|
|
</div>
|
|
<p style="color: #64748b; font-size: 0.9rem; margin-top: 10px;">${
|
|
item.description
|
|
}</p>
|
|
<div style="margin-top: 10px;">
|
|
<small><strong>Pelapor:</strong> ${item.user_name}</small>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" onclick="findSimilarItems(${
|
|
item.id
|
|
})" style="margin-top: 10px; width: 100%;">
|
|
🔍 Cari Barang yang Mirip
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Find similar items - FIXED
|
|
async function findSimilarItems(lostItemId) {
|
|
try {
|
|
setLoading("matchItemsContent", true);
|
|
openModal("matchItemsModal");
|
|
|
|
const response = await apiCall(`/api/lost-items/${lostItemId}/matches`);
|
|
const matches = response.data || [];
|
|
|
|
const modalContent = document.getElementById("matchItemsContent");
|
|
|
|
if (matches.length === 0) {
|
|
modalContent.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">🔍</div>
|
|
<h3>Tidak ada barang yang cocok</h3>
|
|
<p>Belum ada barang ditemukan yang mirip dengan laporan ini</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
modalContent.innerHTML = `
|
|
<p style="margin-bottom: 20px; color: #64748b;">Ditemukan ${
|
|
matches.length
|
|
} barang yang mungkin cocok:</p>
|
|
<div class="items-grid">
|
|
${matches
|
|
.map(
|
|
(match) => `
|
|
<div class="item-card" style="border: 2px solid ${
|
|
match.similarity >= 70 ? "#10b981" : "#f59e0b"
|
|
};">
|
|
<img src="${
|
|
match.photo_url ||
|
|
"https://via.placeholder.com/280x200?text=No+Image"
|
|
}"
|
|
alt="${match.name}"
|
|
class="item-image"
|
|
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
|
|
<div class="item-body">
|
|
<div style="text-align: center; margin-bottom: 10px;">
|
|
<span style="font-size: 1.5rem; font-weight: 700; color: ${
|
|
match.similarity >= 70 ? "#10b981" : "#f59e0b"
|
|
};">
|
|
${match.similarity}% Match
|
|
</span>
|
|
</div>
|
|
<h3 class="item-title">${match.name}</h3>
|
|
<div class="item-meta">
|
|
<span>📍 ${match.location}</span>
|
|
<span>📅 ${formatDate(match.date_found)}</span>
|
|
<span>${getStatusBadge(match.status)}</span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" onclick="viewItemDetail(${
|
|
match.id
|
|
})" style="width: 100%; margin-top: 10px;">
|
|
Lihat Detail
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
`;
|
|
} catch (error) {
|
|
console.error("Error finding similar items:", error);
|
|
document.getElementById("matchItemsContent").innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">❌</div>
|
|
<p>Gagal mencari barang yang mirip</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Load archive - FIXED
|
|
async function loadArchive() {
|
|
setLoading("archiveGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/archives");
|
|
allArchive = response.data || [];
|
|
renderArchive(allArchive);
|
|
} catch (error) {
|
|
console.error("Error loading archive:", error);
|
|
showEmptyState("archiveGrid", "📂", "Gagal memuat data arsip");
|
|
}
|
|
}
|
|
|
|
// Render archive
|
|
function renderArchive(items) {
|
|
const grid = document.getElementById("archiveGrid");
|
|
|
|
if (!items || items.length === 0) {
|
|
showEmptyState("archiveGrid", "📂", "Belum ada barang di arsip");
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<div class="item-card">
|
|
<img src="${
|
|
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
|
|
}"
|
|
alt="${item.name}"
|
|
class="item-image"
|
|
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${item.name}</h3>
|
|
<div class="item-meta">
|
|
<span>📍 ${item.location}</span>
|
|
<span>📅 ${formatDate(item.date_found)}</span>
|
|
<span>${getStatusBadge(item.status)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Report found item - FIXED
|
|
function openReportFoundModal() {
|
|
openModal("reportFoundModal");
|
|
}
|
|
|
|
// Submit found item report - FIXED
|
|
document
|
|
.getElementById("reportFoundForm")
|
|
?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
|
|
try {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Mengirim...';
|
|
|
|
await apiUpload("/api/items", formData);
|
|
|
|
showAlert("Barang berhasil ditambahkan!", "success");
|
|
closeModal("reportFoundModal");
|
|
e.target.reset();
|
|
await loadItems();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error submitting item:", error);
|
|
showAlert(error.message || "Gagal menambahkan barang", "danger");
|
|
} finally {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Submit";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Setup search and filters
|
|
function setupSearchAndFilters() {
|
|
// Items tab
|
|
const searchItems = document.getElementById("searchItems");
|
|
const categoryFilterItems = document.getElementById("categoryFilterItems");
|
|
const statusFilterItems = document.getElementById("statusFilterItems");
|
|
const sortItems = document.getElementById("sortItems");
|
|
|
|
const performItemsSearch = debounce(() => {
|
|
const searchTerm = searchItems?.value.toLowerCase() || "";
|
|
const category = categoryFilterItems?.value || "";
|
|
const status = statusFilterItems?.value || "";
|
|
const sort = sortItems?.value || "date_desc";
|
|
|
|
let filtered = allItems.filter((item) => {
|
|
const matchesSearch =
|
|
item.name.toLowerCase().includes(searchTerm) ||
|
|
item.location.toLowerCase().includes(searchTerm);
|
|
const matchesCategory = !category || item.category === category;
|
|
const matchesStatus = !status || item.status === status;
|
|
return matchesSearch && matchesCategory && matchesStatus;
|
|
});
|
|
|
|
// Sort
|
|
filtered.sort((a, b) => {
|
|
switch (sort) {
|
|
case "date_desc":
|
|
return new Date(b.date_found) - new Date(a.date_found);
|
|
case "date_asc":
|
|
return new Date(a.date_found) - new Date(b.date_found);
|
|
case "name_asc":
|
|
return a.name.localeCompare(b.name);
|
|
case "name_desc":
|
|
return b.name.localeCompare(a.name);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
renderItems(filtered);
|
|
}, 300);
|
|
|
|
searchItems?.addEventListener("input", performItemsSearch);
|
|
categoryFilterItems?.addEventListener("change", performItemsSearch);
|
|
statusFilterItems?.addEventListener("change", performItemsSearch);
|
|
sortItems?.addEventListener("change", performItemsSearch);
|
|
|
|
// Claims tab
|
|
const searchClaims = document.getElementById("searchClaims");
|
|
const statusFilterClaims = document.getElementById("statusFilterClaims");
|
|
|
|
const performClaimsSearch = debounce(() => {
|
|
const searchTerm = searchClaims?.value.toLowerCase() || "";
|
|
const status = statusFilterClaims?.value || "";
|
|
|
|
let filtered = allClaims.filter((claim) => {
|
|
const matchesSearch =
|
|
claim.item_name.toLowerCase().includes(searchTerm) ||
|
|
claim.user_name.toLowerCase().includes(searchTerm);
|
|
const matchesStatus = !status || claim.status === status;
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
renderClaims(filtered);
|
|
}, 300);
|
|
|
|
searchClaims?.addEventListener("input", performClaimsSearch);
|
|
statusFilterClaims?.addEventListener("change", performClaimsSearch);
|
|
}
|
|
|
|
// Create edit item modal if not exists
|
|
if (!document.getElementById("editItemModal")) {
|
|
const editItemModal = document.createElement("div");
|
|
editItemModal.id = "editItemModal";
|
|
editItemModal.className = "modal";
|
|
editItemModal.innerHTML = `
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Edit Barang</h3>
|
|
<button class="close-btn" onclick="closeModal('editItemModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="editItemForm">
|
|
<input type="hidden" name="item_id">
|
|
<div class="form-group">
|
|
<label>Foto Barang (Opsional - Kosongkan jika tidak ingin mengubah)</label>
|
|
<input type="file" name="photo" accept="image/*">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nama Barang *</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Kategori *</label>
|
|
<select name="category" required>
|
|
<option value="">Pilih Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Lokasi Ditemukan *</label>
|
|
<input type="text" name="location" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Deskripsi Keunikan *</label>
|
|
<textarea name="description" required></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nama Pelapor *</label>
|
|
<input type="text" name="reporter_name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Kontak Pelapor *</label>
|
|
<input type="text" name="reporter_contact" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Tanggal Ditemukan *</label>
|
|
<input type="date" name="date_found" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Update</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(editItemModal);
|
|
}
|
|
|
|
// web/js/user.js
|
|
// Dashboard User JavaScript - FIXED ENDPOINTS
|
|
|
|
let allItems = [];
|
|
let allLostItems = [];
|
|
let allClaims = [];
|
|
|
|
// Initialize dashboard
|
|
window.addEventListener("DOMContentLoaded", async () => {
|
|
const user = checkAuth();
|
|
|
|
// FIXED: Tambahkan pengecekan role yang lebih spesifik
|
|
if (!user) {
|
|
window.location.href = "/login";
|
|
return;
|
|
}
|
|
|
|
if (user.role !== "user") {
|
|
// Redirect ke dashboard yang sesuai dengan role
|
|
if (user.role === "admin") {
|
|
window.location.href = "/admin";
|
|
} else if (user.role === "manager") {
|
|
window.location.href = "/manager";
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Lanjutkan load data untuk user
|
|
await loadStats();
|
|
await loadBrowseItems();
|
|
setupSearchAndFilters();
|
|
});
|
|
|
|
// Load statistics - CORRECT (sudah sesuai)
|
|
async function loadStats() {
|
|
try {
|
|
const stats = await apiCall("/api/user/stats");
|
|
document.getElementById("statLost").textContent = stats.lost_items || 0;
|
|
document.getElementById("statFound").textContent = stats.found_items || 0;
|
|
document.getElementById("statClaims").textContent = stats.claims || 0;
|
|
} catch (error) {
|
|
console.error("Error loading stats:", error);
|
|
}
|
|
}
|
|
|
|
// Load browse items - CORRECT (sudah sesuai)
|
|
async function loadBrowseItems() {
|
|
setLoading("itemsGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/items");
|
|
allItems = response.data || [];
|
|
renderItems(allItems);
|
|
} catch (error) {
|
|
console.error("Error loading items:", error);
|
|
showEmptyState("itemsGrid", "📦", "Gagal memuat data barang");
|
|
}
|
|
}
|
|
|
|
// Render items
|
|
function renderItems(items) {
|
|
const grid = document.getElementById("itemsGrid");
|
|
|
|
if (!items || items.length === 0) {
|
|
showEmptyState("itemsGrid", "📦", "Belum ada barang ditemukan");
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<div class="item-card" onclick="viewItemDetail(${item.id})">
|
|
<img src="${
|
|
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
|
|
}"
|
|
alt="${item.name}"
|
|
class="item-image"
|
|
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${item.name}</h3>
|
|
<div class="item-meta">
|
|
<span>📍 ${item.location}</span>
|
|
<span>📅 ${formatDate(item.date_found)}</span>
|
|
<span>${getStatusBadge(item.status)}</span>
|
|
</div>
|
|
${
|
|
item.status === "unclaimed"
|
|
? `<button class="btn btn-primary btn-sm" onclick="event.stopPropagation(); openClaimModal(${item.id})">Klaim Barang</button>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// View item detail - CORRECT (sudah sesuai)
|
|
async function viewItemDetail(itemId) {
|
|
try {
|
|
const item = await apiCall(`/api/items/${itemId}`);
|
|
|
|
const modalContent = document.getElementById("itemDetailContent");
|
|
modalContent.innerHTML = `
|
|
<img src="${
|
|
item.photo_url || "https://via.placeholder.com/600x400?text=No+Image"
|
|
}"
|
|
alt="${item.name}"
|
|
style="width: 100%; max-height: 400px; object-fit: cover; border-radius: 10px; margin-bottom: 20px;"
|
|
onerror="this.src='https://via.placeholder.com/600x400?text=No+Image'">
|
|
<h3>${item.name}</h3>
|
|
<div style="display: grid; gap: 15px; margin-top: 20px;">
|
|
<div><strong>Kategori:</strong> ${item.category}</div>
|
|
<div><strong>Lokasi Ditemukan:</strong> ${item.location}</div>
|
|
<div><strong>Tanggal Ditemukan:</strong> ${formatDate(
|
|
item.date_found
|
|
)}</div>
|
|
<div><strong>Status:</strong> ${getStatusBadge(item.status)}</div>
|
|
</div>
|
|
${
|
|
item.status === "unclaimed"
|
|
? `<button class="btn btn-primary" onclick="openClaimModal(${item.id})" style="width: 100%; margin-top: 20px;">Klaim Barang Ini</button>`
|
|
: ""
|
|
}
|
|
`;
|
|
|
|
openModal("itemDetailModal");
|
|
} catch (error) {
|
|
console.error("Error loading item detail:", error);
|
|
showAlert("Gagal memuat detail barang", "danger");
|
|
}
|
|
}
|
|
|
|
// Open claim modal
|
|
function openClaimModal(itemId) {
|
|
closeModal("itemDetailModal");
|
|
|
|
const modalContent = document.getElementById("claimModalContent");
|
|
modalContent.innerHTML = `
|
|
<form id="claimForm" onsubmit="submitClaim(event, ${itemId})">
|
|
<div class="form-group">
|
|
<label>Deskripsi Barang (Jelaskan ciri-ciri barang Anda) *</label>
|
|
<textarea name="description" required placeholder="Jelaskan ciri khusus barang yang hanya Anda tahu..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Bukti Pendukung (Opsional)</label>
|
|
<input type="file" name="proof" accept="image/*">
|
|
<small style="color: #64748b;">Upload foto barang saat Anda masih memilikinya (opsional)</small>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>No. Kontak *</label>
|
|
<input type="text" name="contact" required placeholder="08123456789">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit Klaim</button>
|
|
</form>
|
|
`;
|
|
|
|
openModal("claimModal");
|
|
}
|
|
|
|
// Submit claim - CORRECT (sudah sesuai)
|
|
async function submitClaim(event, itemId) {
|
|
event.preventDefault();
|
|
|
|
const form = event.target;
|
|
const formData = new FormData(form);
|
|
formData.append("item_id", itemId);
|
|
|
|
try {
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Mengirim...';
|
|
|
|
await apiUpload("/api/claims", formData);
|
|
|
|
showAlert(
|
|
"Klaim berhasil disubmit! Menunggu verifikasi dari manager.",
|
|
"success"
|
|
);
|
|
closeModal("claimModal");
|
|
await loadBrowseItems();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error submitting claim:", error);
|
|
showAlert(error.message || "Gagal submit klaim", "danger");
|
|
} finally {
|
|
const submitBtn = form.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Submit Klaim";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load my lost items - CORRECT (sudah sesuai)
|
|
async function loadLost() {
|
|
setLoading("lostItemsGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/user/lost-items");
|
|
allLostItems = response.data || [];
|
|
renderLostItems(allLostItems);
|
|
} catch (error) {
|
|
console.error("Error loading lost items:", error);
|
|
showEmptyState("lostItemsGrid", "😢", "Gagal memuat data barang hilang");
|
|
}
|
|
}
|
|
|
|
// Render lost items
|
|
function renderLostItems(items) {
|
|
const grid = document.getElementById("lostItemsGrid");
|
|
|
|
if (!items || items.length === 0) {
|
|
showEmptyState(
|
|
"lostItemsGrid",
|
|
"😢",
|
|
"Anda belum melaporkan barang hilang"
|
|
);
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<div class="item-card">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${item.name}</h3>
|
|
<div class="item-meta">
|
|
<span>🏷️ ${item.category}</span>
|
|
<span>🎨 ${item.color}</span>
|
|
<span>📅 ${formatDate(item.date_lost)}</span>
|
|
${item.location ? `<span>📍 ${item.location}</span>` : ""}
|
|
</div>
|
|
<p style="color: #64748b; font-size: 0.9rem; margin-top: 10px;">${
|
|
item.description
|
|
}</p>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Load my found items - FIXED
|
|
async function loadFound() {
|
|
setLoading("foundItemsGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/user/items");
|
|
const items = response.data || [];
|
|
renderFoundItems(items);
|
|
} catch (error) {
|
|
console.error("Error loading found items:", error);
|
|
showEmptyState(
|
|
"foundItemsGrid",
|
|
"🎉",
|
|
"Gagal memuat data barang yang ditemukan"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Render found items
|
|
function renderFoundItems(items) {
|
|
const grid = document.getElementById("foundItemsGrid");
|
|
|
|
if (!items || items.length === 0) {
|
|
showEmptyState(
|
|
"foundItemsGrid",
|
|
"🎉",
|
|
"Anda belum melaporkan penemuan barang"
|
|
);
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = items
|
|
.map(
|
|
(item) => `
|
|
<div class="item-card">
|
|
<img src="${
|
|
item.photo_url || "https://via.placeholder.com/280x200?text=No+Image"
|
|
}"
|
|
alt="${item.name}"
|
|
class="item-image"
|
|
onerror="this.src='https://via.placeholder.com/280x200?text=No+Image'">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${item.name}</h3>
|
|
<div class="item-meta">
|
|
<span>📍 ${item.location}</span>
|
|
<span>📅 ${formatDate(item.date_found)}</span>
|
|
<span>${getStatusBadge(item.status)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Load my claims - CORRECT (sudah sesuai)
|
|
async function loadClaims() {
|
|
setLoading("claimsGrid", true);
|
|
|
|
try {
|
|
const response = await apiCall("/api/user/claims");
|
|
allClaims = response.data || [];
|
|
renderClaims(allClaims);
|
|
} catch (error) {
|
|
console.error("Error loading claims:", error);
|
|
showEmptyState("claimsGrid", "🤝", "Gagal memuat data klaim");
|
|
}
|
|
}
|
|
|
|
// Render claims
|
|
function renderClaims(claims) {
|
|
const grid = document.getElementById("claimsGrid");
|
|
|
|
if (!claims || claims.length === 0) {
|
|
showEmptyState("claimsGrid", "🤝", "Anda belum pernah mengajukan klaim");
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = claims
|
|
.map(
|
|
(claim) => `
|
|
<div class="item-card">
|
|
<div class="item-body">
|
|
<h3 class="item-title">${claim.item_name}</h3>
|
|
<div class="item-meta">
|
|
<span>📅 ${formatDate(claim.created_at)}</span>
|
|
<span>${getStatusBadge(claim.status)}</span>
|
|
</div>
|
|
<p style="color: #64748b; font-size: 0.9rem; margin-top: 10px;">
|
|
${claim.description}
|
|
</p>
|
|
${
|
|
claim.status === "rejected" && claim.notes
|
|
? `
|
|
<p style="color: #ef4444; font-size: 0.9rem; margin-top: 10px;">
|
|
<strong>Alasan ditolak:</strong> ${claim.notes}
|
|
</p>
|
|
`
|
|
: ""
|
|
}
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
// Report lost item
|
|
function openReportLostModal() {
|
|
openModal("reportLostModal");
|
|
}
|
|
|
|
// Submit lost item report - CORRECT (sudah sesuai)
|
|
document
|
|
.getElementById("reportLostForm")
|
|
?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const data = Object.fromEntries(formData);
|
|
|
|
try {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Mengirim...';
|
|
|
|
await apiCall("/api/lost-items", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
showAlert("Laporan kehilangan berhasil disubmit!", "success");
|
|
closeModal("reportLostModal");
|
|
e.target.reset();
|
|
await loadLost();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error submitting lost item:", error);
|
|
showAlert(error.message || "Gagal submit laporan", "danger");
|
|
} finally {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Submit Laporan";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Report found item
|
|
function openReportFoundModal() {
|
|
openModal("reportFoundModal");
|
|
}
|
|
|
|
// Submit found item report - CORRECT (sudah sesuai)
|
|
document
|
|
.getElementById("reportFoundForm")
|
|
?.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
|
|
try {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = '<span class="loading"></span> Mengirim...';
|
|
|
|
await apiUpload("/api/items", formData);
|
|
|
|
showAlert("Laporan penemuan berhasil disubmit!", "success");
|
|
closeModal("reportFoundModal");
|
|
e.target.reset();
|
|
await loadFound();
|
|
await loadStats();
|
|
} catch (error) {
|
|
console.error("Error submitting found item:", error);
|
|
showAlert(error.message || "Gagal submit laporan", "danger");
|
|
} finally {
|
|
const submitBtn = e.target.querySelector('button[type="submit"]');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Submit Penemuan";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Setup search and filters
|
|
function setupSearchAndFilters() {
|
|
const searchInput = document.getElementById("searchInput");
|
|
const categoryFilter = document.getElementById("categoryFilter");
|
|
const sortBy = document.getElementById("sortBy");
|
|
|
|
const performSearch = debounce(() => {
|
|
const searchTerm = searchInput?.value.toLowerCase() || "";
|
|
const category = categoryFilter?.value || "";
|
|
const sort = sortBy?.value || "date_desc";
|
|
|
|
let filtered = allItems.filter((item) => {
|
|
const matchesSearch =
|
|
item.name.toLowerCase().includes(searchTerm) ||
|
|
item.location.toLowerCase().includes(searchTerm);
|
|
const matchesCategory = !category || item.category === category;
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
|
|
// Sort
|
|
filtered.sort((a, b) => {
|
|
switch (sort) {
|
|
case "date_desc":
|
|
return new Date(b.date_found) - new Date(a.date_found);
|
|
case "date_asc":
|
|
return new Date(a.date_found) - new Date(b.date_found);
|
|
case "name_asc":
|
|
return a.name.localeCompare(b.name);
|
|
case "name_desc":
|
|
return b.name.localeCompare(a.name);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
renderItems(filtered);
|
|
}, 300);
|
|
|
|
searchInput?.addEventListener("input", performSearch);
|
|
categoryFilter?.addEventListener("change", performSearch);
|
|
sortBy?.addEventListener("change", performSearch);
|
|
}
|
|
|
|
// Create claim modal if not exists
|
|
if (!document.getElementById("claimModal")) {
|
|
const claimModal = document.createElement("div");
|
|
claimModal.id = "claimModal";
|
|
claimModal.className = "modal";
|
|
claimModal.innerHTML = `
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Klaim Barang</h3>
|
|
<button class="close-btn" onclick="closeModal('claimModal')">×</button>
|
|
</div>
|
|
<div class="modal-body" id="claimModalContent"></div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(claimModal);
|
|
}
|
|
|
|
// Create item detail modal if not exists
|
|
if (!document.getElementById("itemDetailModal")) {
|
|
const itemDetailModal = document.createElement("div");
|
|
itemDetailModal.id = "itemDetailModal";
|
|
itemDetailModal.className = "modal";
|
|
itemDetailModal.innerHTML = `
|
|
<div class="modal-content modal-large">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Detail Barang</h3>
|
|
<button class="close-btn" onclick="closeModal('itemDetailModal')">×</button>
|
|
</div>
|
|
<div class="modal-body" id="itemDetailContent"></div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(itemDetailModal);
|
|
}
|
|
|
|
<!-- web/admin.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dashboard Admin - Lost & Found</title>
|
|
<link rel="stylesheet" href="css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="navbar-brand">🔍 Lost & Found</div>
|
|
<div class="navbar-menu">
|
|
<div class="user-info">
|
|
<div class="user-avatar" id="userAvatar">A</div>
|
|
<span id="userName">Admin</span>
|
|
<span class="user-role badge-danger">Admin</span>
|
|
</div>
|
|
<button class="btn-logout" onclick="logout()">Logout</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<div class="page-header">
|
|
<h1>Dashboard Admin</h1>
|
|
<p>Kelola sistem Lost & Found</p>
|
|
</div>
|
|
|
|
<div class="stats-grid stats-grid-4">
|
|
<div class="stat-card">
|
|
<h3>Total User</h3>
|
|
<div class="stat-number" id="statTotalUsers">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Total Barang</h3>
|
|
<div class="stat-number" id="statTotalItems">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Total Klaim</h3>
|
|
<div class="stat-number" id="statTotalClaims">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Di Arsip</h3>
|
|
<div class="stat-number" id="statTotalArchive">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab-btn active" onclick="switchTab('users')">👥 Kelola User</button>
|
|
<button class="tab-btn" onclick="switchTab('categories')">🏷️ Kategori</button>
|
|
<button class="tab-btn" onclick="switchTab('reports')">📊 Laporan</button>
|
|
<button class="tab-btn" onclick="switchTab('audit')">🔍 Audit Log</button>
|
|
</div>
|
|
|
|
<!-- Tab: Kelola User -->
|
|
<div id="usersTab" class="tab-content active">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Daftar User</h2>
|
|
<button class="btn btn-primary" onclick="openAddUserModal()">+ Tambah User</button>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari user..." id="searchUsers">
|
|
<select class="filter-select" id="roleFilter">
|
|
<option value="">Semua Role</option>
|
|
<option value="user">User</option>
|
|
<option value="manager">Manager</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
<select class="filter-select" id="statusFilter">
|
|
<option value="">Semua Status</option>
|
|
<option value="active">Active</option>
|
|
<option value="blocked">Blocked</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table class="data-table" id="usersTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Nama</th>
|
|
<th>Email</th>
|
|
<th>NRP</th>
|
|
<th>Role</th>
|
|
<th>Status</th>
|
|
<th>Aksi</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="usersTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Kategori -->
|
|
<div id="categoriesTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Kelola Kategori</h2>
|
|
<button class="btn btn-primary" onclick="openAddCategoryModal()">+ Tambah Kategori</button>
|
|
</div>
|
|
|
|
<div class="categories-grid" id="categoriesGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Laporan -->
|
|
<div id="reportsTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Export Laporan</h2>
|
|
</div>
|
|
|
|
<div class="report-section">
|
|
<div class="report-filters">
|
|
<h3>Filter Laporan</h3>
|
|
<div class="form-group">
|
|
<label>Periode</label>
|
|
<select id="reportPeriod">
|
|
<option value="month">Bulan Ini</option>
|
|
<option value="semester">Semester Ini</option>
|
|
<option value="year">Tahun Ini</option>
|
|
<option value="custom">Custom Range</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" id="customDateRange" style="display: none;">
|
|
<label>Tanggal Mulai</label>
|
|
<input type="date" id="reportStartDate">
|
|
<label>Tanggal Akhir</label>
|
|
<input type="date" id="reportEndDate">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Tipe Laporan</label>
|
|
<select id="reportType">
|
|
<option value="all">Semua Data</option>
|
|
<option value="items">Barang Ditemukan</option>
|
|
<option value="lost">Barang Hilang</option>
|
|
<option value="claims">Klaim</option>
|
|
<option value="archive">Arsip</option>
|
|
</select>
|
|
</div>
|
|
<div class="report-actions">
|
|
<button class="btn btn-success" onclick="exportReport('pdf')">📄 Export PDF</button>
|
|
<button class="btn btn-success" onclick="exportReport('excel')">📊 Export Excel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="report-preview" id="reportPreview">
|
|
<h3>Preview Laporan</h3>
|
|
<div class="report-stats">
|
|
<div class="report-stat-item">
|
|
<strong>Total Barang:</strong>
|
|
<span id="previewTotalItems">-</span>
|
|
</div>
|
|
<div class="report-stat-item">
|
|
<strong>Diklaim:</strong>
|
|
<span id="previewClaimed">-</span>
|
|
</div>
|
|
<div class="report-stat-item">
|
|
<strong>Expired:</strong>
|
|
<span id="previewExpired">-</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Audit Log -->
|
|
<div id="auditTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Audit Log</h2>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari log..." id="searchAudit">
|
|
<select class="filter-select" id="actionFilter">
|
|
<option value="">Semua Aksi</option>
|
|
<option value="create">Create</option>
|
|
<option value="update">Update</option>
|
|
<option value="delete">Delete</option>
|
|
<option value="verify">Verify</option>
|
|
</select>
|
|
<input type="date" class="filter-select" id="dateFilter">
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table class="data-table" id="auditTable">
|
|
<thead>
|
|
<tr>
|
|
<th>Waktu</th>
|
|
<th>User</th>
|
|
<th>Aksi</th>
|
|
<th>Detail</th>
|
|
<th>IP Address</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="auditTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Add User -->
|
|
<div id="addUserModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Tambah User</h3>
|
|
<button class="close-btn" onclick="closeModal('addUserModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="addUserForm">
|
|
<div class="form-group">
|
|
<label>Nama Lengkap *</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Email *</label>
|
|
<input type="email" name="email" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>NRP *</label>
|
|
<input type="text" name="nrp" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>No. Telepon *</label>
|
|
<input type="tel" name="phone" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Role *</label>
|
|
<select name="role" required>
|
|
<option value="user">User</option>
|
|
<option value="manager">Manager</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Password *</label>
|
|
<input type="password" name="password" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Tambah User</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Edit User -->
|
|
<div id="editUserModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Edit User</h3>
|
|
<button class="close-btn" onclick="closeModal('editUserModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="editUserForm">
|
|
<input type="hidden" name="user_id">
|
|
<div class="form-group">
|
|
<label>Nama Lengkap *</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Email *</label>
|
|
<input type="email" name="email" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>NRP *</label>
|
|
<input type="text" name="nrp" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>No. Telepon *</label>
|
|
<input type="tel" name="phone" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Role *</label>
|
|
<select name="role" required>
|
|
<option value="user">User</option>
|
|
<option value="manager">Manager</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Update User</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Add Category -->
|
|
<div id="addCategoryModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Tambah Kategori</h3>
|
|
<button class="close-btn" onclick="closeModal('addCategoryModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="addCategoryForm">
|
|
<div class="form-group">
|
|
<label>Nama Kategori *</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Deskripsi</label>
|
|
<textarea name="description"></textarea>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Tambah Kategori</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- REFACTORED: Update script references -->
|
|
<script src="js/main.js"></script>
|
|
<script src="js/admin.js"></script>
|
|
</body>
|
|
</html>
|
|
|
|
<!-- web/index.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Lost & Found System</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-dark: #1e40af;
|
|
--secondary: #64748b;
|
|
--success: #10b981;
|
|
--danger: #ef4444;
|
|
--warning: #f59e0b;
|
|
--light: #f8fafc;
|
|
--dark: #1e293b;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
width: 100%;
|
|
}
|
|
|
|
.hero {
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.hero-header {
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
|
color: white;
|
|
padding: 60px 40px;
|
|
text-align: center;
|
|
}
|
|
|
|
.hero-header h1 {
|
|
font-size: 3rem;
|
|
margin-bottom: 10px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.hero-header p {
|
|
font-size: 1.2rem;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.hero-body {
|
|
padding: 50px 40px;
|
|
}
|
|
|
|
.features {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 30px;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.feature-card {
|
|
background: var(--light);
|
|
padding: 30px;
|
|
border-radius: 15px;
|
|
text-align: center;
|
|
transition: transform 0.3s, box-shadow 0.3s;
|
|
}
|
|
|
|
.feature-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.feature-icon {
|
|
font-size: 3rem;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.feature-card h3 {
|
|
color: var(--dark);
|
|
margin-bottom: 10px;
|
|
font-size: 1.3rem;
|
|
}
|
|
|
|
.feature-card p {
|
|
color: var(--secondary);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.cta-section {
|
|
text-align: center;
|
|
padding: 40px 0;
|
|
}
|
|
|
|
.cta-buttons {
|
|
display: flex;
|
|
gap: 20px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 15px 40px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--primary-dark);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.3);
|
|
}
|
|
|
|
.btn-outline {
|
|
background: transparent;
|
|
color: var(--primary);
|
|
border: 2px solid var(--primary);
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-top: 50px;
|
|
padding: 40px;
|
|
background: var(--light);
|
|
border-radius: 15px;
|
|
}
|
|
|
|
.stat-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.hero-header h1 {
|
|
font-size: 2rem;
|
|
}
|
|
|
|
.hero-header p {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.hero-header, .hero-body {
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
.features {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.cta-buttons {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.footer {
|
|
text-align: center;
|
|
padding: 30px;
|
|
color: white;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.footer p {
|
|
opacity: 0.8;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="hero">
|
|
<div class="hero-header">
|
|
<h1>🔍 Lost & Found System</h1>
|
|
<p>Sistem Manajemen Barang Hilang & Temuan Kampus</p>
|
|
</div>
|
|
|
|
<div class="hero-body">
|
|
<div class="features">
|
|
<div class="feature-card">
|
|
<div class="feature-icon">📢</div>
|
|
<h3>Lapor Kehilangan</h3>
|
|
<p>Laporkan barang yang hilang dengan mudah dan cepat</p>
|
|
</div>
|
|
|
|
<div class="feature-card">
|
|
<div class="feature-icon">📦</div>
|
|
<h3>Temukan Barang</h3>
|
|
<p>Cari barang temuanmu di database kami</p>
|
|
</div>
|
|
|
|
<div class="feature-card">
|
|
<div class="feature-icon">🤝</div>
|
|
<h3>Klaim Barang</h3>
|
|
<p>Proses klaim yang aman dengan verifikasi</p>
|
|
</div>
|
|
|
|
<div class="feature-card">
|
|
<div class="feature-icon">⚡</div>
|
|
<h3>Auto Matching</h3>
|
|
<p>Sistem otomatis mencocokkan barang hilang</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cta-section">
|
|
<h2 style="margin-bottom: 30px; color: var(--dark);">Mulai Sekarang</h2>
|
|
<div class="cta-buttons">
|
|
<a href="/login" class="btn btn-primary">🔐 Login</a>
|
|
<a href="/register" class="btn btn-outline">📝 Register</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats">
|
|
<div class="stat-item">
|
|
<div class="stat-number" id="totalItems">0</div>
|
|
<div class="stat-label">Barang Ditemukan</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-number" id="totalClaimed">0</div>
|
|
<div class="stat-label">Sudah Diklaim</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-number" id="totalUsers">0</div>
|
|
<div class="stat-label">Pengguna Terdaftar</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>© 2025 Lost & Found System. Built with Go & Love ❤️</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Animasi counter
|
|
function animateCounter(id, target) {
|
|
const element = document.getElementById(id);
|
|
let current = 0;
|
|
const increment = target / 50;
|
|
const timer = setInterval(() => {
|
|
current += increment;
|
|
if (current >= target) {
|
|
element.textContent = target;
|
|
clearInterval(timer);
|
|
} else {
|
|
element.textContent = Math.floor(current);
|
|
}
|
|
}, 30);
|
|
}
|
|
|
|
// Simulasi data (nanti akan diganti dengan API call ke backend)
|
|
window.addEventListener('load', () => {
|
|
animateCounter('totalItems', 127);
|
|
animateCounter('totalClaimed', 89);
|
|
animateCounter('totalUsers', 234);
|
|
});
|
|
|
|
// Uncomment ini ketika backend sudah siap
|
|
/*
|
|
async function fetchStats() {
|
|
try {
|
|
const response = await fetch('http://localhost:8080/stats');
|
|
const data = await response.json();
|
|
document.getElementById('totalItems').textContent = data.total_items;
|
|
document.getElementById('totalClaimed').textContent = data.total_claimed;
|
|
document.getElementById('totalUsers').textContent = data.total_users;
|
|
} catch (error) {
|
|
console.error('Error fetching stats:', error);
|
|
}
|
|
}
|
|
fetchStats();
|
|
*/
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|
|
<!-- web/login.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Login - Lost & Found</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-dark: #1e40af;
|
|
--danger: #ef4444;
|
|
--success: #10b981;
|
|
--light: #f8fafc;
|
|
--dark: #1e293b;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.login-container {
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
overflow: hidden;
|
|
max-width: 450px;
|
|
width: 100%;
|
|
}
|
|
|
|
.login-header {
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
|
color: white;
|
|
padding: 40px 30px;
|
|
text-align: center;
|
|
}
|
|
|
|
.login-header h1 {
|
|
font-size: 2rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.login-header p {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.login-body {
|
|
padding: 40px 30px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: var(--dark);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
transition: border-color 0.3s;
|
|
}
|
|
|
|
.form-group input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.form-group input.error {
|
|
border-color: var(--danger);
|
|
}
|
|
|
|
.error-message {
|
|
color: var(--danger);
|
|
font-size: 0.85rem;
|
|
margin-top: 5px;
|
|
display: none;
|
|
}
|
|
|
|
.error-message.show {
|
|
display: block;
|
|
}
|
|
|
|
.alert {
|
|
padding: 12px 15px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
display: none;
|
|
}
|
|
|
|
.alert.show {
|
|
display: block;
|
|
}
|
|
|
|
.alert-error {
|
|
background: #fee;
|
|
color: var(--danger);
|
|
border: 1px solid var(--danger);
|
|
}
|
|
|
|
.alert-success {
|
|
background: #efe;
|
|
color: var(--success);
|
|
border: 1px solid var(--success);
|
|
}
|
|
|
|
.btn-login {
|
|
width: 100%;
|
|
padding: 15px;
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-login:hover {
|
|
background: var(--primary-dark);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.3);
|
|
}
|
|
|
|
.btn-login:disabled {
|
|
background: #94a3b8;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.divider {
|
|
text-align: center;
|
|
margin: 25px 0;
|
|
color: #64748b;
|
|
position: relative;
|
|
}
|
|
|
|
.divider::before,
|
|
.divider::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 45%;
|
|
height: 1px;
|
|
background: #e2e8f0;
|
|
}
|
|
|
|
.divider::before {
|
|
left: 0;
|
|
}
|
|
|
|
.divider::after {
|
|
right: 0;
|
|
}
|
|
|
|
.register-link {
|
|
text-align: center;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.register-link a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.register-link a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.back-home {
|
|
text-align: center;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.back-home a {
|
|
color: #64748b;
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.back-home a:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.loading {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid rgba(255,255,255,.3);
|
|
border-radius: 50%;
|
|
border-top-color: white;
|
|
animation: spin 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.login-container {
|
|
margin: 10px;
|
|
}
|
|
|
|
.login-header {
|
|
padding: 30px 20px;
|
|
}
|
|
|
|
.login-body {
|
|
padding: 30px 20px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-container">
|
|
<div class="login-header">
|
|
<h1>🔐 Login</h1>
|
|
<p>Masuk ke Lost & Found System</p>
|
|
</div>
|
|
|
|
<div class="login-body">
|
|
<div id="alertBox" class="alert"></div>
|
|
|
|
<form id="loginForm">
|
|
<div class="form-group">
|
|
<label for="email">Email</label>
|
|
<input type="email" id="email" name="email" placeholder="mahasiswa@example.com" required>
|
|
<div class="error-message" id="emailError">Email tidak valid</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="password">Password</label>
|
|
<input type="password" id="password" name="password" placeholder="********" required>
|
|
<div class="error-message" id="passwordError">Password minimal 6 karakter</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn-login" id="loginBtn">
|
|
Login
|
|
</button>
|
|
</form>
|
|
|
|
<div class="divider">atau</div>
|
|
|
|
<div class="register-link">
|
|
<p>Belum punya akun? <a href="/register">Register disini</a></p>
|
|
</div>
|
|
|
|
<div class="back-home">
|
|
<a href="/">← Kembali ke Beranda</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_URL = 'http://localhost:8080/api';
|
|
|
|
const loginForm = document.getElementById('loginForm');
|
|
const emailInput = document.getElementById('email');
|
|
const passwordInput = document.getElementById('password');
|
|
const loginBtn = document.getElementById('loginBtn');
|
|
const alertBox = document.getElementById('alertBox');
|
|
|
|
// Validasi Email
|
|
function validateEmail(email) {
|
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return re.test(email);
|
|
}
|
|
|
|
// Show Alert
|
|
function showAlert(message, type = 'error') {
|
|
alertBox.textContent = message;
|
|
alertBox.className = `alert alert-${type} show`;
|
|
setTimeout(() => {
|
|
alertBox.classList.remove('show');
|
|
}, 5000);
|
|
}
|
|
|
|
// Clear errors
|
|
function clearErrors() {
|
|
document.querySelectorAll('.error-message').forEach(el => {
|
|
el.classList.remove('show');
|
|
});
|
|
document.querySelectorAll('input').forEach(el => {
|
|
el.classList.remove('error');
|
|
});
|
|
}
|
|
|
|
// Form submit
|
|
loginForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
clearErrors();
|
|
|
|
const email = emailInput.value.trim();
|
|
const password = passwordInput.value;
|
|
|
|
// Validasi frontend
|
|
let hasError = false;
|
|
|
|
if (!validateEmail(email)) {
|
|
emailInput.classList.add('error');
|
|
document.getElementById('emailError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (password.length < 6) {
|
|
passwordInput.classList.add('error');
|
|
document.getElementById('passwordError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (hasError) return;
|
|
|
|
// Disable button dan tampilkan loading
|
|
loginBtn.disabled = true;
|
|
loginBtn.innerHTML = '<span class="loading"></span> Logging in...';
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ email, password })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
console.log('Login response:', data); // DEBUG
|
|
|
|
// Simpan token dan user info
|
|
localStorage.setItem('token', data.data.token);
|
|
localStorage.setItem('user', JSON.stringify(data.data.user));
|
|
|
|
console.log('Token saved:', localStorage.getItem('token')); // DEBUG
|
|
console.log('User saved:', localStorage.getItem('user')); // DEBUG
|
|
|
|
showAlert('Login berhasil! Mengalihkan...', 'success');
|
|
|
|
setTimeout(() => {
|
|
const role = data.data.user.role;
|
|
console.log('Redirecting to:', role); // DEBUG
|
|
|
|
if (role === 'admin') {
|
|
window.location.href = '/admin';
|
|
} else if (role === 'manager') {
|
|
window.location.href = '/manager';
|
|
} else {
|
|
window.location.href = '/user';
|
|
}
|
|
}, 500);
|
|
} else {
|
|
showAlert(data.error || 'Email atau password salah!');
|
|
}
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
showAlert('Terjadi kesalahan. Pastikan server berjalan di port 8080');
|
|
} finally {
|
|
loginBtn.disabled = false;
|
|
loginBtn.textContent = 'Login';
|
|
}
|
|
});
|
|
|
|
// Check jika sudah login - FIXED: gunakan underscore
|
|
window.addEventListener('load', () => {
|
|
const token = localStorage.getItem('token');
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
|
|
if (token && user.role) {
|
|
// Redirect ke dashboard sesuai role
|
|
if (user.role === 'admin') {
|
|
window.location.href = '/admin'; // ✅ FIXED
|
|
} else if (user.role === 'manager') {
|
|
window.location.href = '/manager'; // ✅ FIXED
|
|
} else {
|
|
window.location.href = '/user'; // ✅ FIXED
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|
|
<!-- web/manager.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dashboard Manager - Lost & Found</title>
|
|
<link rel="stylesheet" href="css/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="navbar-brand">🔍 Lost & Found</div>
|
|
<div class="navbar-menu">
|
|
<div class="user-info">
|
|
<div class="user-avatar" id="userAvatar">M</div>
|
|
<span id="userName">Manager</span>
|
|
<span class="user-role">Manager</span>
|
|
</div>
|
|
<button class="btn-logout" onclick="logout()">Logout</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<div class="page-header">
|
|
<h1>Dashboard Manager</h1>
|
|
<p>Kelola barang temuan dan verifikasi klaim</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<h3>Total Barang</h3>
|
|
<div class="stat-number" id="statTotalItems">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Pending Claim</h3>
|
|
<div class="stat-number stat-warning" id="statPendingClaims">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Verified</h3>
|
|
<div class="stat-number stat-success" id="statVerified">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Expired</h3>
|
|
<div class="stat-number stat-danger" id="statExpired">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab-btn active" onclick="switchTab('items')">📦 Kelola Barang</button>
|
|
<button class="tab-btn" onclick="switchTab('claims')">🤝 Verifikasi Klaim</button>
|
|
<button class="tab-btn" onclick="switchTab('lost')">😢 Barang Hilang</button>
|
|
<button class="tab-btn" onclick="switchTab('archive')">📂 Arsip</button>
|
|
</div>
|
|
|
|
<!-- Tab: Kelola Barang -->
|
|
<div id="itemsTab" class="tab-content active">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Daftar Barang Ditemukan</h2>
|
|
<button class="btn btn-primary" onclick="openReportFoundModal()">+ Tambah Barang</button>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari barang..." id="searchItems">
|
|
<select class="filter-select" id="categoryFilterItems">
|
|
<option value="">Semua Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
<select class="filter-select" id="statusFilterItems">
|
|
<option value="">Semua Status</option>
|
|
<option value="unclaimed">Unclaimed</option>
|
|
<option value="pending_claim">Pending Claim</option>
|
|
<option value="verified">Verified</option>
|
|
<option value="case_closed">Case Closed</option>
|
|
</select>
|
|
<select class="filter-select" id="sortItems">
|
|
<option value="date_desc">Terbaru</option>
|
|
<option value="date_asc">Terlama</option>
|
|
<option value="name_asc">Nama A-Z</option>
|
|
<option value="name_desc">Nama Z-A</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="items-grid" id="itemsGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Verifikasi Klaim -->
|
|
<div id="claimsTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Daftar Klaim</h2>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari klaim..." id="searchClaims">
|
|
<select class="filter-select" id="statusFilterClaims">
|
|
<option value="">Semua Status</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="approved">Approved</option>
|
|
<option value="rejected">Rejected</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="claims-list" id="claimsList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Barang Hilang -->
|
|
<div id="lostTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Barang Hilang</h2>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari barang hilang..." id="searchLost">
|
|
<select class="filter-select" id="categoryFilterLost">
|
|
<option value="">Semua Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="items-grid" id="lostItemsGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: Arsip -->
|
|
<div id="archiveTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Arsip Barang</h2>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari arsip..." id="searchArchive">
|
|
<select class="filter-select" id="categoryFilterArchive">
|
|
<option value="">Semua Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="items-grid" id="archiveGrid"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Report Found -->
|
|
<div id="reportFoundModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Tambah Barang Ditemukan</h3>
|
|
<button class="close-btn" onclick="closeModal('reportFoundModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="reportFoundForm">
|
|
<div class="form-group">
|
|
<label>Foto Barang *</label>
|
|
<input type="file" name="photo" accept="image/*" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nama Barang *</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Kategori *</label>
|
|
<select name="category" required>
|
|
<option value="">Pilih Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Lokasi Ditemukan *</label>
|
|
<input type="text" name="location" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Deskripsi Keunikan (Rahasia) *</label>
|
|
<textarea name="description" required placeholder="Ciri khusus untuk verifikasi..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nama Pelapor *</label>
|
|
<input type="text" name="reporter_name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Kontak Pelapor *</label>
|
|
<input type="text" name="reporter_contact" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Tanggal Ditemukan *</label>
|
|
<input type="date" name="date_found" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Detail Item -->
|
|
<div id="itemDetailModal" class="modal">
|
|
<div class="modal-content modal-large">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Detail Barang</h3>
|
|
<button class="close-btn" onclick="closeModal('itemDetailModal')">×</button>
|
|
</div>
|
|
<div class="modal-body" id="itemDetailContent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Verify Claim -->
|
|
<div id="verifyClaimModal" class="modal">
|
|
<div class="modal-content modal-large">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Verifikasi Klaim</h3>
|
|
<button class="close-btn" onclick="closeModal('verifyClaimModal')">×</button>
|
|
</div>
|
|
<div class="modal-body" id="verifyClaimContent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Match Items -->
|
|
<div id="matchItemsModal" class="modal">
|
|
<div class="modal-content modal-large">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Cari Barang yang Mirip</h3>
|
|
<button class="close-btn" onclick="closeModal('matchItemsModal')">×</button>
|
|
</div>
|
|
<div class="modal-body" id="matchItemsContent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="js/main.js"></script>
|
|
<script src="js/manager.js"></script>
|
|
</body>
|
|
</html>
|
|
|
|
<!-- web/register.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Register - Lost & Found</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-dark: #1e40af;
|
|
--danger: #ef4444;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--light: #f8fafc;
|
|
--dark: #1e293b;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.register-container {
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
overflow: hidden;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
margin: 20px auto;
|
|
}
|
|
|
|
.register-header {
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
|
color: white;
|
|
padding: 40px 30px;
|
|
text-align: center;
|
|
}
|
|
|
|
.register-header h1 {
|
|
font-size: 2rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.register-header p {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.register-body {
|
|
padding: 40px 30px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: var(--dark);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select {
|
|
width: 100%;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
transition: border-color 0.3s;
|
|
}
|
|
|
|
.form-group input:focus,
|
|
.form-group select:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.form-group input.error,
|
|
.form-group select.error {
|
|
border-color: var(--danger);
|
|
}
|
|
|
|
.error-message {
|
|
color: var(--danger);
|
|
font-size: 0.85rem;
|
|
margin-top: 5px;
|
|
display: none;
|
|
}
|
|
|
|
.error-message.show {
|
|
display: block;
|
|
}
|
|
|
|
.alert {
|
|
padding: 12px 15px;
|
|
border-radius: 10px;
|
|
margin-bottom: 20px;
|
|
display: none;
|
|
}
|
|
|
|
.alert.show {
|
|
display: block;
|
|
}
|
|
|
|
.alert-error {
|
|
background: #fee;
|
|
color: var(--danger);
|
|
border: 1px solid var(--danger);
|
|
}
|
|
|
|
.alert-success {
|
|
background: #efe;
|
|
color: var(--success);
|
|
border: 1px solid var(--success);
|
|
}
|
|
|
|
.password-strength {
|
|
margin-top: 5px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.strength-weak {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.strength-medium {
|
|
color: var(--warning);
|
|
}
|
|
|
|
.strength-strong {
|
|
color: var(--success);
|
|
}
|
|
|
|
.btn-register {
|
|
width: 100%;
|
|
padding: 15px;
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.btn-register:hover {
|
|
background: var(--primary-dark);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.3);
|
|
}
|
|
|
|
.btn-register:disabled {
|
|
background: #94a3b8;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.divider {
|
|
text-align: center;
|
|
margin: 25px 0;
|
|
color: #64748b;
|
|
position: relative;
|
|
}
|
|
|
|
.divider::before,
|
|
.divider::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 45%;
|
|
height: 1px;
|
|
background: #e2e8f0;
|
|
}
|
|
|
|
.divider::before {
|
|
left: 0;
|
|
}
|
|
|
|
.divider::after {
|
|
right: 0;
|
|
}
|
|
|
|
.login-link {
|
|
text-align: center;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.login-link a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.login-link a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.back-home {
|
|
text-align: center;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.back-home a {
|
|
color: #64748b;
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.back-home a:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.loading {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid rgba(255,255,255,.3);
|
|
border-radius: 50%;
|
|
border-top-color: white;
|
|
animation: spin 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 15px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.register-container {
|
|
margin: 10px;
|
|
}
|
|
|
|
.register-header {
|
|
padding: 30px 20px;
|
|
}
|
|
|
|
.register-body {
|
|
padding: 30px 20px;
|
|
}
|
|
|
|
.form-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="register-container">
|
|
<div class="register-header">
|
|
<h1>🔐 Register</h1>
|
|
<p>Buat akun Lost & Found System</p>
|
|
</div>
|
|
|
|
<div class="register-body">
|
|
<div id="alertBox" class="alert"></div>
|
|
|
|
<form id="registerForm">
|
|
<div class="form-group">
|
|
<label for="name">Nama Lengkap</label>
|
|
<input type="text" id="name" name="name" placeholder="John Doe" required>
|
|
<div class="error-message" id="nameError">Nama minimal 3 karakter</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="email">Email</label>
|
|
<input type="email" id="email" name="email" placeholder="mahasiswa@example.com" required>
|
|
<div class="error-message" id="emailError">Email tidak valid</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="nrp">NRP</label>
|
|
<input type="text" id="nrp" name="nrp" placeholder="5026211234" required>
|
|
<div class="error-message" id="nrpError">NRP minimal 10 digit</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="phone">No. Telepon</label>
|
|
<input type="tel" id="phone" name="phone" placeholder="08123456789" required>
|
|
<div class="error-message" id="phoneError">Nomor tidak valid</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="password">Password</label>
|
|
<input type="password" id="password" name="password" placeholder="********" required>
|
|
<div class="password-strength" id="passwordStrength"></div>
|
|
<div class="error-message" id="passwordError">Password minimal 8 karakter</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="confirmPassword">Konfirmasi Password</label>
|
|
<input type="password" id="confirmPassword" name="confirmPassword" placeholder="********" required>
|
|
<div class="error-message" id="confirmPasswordError">Password tidak cocok</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn-register" id="registerBtn">
|
|
Register
|
|
</button>
|
|
</form>
|
|
|
|
<div class="divider">atau</div>
|
|
|
|
<div class="login-link">
|
|
<p>Sudah punya akun? <a href="/login">Login disini</a></p>
|
|
</div>
|
|
|
|
<div class="back-home">
|
|
<a href="/">← Kembali ke Beranda</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_URL = 'http://localhost:8080/api';
|
|
|
|
const registerForm = document.getElementById('registerForm');
|
|
const nameInput = document.getElementById('name');
|
|
const emailInput = document.getElementById('email');
|
|
const nrpInput = document.getElementById('nrp');
|
|
const phoneInput = document.getElementById('phone');
|
|
const passwordInput = document.getElementById('password');
|
|
const confirmPasswordInput = document.getElementById('confirmPassword');
|
|
const registerBtn = document.getElementById('registerBtn');
|
|
const alertBox = document.getElementById('alertBox');
|
|
|
|
// Validasi Email
|
|
function validateEmail(email) {
|
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return re.test(email);
|
|
}
|
|
|
|
// Validasi Phone
|
|
function validatePhone(phone) {
|
|
const re = /^(08|62)[0-9]{9,12}$/;
|
|
return re.test(phone);
|
|
}
|
|
|
|
// Check password strength
|
|
function checkPasswordStrength(password) {
|
|
const strengthEl = document.getElementById('passwordStrength');
|
|
let strength = 0;
|
|
|
|
if (password.length >= 8) strength++;
|
|
if (password.match(/[a-z]+/)) strength++;
|
|
if (password.match(/[A-Z]+/)) strength++;
|
|
if (password.match(/[0-9]+/)) strength++;
|
|
if (password.match(/[$@#&!]+/)) strength++;
|
|
|
|
if (password.length < 8) {
|
|
strengthEl.textContent = '';
|
|
strengthEl.className = 'password-strength';
|
|
} else if (strength <= 2) {
|
|
strengthEl.textContent = '⚠️ Password lemah';
|
|
strengthEl.className = 'password-strength strength-weak';
|
|
} else if (strength <= 4) {
|
|
strengthEl.textContent = '✓ Password sedang';
|
|
strengthEl.className = 'password-strength strength-medium';
|
|
} else {
|
|
strengthEl.textContent = '✓ Password kuat';
|
|
strengthEl.className = 'password-strength strength-strong';
|
|
}
|
|
}
|
|
|
|
// Show Alert
|
|
function showAlert(message, type = 'error') {
|
|
alertBox.textContent = message;
|
|
alertBox.className = `alert alert-${type} show`;
|
|
setTimeout(() => {
|
|
alertBox.classList.remove('show');
|
|
}, 5000);
|
|
}
|
|
|
|
// Clear errors
|
|
function clearErrors() {
|
|
document.querySelectorAll('.error-message').forEach(el => {
|
|
el.classList.remove('show');
|
|
});
|
|
document.querySelectorAll('input').forEach(el => {
|
|
el.classList.remove('error');
|
|
});
|
|
}
|
|
|
|
// Password strength checker
|
|
passwordInput.addEventListener('input', (e) => {
|
|
checkPasswordStrength(e.target.value);
|
|
});
|
|
|
|
// Form submit
|
|
registerForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
clearErrors();
|
|
|
|
const name = nameInput.value.trim();
|
|
const email = emailInput.value.trim();
|
|
const nrp = nrpInput.value.trim();
|
|
const phone = phoneInput.value.trim();
|
|
const password = passwordInput.value;
|
|
const confirmPassword = confirmPasswordInput.value;
|
|
|
|
// Validasi frontend
|
|
let hasError = false;
|
|
|
|
if (name.length < 3) {
|
|
nameInput.classList.add('error');
|
|
document.getElementById('nameError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (!validateEmail(email)) {
|
|
emailInput.classList.add('error');
|
|
document.getElementById('emailError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (nrp.length < 10) {
|
|
nrpInput.classList.add('error');
|
|
document.getElementById('nrpError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (!validatePhone(phone)) {
|
|
phoneInput.classList.add('error');
|
|
document.getElementById('phoneError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
passwordInput.classList.add('error');
|
|
document.getElementById('passwordError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
confirmPasswordInput.classList.add('error');
|
|
document.getElementById('confirmPasswordError').classList.add('show');
|
|
hasError = true;
|
|
}
|
|
|
|
if (hasError) return;
|
|
|
|
// Disable button dan tampilkan loading
|
|
registerBtn.disabled = true;
|
|
registerBtn.innerHTML = '<span class="loading"></span> Registering...';
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/register`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
name,
|
|
email,
|
|
nrp,
|
|
phone,
|
|
password
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
showAlert('Registrasi berhasil! Mengalihkan ke login...', 'success');
|
|
|
|
setTimeout(() => {
|
|
window.location.href = '/login';
|
|
}, 2000);
|
|
} else {
|
|
showAlert(data.error || 'Registrasi gagal!');
|
|
}
|
|
} catch (error) {
|
|
console.error('Register error:', error);
|
|
showAlert('Terjadi kesalahan. Pastikan server berjalan di port 8080');
|
|
} finally {
|
|
registerBtn.disabled = false;
|
|
registerBtn.textContent = 'Register';
|
|
}
|
|
});
|
|
|
|
// Check jika sudah login - REFACTORED: URL lebih simple
|
|
window.addEventListener('load', () => {
|
|
const token = localStorage.getItem('token');
|
|
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
|
|
if (token && user.role) {
|
|
// Redirect ke dashboard sesuai role
|
|
if (user.role === 'admin') {
|
|
window.location.href = '/admin'; // ✅ REFACTORED
|
|
} else if (user.role === 'manager') {
|
|
window.location.href = '/manager'; // ✅ REFACTORED
|
|
} else {
|
|
window.location.href = '/user'; // ✅ REFACTORED
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|
|
<!-- web/user.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dashboard User - Lost & Found</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-dark: #1e40af;
|
|
--danger: #ef4444;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--light: #f8fafc;
|
|
--dark: #1e293b;
|
|
--secondary: #64748b;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
.navbar {
|
|
background: white;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
padding: 15px 30px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.navbar-menu {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-link {
|
|
text-decoration: none;
|
|
color: var(--dark);
|
|
font-weight: 500;
|
|
padding: 8px 15px;
|
|
border-radius: 8px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.nav-link:hover {
|
|
background: var(--light);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: var(--primary);
|
|
color: white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn-logout {
|
|
background: var(--danger);
|
|
color: white;
|
|
border: none;
|
|
padding: 8px 20px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-logout:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 30px auto;
|
|
padding: 0 20px;
|
|
}
|
|
|
|
.page-header {
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
color: var(--dark);
|
|
font-size: 2rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 30px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 12px 25px;
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
color: var(--secondary);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.stat-card h3 {
|
|
color: var(--secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
border-radius: 15px;
|
|
padding: 25px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 1.3rem;
|
|
color: var(--dark);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn {
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.search-box {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.filter-select {
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.items-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.item-card {
|
|
background: white;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 15px;
|
|
overflow: hidden;
|
|
transition: all 0.3s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.item-card:hover {
|
|
transform: translateY(-5px);
|
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.item-image {
|
|
width: 100%;
|
|
height: 200px;
|
|
object-fit: cover;
|
|
background: var(--light);
|
|
}
|
|
|
|
.item-body {
|
|
padding: 15px;
|
|
}
|
|
|
|
.item-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: var(--dark);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.item-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
color: var(--secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 5px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-success {
|
|
background: #d1fae5;
|
|
color: var(--success);
|
|
}
|
|
|
|
.badge-warning {
|
|
background: #fef3c7;
|
|
color: var(--warning);
|
|
}
|
|
|
|
.badge-danger {
|
|
background: #fee2e2;
|
|
color: var(--danger);
|
|
}
|
|
|
|
.badge-primary {
|
|
background: #dbeafe;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 1000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
animation: fadeIn 0.3s;
|
|
}
|
|
|
|
.modal.active {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 20px;
|
|
max-width: 600px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
animation: slideUp 0.3s;
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 25px;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.close-btn {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
color: var(--secondary);
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 25px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 600;
|
|
color: var(--dark);
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group textarea,
|
|
.form-group select {
|
|
width: 100%;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-group textarea {
|
|
resize: vertical;
|
|
min-height: 100px;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--secondary);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 4rem;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
transform: translateY(50px);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.navbar {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.navbar-menu {
|
|
flex-direction: column;
|
|
width: 100%;
|
|
}
|
|
|
|
.items-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="navbar-brand">🔍 Lost & Found</div>
|
|
<div class="navbar-menu">
|
|
<div class="user-info">
|
|
<div class="user-avatar" id="userAvatar">U</div>
|
|
<span id="userName">User</span>
|
|
</div>
|
|
<button class="btn-logout" onclick="logout()">Logout</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<div class="page-header">
|
|
<h1>Dashboard User</h1>
|
|
<p>Selamat datang di Lost & Found System</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<h3>Barang Hilang Saya</h3>
|
|
<div class="stat-number" id="statLost">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Barang yang Saya Temukan</h3>
|
|
<div class="stat-number" id="statFound">0</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>Klaim Saya</h3>
|
|
<div class="stat-number" id="statClaims">0</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab-btn active" onclick="switchTab('browse')">📦 Cari Barang</button>
|
|
<button class="tab-btn" onclick="switchTab('lost')">😢 Barang Hilang Saya</button>
|
|
<button class="tab-btn" onclick="switchTab('found')">🎉 Barang yang Saya Temukan</button>
|
|
<button class="tab-btn" onclick="switchTab('claims')">🤝 Riwayat Klaim</button>
|
|
</div>
|
|
|
|
<!-- Tab: Browse Items -->
|
|
<div id="browseTab" class="tab-content active">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Barang Ditemukan</h2>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input type="text" class="search-input" placeholder="Cari barang..." id="searchInput">
|
|
<select class="filter-select" id="categoryFilter">
|
|
<option value="">Semua Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
<select class="filter-select" id="sortBy">
|
|
<option value="date_desc">Terbaru</option>
|
|
<option value="date_asc">Terlama</option>
|
|
<option value="name_asc">Nama A-Z</option>
|
|
<option value="name_desc">Nama Z-A</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="items-grid" id="itemsGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: My Lost Items -->
|
|
<div id="lostTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Barang Hilang Saya</h2>
|
|
<button class="btn btn-primary" onclick="openReportLostModal()">+ Lapor Kehilangan</button>
|
|
</div>
|
|
<div class="items-grid" id="lostItemsGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: My Found Items -->
|
|
<div id="foundTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Barang yang Saya Temukan</h2>
|
|
<button class="btn btn-primary" onclick="openReportFoundModal()">+ Lapor Penemuan</button>
|
|
</div>
|
|
<div class="items-grid" id="foundItemsGrid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab: My Claims -->
|
|
<div id="claimsTab" class="tab-content">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Riwayat Klaim</h2>
|
|
</div>
|
|
<div class="items-grid" id="claimsGrid"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Report Lost -->
|
|
<div id="reportLostModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Lapor Barang Hilang</h3>
|
|
<button class="close-btn" onclick="closeModal('reportLostModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="reportLostForm">
|
|
<div class="form-group">
|
|
<label>Nama Barang</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Kategori</label>
|
|
<select name="category" required>
|
|
<option value="">Pilih Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Warna</label>
|
|
<input type="text" name="color" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Lokasi Hilang (Opsional)</label>
|
|
<input type="text" name="location">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Deskripsi</label>
|
|
<textarea name="description" required></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Tanggal Hilang</label>
|
|
<input type="date" name="date_lost" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit Laporan</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Report Found -->
|
|
<div id="reportFoundModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Lapor Penemuan Barang</h3>
|
|
<button class="close-btn" onclick="closeModal('reportFoundModal')">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="reportFoundForm">
|
|
<div class="form-group">
|
|
<label>Foto Barang</label>
|
|
<input type="file" name="photo" accept="image/*" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nama Barang</label>
|
|
<input type="text" name="name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Kategori</label>
|
|
<select name="category" required>
|
|
<option value="">Pilih Kategori</option>
|
|
<option value="pakaian">Pakaian</option>
|
|
<option value="alat_makan">Alat Makan</option>
|
|
<option value="aksesoris">Aksesoris</option>
|
|
<option value="elektronik">Elektronik</option>
|
|
<option value="alat_tulis">Alat Tulis</option>
|
|
<option value="lainnya">Lainnya</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Lokasi Ditemukan</label>
|
|
<input type="text" name="location" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Deskripsi Keunikan (Rahasia untuk Verifikasi)</label>
|
|
<textarea name="description" required placeholder="Ciri khusus yang hanya pemilik tahu..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Tanggal Ditemukan</label>
|
|
<input type="date" name="date_found" required>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Submit Penemuan</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="js/main.js"></script>
|
|
<script src="js/user.js"></script>
|
|
</body>
|
|
</html>
|
|
|
|
# .env
|
|
# Server Configuration
|
|
PORT=8080
|
|
ENVIRONMENT=development
|
|
|
|
# Database Configuration (MySQL/MariaDB)
|
|
DB_HOST=localhost
|
|
DB_PORT=3306
|
|
DB_USER=root
|
|
DB_PASSWORD=
|
|
DB_NAME=lost_and_found
|
|
DB_CHARSET=utf8mb4
|
|
DB_PARSE_TIME=True
|
|
DB_LOC=Local
|
|
|
|
# JWT Configuration
|
|
JWT_SECRET_KEY=L0stF0und$ecureK3y2024!@#M4h4s1sw4UAS*Pr0j3ct&BackendD3v
|
|
|
|
# Upload Configuration
|
|
UPLOAD_PATH=./uploads
|
|
MAX_UPLOAD_SIZE=10485760
|
|
|
|
# CORS Configuration
|
|
ALLOWED_ORIGINS=*
|
|
|
|
|
|
// go.mod
|
|
module lost-and-found
|
|
|
|
go 1.25.0
|
|
|
|
require (
|
|
github.com/gin-gonic/gin v1.11.0
|
|
github.com/go-playground/validator/v10 v10.28.0
|
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
|
github.com/joho/godotenv v1.5.1
|
|
github.com/jung-kurt/gofpdf v1.16.2
|
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
|
github.com/xuri/excelize/v2 v2.10.0
|
|
golang.org/x/crypto v0.44.0
|
|
gorm.io/driver/mysql v1.6.0
|
|
gorm.io/gorm v1.31.1
|
|
)
|
|
|
|
require (
|
|
filippo.io/edwards25519 v1.1.0 // indirect
|
|
github.com/bytedance/sonic v1.14.0 // indirect
|
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
|
github.com/go-playground/locales v0.14.1 // indirect
|
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
|
github.com/goccy/go-json v0.10.2 // indirect
|
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
github.com/jinzhu/now v1.1.5 // indirect
|
|
github.com/json-iterator/go v1.1.12 // indirect
|
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
github.com/leodido/go-urn v1.4.0 // indirect
|
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
github.com/quic-go/qpack v0.5.1 // indirect
|
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
|
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
|
github.com/xuri/efp v0.0.1 // indirect
|
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
|
go.uber.org/mock v0.5.0 // indirect
|
|
golang.org/x/arch v0.20.0 // indirect
|
|
golang.org/x/mod v0.29.0 // indirect
|
|
golang.org/x/net v0.46.0 // indirect
|
|
golang.org/x/sync v0.18.0 // indirect
|
|
golang.org/x/sys v0.38.0 // indirect
|
|
golang.org/x/text v0.31.0 // indirect
|
|
golang.org/x/tools v0.38.0 // indirect
|
|
google.golang.org/protobuf v1.36.9 // indirect
|
|
)
|
|
|
|
|
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
|
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
|
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
|
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
|
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
|
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
|
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
|
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
|
|