From 0afa6817a8401e1105d0f7649de62348cff0fcdc Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Sat, 15 Mar 2025 20:01:51 +0000 Subject: [PATCH] cannot fix issue with elapsed timer on admin dashboard. fixing issues with QR code editing. Grey = cant click Red = current Green = complete blue = skipped? --- app/(main)/admin/clues/page.js | 566 +++++++++++++++++++++++--- app/(main)/admin/page.js | 139 ++++++- app/(main)/clue/[clueId]/page.js | 140 ++++++- app/api/admin/clues/[clueId]/route.js | 160 ++++++-- app/api/admin/clues/route.js | 141 ++++++- app/api/admin/fix-database/route.js | 200 +++++++++ app/api/admin/fix-qr-codes/route.js | 80 ++++ app/api/clues/[clueId]/route.js | 47 ++- app/api/hunt/timer/route.js | 30 +- clue_hunt.db | Bin 77824 -> 86016 bytes lib/db.js | 95 ++++- utils/timerService.js | 142 +++++-- 12 files changed, 1529 insertions(+), 211 deletions(-) create mode 100644 app/api/admin/fix-database/route.js create mode 100644 app/api/admin/fix-qr-codes/route.js diff --git a/app/(main)/admin/clues/page.js b/app/(main)/admin/clues/page.js index 24137e4..fd4384c 100644 --- a/app/(main)/admin/clues/page.js +++ b/app/(main)/admin/clues/page.js @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; @@ -9,12 +9,24 @@ export default function AdminCluesPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const router = useRouter(); + const [isEditMode, setIsEditMode] = useState(false); + const [editingClue, setEditingClue] = useState(null); + const [fixMessage, setFixMessage] = useState(""); + // Add a lastRefresh timestamp to track when data was last fetched + const [lastRefresh, setLastRefresh] = useState(Date.now()); + // Add next refresh countdown state + const [nextRefresh, setNextRefresh] = useState(10); + // Add auto-refresh enabled/disabled state + const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true); + // Ref to store interval ID for cleanup + const refreshIntervalRef = useRef(null); - // Form state for adding new clues - removed coordinates + // Form state for adding new clues - add qrCode field back const [newClue, setNewClue] = useState({ title: "", description: "", location: "", + qr_code: "", // Add QR code field }); // Check if user is admin @@ -30,15 +42,63 @@ export default function AdminCluesPage() { } }, [session, status, router]); + // Controlled auto-refresh effect + useEffect(() => { + // Only set up auto-refresh if enabled and not in edit mode + if (autoRefreshEnabled && !isEditMode && status === "authenticated") { + // Clear any existing interval + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + + // Set initial countdown value + setNextRefresh(10); + + // Set up countdown interval + refreshIntervalRef.current = setInterval(() => { + setNextRefresh((prev) => { + // When countdown reaches 0, refresh the data and reset countdown + if (prev <= 1) { + fetchClues(); + return 10; + } + return prev - 1; + }); + }, 1000); + } + + // Clean up interval on unmount or when dependencies change + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current); + } + }; + }, [autoRefreshEnabled, isEditMode, status]); + + // Disable auto-refresh when entering edit mode + useEffect(() => { + if (isEditMode) { + setAutoRefreshEnabled(false); + } + }, [isEditMode]); + // Fetch all clues const fetchClues = async () => { + // Skip fetch if in edit mode to prevent data loss + if (isEditMode) { + return; + } + try { setLoading(true); - const response = await fetch("/api/admin/clues", { + // Add timestamp parameter to bust cache + const timestamp = Date.now(); + const response = await fetch(`/api/admin/clues?t=${timestamp}`, { method: "GET", headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", }, credentials: "include", }); @@ -57,8 +117,13 @@ export default function AdminCluesPage() { throw new Error("Invalid response format from server"); } + console.log("Fetched clues:", data); // Debug log to see what's coming from API setClues(data); setError(""); + // Update lastRefresh timestamp + setLastRefresh(Date.now()); + // Reset countdown after manual refresh + setNextRefresh(10); } catch (err) { console.error("Error fetching clues:", err); setError(err.message); @@ -81,6 +146,8 @@ export default function AdminCluesPage() { e.preventDefault(); try { setLoading(true); + + // Now include the QR code in the request const response = await fetch("/api/admin/clues", { method: "POST", headers: { @@ -90,18 +157,23 @@ export default function AdminCluesPage() { credentials: "include", }); + const data = await response.json(); + if (!response.ok) { - throw new Error("Failed to add clue"); + throw new Error( + data.error || `Failed to add clue (Status: ${response.status})` + ); } - // Refresh the clue list - fetchClues(); + // Refresh the clue list with force refresh + await fetchClues(); - // Reset the form - removed coordinates + // Reset the form - now with qrCode setNewClue({ title: "", description: "", location: "", + qr_code: "", }); setError(""); @@ -113,12 +185,102 @@ export default function AdminCluesPage() { } }; - // Handle deleting a clue - const handleDeleteClue = async (clueId) => { - if (!confirm("Are you sure you want to delete this clue?")) return; + // Start editing a clue + const handleEditStart = (clue) => { + // Disable auto-refresh when starting to edit + setAutoRefreshEnabled(false); + setEditingClue({ + id: clue.id, + title: clue.title, + description: clue.description, + location: clue.location || "", + // Use the actual qr_code from the clue object if available + qr_code: clue.qr_code || `QR${clue.id}`, + }); + setIsEditMode(true); + }; + + // Handle input change for editing clue + const handleEditChange = (e) => { + const { name, value } = e.target; + setEditingClue((prev) => ({ + ...prev, + [name]: value, + })); + }; + + // Handle form submission to update clue + const handleUpdateClue = async (e) => { + e.preventDefault(); + try { + setLoading(true); + + const response = await fetch(`/api/admin/clues/${editingClue.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: editingClue.title, + description: editingClue.description, + location: editingClue.location, + qr_code: editingClue.qr_code, // Add QR code to the update + }), + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error( + data.error || `Failed to update clue (Status: ${response.status})` + ); + } + + // Refresh the clue list with force refresh + await fetchClues(); + setIsEditMode(false); + setEditingClue(null); + setError(""); + } catch (err) { + console.error("Error updating clue:", err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + // Cancel editing + const handleCancelEdit = () => { + setIsEditMode(false); + setEditingClue(null); + // Re-enable auto-refresh when canceling edit + setAutoRefreshEnabled(true); + }; + + // Force refresh the clue list + const handleForceRefresh = () => { + fetchClues(); + }; + + // Toggle auto-refresh + const toggleAutoRefresh = () => { + setAutoRefreshEnabled((prev) => !prev); + }; + + // Handle deleting a clue with automatic QR code renumbering + const handleDeleteClue = async (clueId, qrCode) => { + if ( + !confirm( + "Are you sure you want to delete this clue?\n\nQR codes will be automatically renumbered to prevent gaps in the sequence." + ) + ) + return; try { setLoading(true); + + // QR codes will always be renumbered - no need to pass this parameter anymore const response = await fetch(`/api/admin/clues/${clueId}`, { method: "DELETE", headers: { @@ -127,16 +289,23 @@ export default function AdminCluesPage() { credentials: "include", }); + // Parse the response data for detailed error messages + const data = await response.json().catch(() => ({})); + if (!response.ok) { - throw new Error("Failed to delete clue"); + throw new Error( + data.error || `Failed to delete clue (Status: ${response.status})` + ); } - // Refresh the clue list + // Success message and refresh + console.log("Clue deleted successfully:", data.message || "Success"); + // Refresh the clue list to show updated QR codes fetchClues(); setError(""); } catch (err) { console.error("Error deleting clue:", err); - setError(err.message); + setError(`Error deleting clue: ${err.message}`); } finally { setLoading(false); } @@ -154,62 +323,186 @@ export default function AdminCluesPage() {
Manage Clues
-
+
+ + +
{error &&

{error}

} + {fixMessage &&

{fixMessage}

} - {/* Add New Clue Form - removed coordinate fields */} -
-

Add New Clue

-
-
- - -
+ {/* Add New Clue Form */} + {!isEditMode ? ( +
+

Add New Clue

+ +
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
- - -
+ {/* Add QR code field with new label */} +
+ + +
+ QR codes should follow the format QR[number] (e.g., QR1, QR2). + Leave blank to auto-generate. +
+
- {/* Existing Clues List - updated to remove coordinates */} + + +
+ ) : ( +
+

Edit Clue

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ QR codes should follow the format QR[number] (e.g., QR1, QR2). + The QR code value determines what users scan to find this + clue. +
+
+ +
+ + +
+
+
+ )} + + {/* Existing Clues List */}

Existing Clues

+

+ QR codes are the values that users scan to find each clue. You can + customize these values, but they should usually follow the format + QR[number]. +

+
+ Last updated: {new Date(lastRefresh).toLocaleTimeString()} + {autoRefreshEnabled && !isEditMode && ( + (next refresh in {nextRefresh}s) + )} +
{clues.length === 0 ? (

No clues found.

) : ( @@ -218,12 +511,20 @@ export default function AdminCluesPage() {

{clue.title}

- +
+ + +

{clue.description}

{clue.location && ( @@ -234,6 +535,9 @@ export default function AdminCluesPage() {

Position: Clue #{clue.id} in sequence

+

+ QR Code: {clue.qr_code || `QR${clue.id}`} +

))}
@@ -276,7 +580,7 @@ export default function AdminCluesPage() { .page-header { display: flex; - justify-content: center; + justify-content: space-between; align-items: center; padding: 1rem; background-color: #68543c; @@ -288,6 +592,39 @@ export default function AdminCluesPage() { font-weight: 600; } + .header-controls { + display: flex; + gap: 10px; + align-items: center; + } + + .auto-refresh-toggle { + display: flex; + align-items: center; + gap: 5px; + background-color: #5a4832; + color: white; + border: none; + border-radius: 4px; + padding: 5px 10px; + cursor: pointer; + font-size: 0.9rem; + opacity: 0.8; + } + + .auto-refresh-toggle.active { + background-color: #4caf50; + opacity: 1; + } + + .refresh-countdown { + font-size: 0.8rem; + background-color: rgba(255, 255, 255, 0.25); + border-radius: 3px; + padding: 0 5px; + margin-left: 3px; + } + .admin-content { flex: 1; padding: 1rem; @@ -400,6 +737,11 @@ export default function AdminCluesPage() { margin-bottom: 0.4rem; } + .clue-qr-code { + font-size: 0.9rem; + margin-bottom: 0.4rem; + } + .error-message { color: red; margin-bottom: 1rem; @@ -462,6 +804,100 @@ export default function AdminCluesPage() { .admin-nav-label { font-size: 13px; } + + .help-text { + font-size: 0.8rem; + color: #666; + margin-top: 4px; + } + + .form-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; + } + + .cancel-button { + background-color: #757575; + } + + .edit-button { + background-color: #2196f3; + color: white; + border: none; + border-radius: 4px; + padding: 0.4rem 0.6rem; + font-size: 0.9rem; + cursor: pointer; + margin-right: 8px; + } + + .clue-actions { + display: flex; + gap: 0.5rem; + } + + .admin-input.disabled { + background-color: #f5f5f5; + color: #666; + } + + .admin-tools { + display: none; + } + + .tool-button, + .sequence-button, + .fix-button { + display: none; + } + + .tool-description { + display: none; + } + + .success-message { + color: #4caf50; + margin-bottom: 1rem; + padding: 0.5rem; + border-radius: 4px; + background-color: #e8f5e9; + width: 100%; + max-width: 700px; + text-align: center; + } + + .info-text { + font-size: 0.9rem; + color: #666; + margin-bottom: 1rem; + padding: 0.5rem; + background-color: #f5f5f5; + border-left: 3px solid #2196f3; + } + + .refresh-button { + background: transparent; + border: none; + font-size: 1.2rem; + cursor: pointer; + padding: 0.5rem; + margin-left: 1rem; + color: white; + } + + .refresh-button:hover { + transform: rotate(90deg); + transition: transform 0.3s ease; + } + + .last-updated { + font-size: 0.8rem; + color: #666; + margin-bottom: 1rem; + text-align: right; + font-style: italic; + } `}
); diff --git a/app/(main)/admin/page.js b/app/(main)/admin/page.js index 9f9bcbf..632ef9e 100644 --- a/app/(main)/admin/page.js +++ b/app/(main)/admin/page.js @@ -21,6 +21,7 @@ export default function AdminDashboard() { const [presetSeconds, setPresetSeconds] = useState(""); const [timerEndTime, setTimerEndTime] = useState(null); const timerRef = useRef(null); + const timerIntervalRef = useRef(null); const router = useRouter(); // Load timer status on page load @@ -28,33 +29,65 @@ export default function AdminDashboard() { async function loadTimerStatus() { try { setLoading(true); + + // First, check if we already have timer data in localStorage + const existingTimer = getHuntTimer(); + if (existingTimer && existingTimer.startTime) { + // If we have timer data, immediately set the display + setTimerActive(existingTimer.isActive); + if (existingTimer.endTime) { + setTimerEndTime(new Date(existingTimer.endTime).getTime()); + } + setTimerDisplay(formatElapsedTime(existingTimer.startTime)); + } + + // Then, fetch fresh data from the server to ensure we're in sync const response = await fetch("/api/hunt/timer", { credentials: "include", headers: { - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", }, }); + + if (!response.ok) { + throw new Error(`Server responded with status: ${response.status}`); + } + const data = await response.json(); - if (data.active) { + // Check both activity indicators to handle cases where they might be inconsistent + const isActive = data.active || data.timerActive; + + if (isActive && data.startTime) { setTimerActive(true); - // Calculate elapsed time - if (data.startTime) { - // Store the start time in the centralized service - setHuntTimer(data.startTime, data.endTime); + // Store the start time in the centralized service + setHuntTimer(data.startTime, data.endTime); - // If there's an end time, set it - if (data.endTime) { - setTimerEndTime(new Date(data.endTime).getTime()); - } + // If there's an end time, set it + if (data.endTime) { + setTimerEndTime(new Date(data.endTime).getTime()); + } else { + setTimerEndTime(null); + } + + // Immediately display the current elapsed time + setTimerDisplay(formatElapsedTime(data.startTime)); + } else { + // Only clear local timer if server explicitly says timer is not active + // This prevents clearing a valid timer if the server response is ambiguous + if (data.active === false && data.timerActive === false) { + clearHuntTimer(); + setTimerActive(false); + setTimerDisplay("--:--:--"); } } setLoading(false); } catch (err) { console.error("Failed to load timer status:", err); - setError("Failed to load timer status"); + setError(`Failed to load timer status: ${err.message}`); setLoading(false); } } @@ -62,6 +95,14 @@ export default function AdminDashboard() { if (status === "authenticated") { loadTimerStatus(); } + + // Clear interval when component unmounts + return () => { + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + timerIntervalRef.current = null; + } + }; }, [status]); // Check if user is admin @@ -77,11 +118,15 @@ export default function AdminDashboard() { // Timer update effect - use the centralized timer service useEffect(() => { - let intervalId; + // Clear any existing interval first to prevent multiple intervals + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + timerIntervalRef.current = null; + } if (timerActive) { - // Use the existing timer data from the centralized service - intervalId = setInterval(() => { + // Start a new interval for timer updates + timerIntervalRef.current = setInterval(() => { const timerData = getHuntTimer(); if (timerData && timerData.startTime) { @@ -94,7 +139,8 @@ export default function AdminDashboard() { stopTimer(); } } else { - setTimerDisplay("--:--:--"); + // If timer data is suddenly gone, try to reload from server + fetchTimerStatusFromServer(); } }, 1000); } else { @@ -102,12 +148,56 @@ export default function AdminDashboard() { } return () => { - if (intervalId) { - clearInterval(intervalId); + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + timerIntervalRef.current = null; } }; }, [timerActive, timerEndTime]); + // Add a function to fetch timer status directly from the server + const fetchTimerStatusFromServer = async () => { + try { + const response = await fetch("/api/hunt/timer", { + credentials: "include", + headers: { + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + }, + }); + + if (!response.ok) { + console.error("Server responded with status:", response.status); + return; + } + + const data = await response.json(); + + // Check both activity indicators for consistency + const isActive = data.active || data.timerActive; + + if (isActive && data.startTime) { + setTimerActive(true); + setHuntTimer(data.startTime, data.endTime); + + if (data.endTime) { + setTimerEndTime(new Date(data.endTime).getTime()); + } else { + setTimerEndTime(null); + } + + setTimerDisplay(formatElapsedTime(data.startTime)); + } else if (data.active === false && data.timerActive === false) { + // Only clear timer if server explicitly says timer is inactive + setTimerActive(false); + clearHuntTimer(); + setTimerDisplay("--:--:--"); + } + } catch (err) { + console.error("Error fetching timer status:", err); + } + }; + const startTimer = async (preset = false) => { setLoading(true); try { @@ -152,6 +242,10 @@ export default function AdminDashboard() { }), }); + if (!response.ok) { + throw new Error(`Server responded with status: ${response.status}`); + } + const data = await response.json(); if (data.success) { @@ -166,13 +260,16 @@ export default function AdminDashboard() { // Update global timer data for all clients setHuntTimer(data.startTime, data.endTime); + // Immediately display the current elapsed time + setTimerDisplay(formatElapsedTime(data.startTime)); + setError(""); } else { setError(data.error || "Failed to start timer"); } } catch (err) { console.error("Failed to start timer:", err); - setError("Failed to start timer"); + setError(`Failed to start timer: ${err.message}`); } finally { setLoading(false); } @@ -189,6 +286,10 @@ export default function AdminDashboard() { body: JSON.stringify({ action: "stop" }), }); + if (!response.ok) { + throw new Error(`Server responded with status: ${response.status}`); + } + const data = await response.json(); if (data.success) { @@ -203,7 +304,7 @@ export default function AdminDashboard() { } } catch (err) { console.error("Failed to stop timer:", err); - setError("Failed to stop timer"); + setError(`Failed to stop timer: ${err.message}`); } finally { setLoading(false); } diff --git a/app/(main)/clue/[clueId]/page.js b/app/(main)/clue/[clueId]/page.js index 60e1b03..8b1f3fb 100644 --- a/app/(main)/clue/[clueId]/page.js +++ b/app/(main)/clue/[clueId]/page.js @@ -20,11 +20,27 @@ export default function CluePage({ params }) { const [showScanner, setShowScanner] = useState(false); const [scanResult, setScanResult] = useState(null); const [scanMessage, setScanMessage] = useState(null); + const [lastRefresh, setLastRefresh] = useState(0); // Track last refresh time const router = useRouter(); - const { status } = useSession(); + const { data: session, status } = useSession(); // Get session to check if user is admin const { clueId } = params; + // Check if user is admin + const isAdmin = session?.user?.role === "admin"; + + // This effect handles authentication and initial clue data fetch useEffect(() => { + let isMounted = true; // Flag to prevent state updates after unmount + const now = Date.now(); + + // Only allow refreshes every 10 seconds (more for admins) + const minRefreshInterval = isAdmin ? 30000 : 10000; // 30 seconds for admins, 10 seconds for regular users + + // Skip if we refreshed too recently and this isn't the first load + if (loading === false && now - lastRefresh < minRefreshInterval) { + return; + } + if (status === "unauthenticated") { router.push("/login"); return; @@ -34,11 +50,14 @@ export default function CluePage({ params }) { return; } - // Fetch clue data + // One-time fetch clue data with debouncing async function fetchClue() { try { setLoading(true); - const response = await fetch(`/api/clues/${clueId}`, { + + // Add timestamp to avoid browser caching + const timestamp = Date.now(); + const response = await fetch(`/api/clues/${clueId}?t=${timestamp}`, { method: "GET", headers: { "Content-Type": "application/json", @@ -57,49 +76,76 @@ export default function CluePage({ params }) { } const data = await response.json(); - setClue(data); - setFoundNow(data.foundNow); - setLoading(false); + + // Only update state if the component is still mounted + if (isMounted) { + setClue(data); + // Only set foundNow on initial load or if it's actually newly found + if (loading === true) { + setFoundNow(data.foundNow); + } + setLoading(false); + setLastRefresh(Date.now()); // Update last refresh timestamp + } } catch (err) { console.error("Error fetching clue:", err); - setError(err.message); - setLoading(false); + if (isMounted) { + setError(err.message); + setLoading(false); + } } } fetchClue(); - }, [clueId, router, status]); - // Update timer display + // Cleanup function to prevent state updates after unmount + return () => { + isMounted = false; + }; + }, [clueId, router, status, lastRefresh, loading, isAdmin]); // Add isAdmin and lastRefresh to dependencies + + // Separate effect to handle timer updates - doesn't re-fetch data useEffect(() => { + let timerId; + // Set up timer display updates const timerData = getHuntTimer(); if (!timerData || !timerData.isActive) { return; } - const timer = setInterval(() => { - setElapsedTime(formatElapsedTime(timerData.startTime)); - }, 1000); - // Initial set setElapsedTime(formatElapsedTime(timerData.startTime)); - return () => clearInterval(timer); - }, []); + // Update timer every second + timerId = setInterval(() => { + setElapsedTime(formatElapsedTime(timerData.startTime)); + }, 1000); + + return () => { + if (timerId) clearInterval(timerId); + }; + }, []); // Empty dependency array so it only runs once // Handle QR scan result const handleScan = (data) => { setScanResult(data); setScanMessage(`QR Code scanned: ${data}`); - // Here you would typically verify the QR code matches expected value - // and then mark the clue as found via API - - // For now, just simulate successful scan and move to the next clue try { - const scannedClueId = parseInt(data); - if (scannedClueId === parseInt(clueId)) { + // Handle both "QR123" format and just "123" format + let scannedClueId; + const match = data.match(/^QR(\d+)$/i); + + if (match) { + // Extract the number from QR[number] + scannedClueId = parseInt(match[1], 10); + } else { + // Try to parse the raw value + scannedClueId = parseInt(data, 10); + } + + if (!isNaN(scannedClueId) && scannedClueId === parseInt(clueId)) { // Correct QR code scanned, mark as found and move to next clue markClueAsFound(clueId); } else { @@ -178,6 +224,12 @@ export default function CluePage({ params }) { } }; + // Function for manual refresh - for admins to test without waiting + const handleManualRefresh = () => { + // Force a refresh by setting lastRefresh to 0 + setLastRefresh(0); + }; + if (loading) { return (
@@ -226,6 +278,19 @@ export default function CluePage({ params }) {
{elapsedTime}
+ {/* Admin tools section - only visible to admins */} + {isAdmin && ( +
+
Admin View
+ +
+ )} + {/* QR Scanner Section */}

Scan QR Code

@@ -517,6 +582,37 @@ export default function CluePage({ params }) { .nav-label { font-size: 12px; } + + /* Admin tools styling */ + .admin-tools { + background-color: #f8d7da; + border: 2px dashed #d9534f; + border-radius: 8px; + padding: 12px; + margin-bottom: 15px; + text-align: center; + width: 100%; + } + + .admin-badge { + font-weight: bold; + margin-bottom: 8px; + color: #d9534f; + } + + .admin-refresh-button { + background-color: #d9534f; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + font-size: 14px; + } + + .admin-refresh-button:hover { + background-color: #c9302c; + } `}
); diff --git a/app/api/admin/clues/[clueId]/route.js b/app/api/admin/clues/[clueId]/route.js index 8759173..f3eb5a7 100644 --- a/app/api/admin/clues/[clueId]/route.js +++ b/app/api/admin/clues/[clueId]/route.js @@ -1,5 +1,5 @@ import { getToken } from "next-auth/jwt"; -import { queryOne, run } from "@/lib/db"; +import { queryOne, query, run } from "@/lib/db"; export const dynamic = "force-dynamic"; // Disable caching @@ -32,39 +32,120 @@ export async function DELETE(request, context) { return Response.json({ error }, { status }); } - const { clueId } = context.params; + // Get clueId parameter - properly awaited + const { clueId } = await context.params; // Validate clueId is a number if (isNaN(parseInt(clueId))) { return Response.json({ error: "Invalid clue ID" }, { status: 400 }); } - // Check if clue exists - const clue = await queryOne("SELECT id FROM clues WHERE id = ?", [clueId]); + // Always set shouldRenumber to true - no longer read from request body + const shouldRenumber = true; + + // Check if clue exists and get its QR code value + const clue = await queryOne("SELECT id, qr_code FROM clues WHERE id = ?", [ + clueId, + ]); if (!clue) { return Response.json({ error: "Clue not found" }, { status: 404 }); } - // Delete clue record - await run("DELETE FROM clues WHERE id = ?", [clueId]); + console.log( + `Starting deletion of clue ${clueId} with QR code ${clue.qr_code}` + ); - // Also delete any references in team_clues table - await run(` - CREATE TABLE IF NOT EXISTS team_clues ( - team_id INTEGER NOT NULL, - clue_id INTEGER NOT NULL, - found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (team_id, clue_id), - FOREIGN KEY (team_id) REFERENCES teams(id), - FOREIGN KEY (clue_id) REFERENCES clues(id) - ) - `); - await run("DELETE FROM team_clues WHERE clue_id = ?", [clueId]); + // Start a transaction for atomicity + try { + await run("BEGIN TRANSACTION"); - return Response.json({ - success: true, - message: "Clue deleted successfully", - }); + console.log("Transaction started"); + + // First, make sure the team_clues table exists and clear any references + await run(` + CREATE TABLE IF NOT EXISTS team_clues ( + team_id INTEGER NOT NULL, + clue_id INTEGER NOT NULL, + found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (team_id, clue_id), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (clue_id) REFERENCES clues(id) ON DELETE CASCADE + ) + `); + + // Delete any references in team_clues table + const deleteTeamClues = await run( + "DELETE FROM team_clues WHERE clue_id = ?", + [clueId] + ); + console.log( + `Deleted team clues result: ${deleteTeamClues.changes} references cleared` + ); + + // Delete clue record + const deleteResult = await run("DELETE FROM clues WHERE id = ?", [ + clueId, + ]); + console.log(`Deleted clue result: ${deleteResult.changes} rows affected`); + + if (deleteResult.changes === 0) { + throw new Error(`Clue ${clueId} was not deleted`); + } + + // If renumbering is enabled (which is always true now) + if (shouldRenumber) { + // Always use the sequential renumbering approach + // This ensures QR codes are sequential regardless of DB IDs + console.log("Renumbering all QR codes sequentially"); + + // Get all remaining clues ordered by ID + const allClues = await query("SELECT id FROM clues ORDER BY id ASC"); + console.log(`Found ${allClues.length} clues to renumber`); + + // Update each clue to have sequential QR[index+1] format + for (let i = 0; i < allClues.length; i++) { + const clue = allClues[i]; + const sequentialQrCode = `QR${i + 1}`; + + const updateResult = await run( + "UPDATE clues SET qr_code = ? WHERE id = ?", + [sequentialQrCode, clue.id] + ); + + console.log( + `Updated clue ${clue.id} to ${sequentialQrCode}: ${updateResult.changes} rows affected` + ); + } + } + + // Commit the transaction + await run("COMMIT"); + console.log("Transaction committed successfully"); + + return Response.json({ + success: true, + message: "Clue deleted successfully", + renumbered: shouldRenumber, + }); + } catch (transactionError) { + // If any error occurs, roll back the transaction + console.error("Error during deletion transaction:", transactionError); + try { + await run("ROLLBACK"); + console.log("Transaction rolled back"); + } catch (rollbackError) { + console.error("Error while rolling back transaction:", rollbackError); + } + + // Provide more specific error message based on the error type + let errorMessage = "Failed to delete clue: " + transactionError.message; + if (transactionError.message.includes("SQLITE_CONSTRAINT")) { + errorMessage = + "Database constraint violation. The clue may be referenced by other records."; + } + + return Response.json({ error: errorMessage }, { status: 500 }); + } } catch (error) { console.error("Admin clue deletion error:", error); return Response.json( @@ -83,16 +164,17 @@ export async function GET(request, context) { return Response.json({ error }, { status }); } - const { clueId } = context.params; + // Get clueId parameter - properly awaited + const { clueId } = await context.params; // Validate clueId is a number if (isNaN(parseInt(clueId))) { return Response.json({ error: "Invalid clue ID" }, { status: 400 }); } - // Get the specific clue - removed coordinates + // Get the specific clue - now including qr_code const clue = await queryOne( - "SELECT id, title, description, location FROM clues WHERE id = ?", + "SELECT id, title, description, location, qr_code FROM clues WHERE id = ?", [clueId] ); @@ -119,15 +201,16 @@ export async function PUT(request, context) { return Response.json({ error }, { status }); } - const { clueId } = context.params; + // Get clueId parameter - properly awaited + const { clueId } = await context.params; // Validate clueId is a number if (isNaN(parseInt(clueId))) { return Response.json({ error: "Invalid clue ID" }, { status: 400 }); } - // Parse the request body - removed coordinates - const { title, description, location } = await request.json(); + // Parse the request body + const { title, description, location, qr_code } = await request.json(); // Validate required fields if (!title || !description) { @@ -137,20 +220,33 @@ export async function PUT(request, context) { ); } + // Validate QR code format if provided + if (qr_code && !qr_code.match(/^QR\d+$/i)) { + return Response.json( + { error: "QR code must follow the format QR[number], e.g. QR1, QR2" }, + { status: 400 } + ); + } + // Check if clue exists - const clue = await queryOne("SELECT id FROM clues WHERE id = ?", [clueId]); + const clue = await queryOne("SELECT id, qr_code FROM clues WHERE id = ?", [ + clueId, + ]); if (!clue) { return Response.json({ error: "Clue not found" }, { status: 404 }); } - // Update the clue - removed coordinates + // Allow custom QR code - no longer enforcing QR{id} format + const finalQrCode = qr_code || `QR${clueId}`; + + // Update the clue with the provided or default QR code await run( ` UPDATE clues - SET title = ?, description = ?, location = ? + SET title = ?, description = ?, location = ?, qr_code = ? WHERE id = ? `, - [title, description, location || "", clueId] + [title, description, location || "", finalQrCode, clueId] ); return Response.json({ diff --git a/app/api/admin/clues/route.js b/app/api/admin/clues/route.js index a867380..3b4187b 100644 --- a/app/api/admin/clues/route.js +++ b/app/api/admin/clues/route.js @@ -39,13 +39,14 @@ export async function GET(request) { title TEXT NOT NULL, description TEXT NOT NULL, location TEXT, + qr_code TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); - // Fetch all clues with sequential ordering + // Fetch all clues with sequential ordering - IMPORTANT: explicitly include qr_code field const clues = await query(` - SELECT id, title, description, location, created_at + SELECT id, title, description, location, qr_code, created_at FROM clues ORDER BY id ASC `); @@ -77,34 +78,136 @@ export async function POST(request) { return Response.json({ error }, { status }); } - // Parse the request body - simplified without coordinates - const { title, description, location = "" } = await request.json(); - - // Validate required fields - if (!title || !description) { + // Parse the request body - now accepting qr_code + let bodyData; + try { + bodyData = await request.json(); + } catch (parseError) { + console.error("Error parsing JSON request body:", parseError); return Response.json( - { error: "Title and description are required" }, + { error: "Invalid JSON in request body" }, { status: 400 } ); } - // Insert the new clue - simplified without coordinates - await run( - ` - INSERT INTO clues (title, description, location) - VALUES (?, ?, ?) - `, - [title, description, location] - ); + const { title, description, location = "", qr_code = "" } = bodyData; - // Get the inserted clue ID - const newClue = await queryOne("SELECT last_insert_rowid() as id"); + // Add more detailed validation + if (!title) { + return Response.json({ error: "Title is required" }, { status: 400 }); + } + + if (!description) { + return Response.json( + { error: "Description is required" }, + { status: 400 } + ); + } + + // Validate QR code format if provided + if (qr_code && !qr_code.match(/^QR\d+$/i)) { + return Response.json( + { error: "QR code must follow the format QR[number], e.g. QR1, QR2" }, + { status: 400 } + ); + } + + // Make sure the clues table exists before inserting + try { + await run(` + CREATE TABLE IF NOT EXISTS clues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL, + location TEXT, + qr_code TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Check if we need to add the qr_code column (for existing tables that don't have it) + try { + // First check if the column exists + const tableInfo = await query(`PRAGMA table_info(clues)`); + const qrCodeColumnExists = tableInfo.some( + (col) => col.name === "qr_code" + ); + + if (!qrCodeColumnExists) { + // If the column doesn't exist, add it with a default value + await run(`ALTER TABLE clues ADD COLUMN qr_code TEXT DEFAULT ''`); + console.log("Added qr_code column to clues table"); + } + } catch (alterTableError) { + console.error( + "Error checking/modifying table structure:", + alterTableError + ); + // Continue anyway - the insertion will either work or fail + } + } catch (tableError) { + console.error("Error ensuring table exists:", tableError); + return Response.json( + { error: "Database error: Could not ensure clues table exists" }, + { status: 500 } + ); + } + + // Insert the new clue with QR code if provided + let result; + try { + if (qr_code) { + // If QR code is provided, use it + result = await run( + `INSERT INTO clues (title, description, location, qr_code) VALUES (?, ?, ?, ?)`, + [title, description, location, qr_code] + ); + } else { + // Otherwise, just insert without QR code (will be set later) + result = await run( + `INSERT INTO clues (title, description, location) VALUES (?, ?, ?)`, + [title, description, location] + ); + } + } catch (insertError) { + console.error("Database insert error:", insertError); + return Response.json( + { error: `Database error: ${insertError.message}` }, + { status: 500 } + ); + } + + // Get the inserted clue ID and update QR codes + let newClueId; + try { + const newClue = await queryOne("SELECT last_insert_rowid() as id"); + newClueId = newClue?.id; + + // If no QR code was provided, update all QR codes to ensure sequential order + if (!qr_code && newClueId) { + // Update all clues to have sequential QR codes + const allClues = await query("SELECT id FROM clues ORDER BY id ASC"); + + // Update each clue to have QR[index+1] format (sequential without gaps) + for (let i = 0; i < allClues.length; i++) { + const clue = allClues[i]; + const sequentialQrCode = `QR${i + 1}`; + await run("UPDATE clues SET qr_code = ? WHERE id = ?", [ + sequentialQrCode, + clue.id, + ]); + } + } + } catch (updateError) { + console.error("Error updating QR codes:", updateError); + // Continue anyway, the clue was inserted successfully + } return Response.json( { success: true, message: "Clue added successfully", - clueId: newClue.id, + clueId: newClueId || null, }, { status: 201 } ); diff --git a/app/api/admin/fix-database/route.js b/app/api/admin/fix-database/route.js new file mode 100644 index 0000000..333957c --- /dev/null +++ b/app/api/admin/fix-database/route.js @@ -0,0 +1,200 @@ +import { getToken } from "next-auth/jwt"; +import { query, queryOne, run } from "@/lib/db"; + +export const dynamic = "force-dynamic"; // Disable caching + +// Ensure the user is an admin +async function verifyAdmin(request) { + const token = await getToken({ + req: request, + secret: + process.env.NEXTAUTH_SECRET || + "your-fallback-secret-should-be-at-least-32-chars", + }); + + if (!token) { + return { authorized: false, error: "Unauthorized", status: 401 }; + } + + if (token.role !== "admin") { + return { authorized: false, error: "Admin access required", status: 403 }; + } + + return { authorized: true, token }; +} + +// POST endpoint to fix database schema issues +export async function POST(request) { + try { + // Verify admin access + const { authorized, error, status } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + const result = { + success: true, + operations: [], + errors: [], + }; + + // Get current table structure + const tableInfo = await query("PRAGMA table_info(clues)"); + console.log("Current clues table schema:", tableInfo); + + // Check if qr_code column exists and its constraints + const qrCodeColumn = tableInfo.find((col) => col.name === "qr_code"); + + if (!qrCodeColumn) { + // QR code column doesn't exist, add it with default empty string + try { + await run("ALTER TABLE clues ADD COLUMN qr_code TEXT DEFAULT ''"); + result.operations.push( + "Added qr_code column with TEXT type and empty default" + ); + } catch (err) { + console.error("Error adding qr_code column:", err); + result.errors.push(`Failed to add qr_code column: ${err.message}`); + } + } else if (qrCodeColumn.notnull === 1 && !qrCodeColumn.dflt_value) { + // Column exists but has NOT NULL constraint with no default + // We need to recreate the table to modify constraints + try { + // Start transaction + await run("BEGIN TRANSACTION"); + + // Create new table with correct schema + await run(` + CREATE TABLE clues_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL, + location TEXT, + qr_code TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Copy data + await run(` + INSERT INTO clues_new (id, title, description, location, qr_code, created_at) + SELECT id, title, description, location, COALESCE(qr_code, 'QR' || id) as qr_code, created_at + FROM clues + `); + + // Drop old table + await run("DROP TABLE clues"); + + // Rename new table to original name + await run("ALTER TABLE clues_new RENAME TO clues"); + + // Commit transaction + await run("COMMIT"); + + result.operations.push("Recreated clues table with proper schema"); + } catch (err) { + // Rollback on error + try { + await run("ROLLBACK"); + } catch (rollbackErr) { + console.error("Error during rollback:", rollbackErr); + } + + console.error("Error fixing table schema:", err); + result.errors.push(`Failed to fix table schema: ${err.message}`); + } + } + + // Ensure all clues have proper QR codes + try { + // Update any NULL QR codes to match their ID + await run( + "UPDATE clues SET qr_code = 'QR' || id WHERE qr_code IS NULL OR qr_code = ''" + ); + result.operations.push("Updated any missing QR codes to match their IDs"); + } catch (err) { + console.error("Error updating QR codes:", err); + result.errors.push(`Failed to update QR codes: ${err.message}`); + } + + // Ensure all clues have proper sequential QR codes + try { + // Get all clues ordered by ID + const clues = await query("SELECT id FROM clues ORDER BY id ASC"); + + // Update each clue to have QR[index+1] format (sequential without gaps) + for (let i = 0; i < clues.length; i++) { + const clue = clues[i]; + const sequentialQrCode = `QR${i + 1}`; + await run("UPDATE clues SET qr_code = ? WHERE id = ?", [ + sequentialQrCode, + clue.id, + ]); + } + + result.operations.push( + "Updated all QR codes to be sequential (QR1, QR2, etc.)" + ); + } catch (err) { + console.error("Error updating sequential QR codes:", err); + result.errors.push( + `Failed to update sequential QR codes: ${err.message}` + ); + } + + // Add foreign key support for team_clues table + try { + await run(` + CREATE TABLE IF NOT EXISTS team_clues_new ( + team_id INTEGER NOT NULL, + clue_id INTEGER NOT NULL, + found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (team_id, clue_id), + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + FOREIGN KEY (clue_id) REFERENCES clues(id) ON DELETE CASCADE + ) + `); + + // Copy existing data if the table already exists + try { + await run(` + INSERT OR IGNORE INTO team_clues_new (team_id, clue_id, found_at) + SELECT team_id, clue_id, found_at FROM team_clues + `); + } catch (copyErr) { + // If the old table doesn't exist, this is fine + console.log("Note: No existing team_clues data to copy"); + } + + // Drop the old table if it exists + try { + await run("DROP TABLE IF EXISTS team_clues"); + } catch (dropErr) { + console.error("Error dropping old team_clues table:", dropErr); + } + + // Rename new table + await run("ALTER TABLE team_clues_new RENAME TO team_clues"); + + result.operations.push("Updated team_clues table with ON DELETE CASCADE"); + } catch (err) { + console.error("Error fixing team_clues table:", err); + result.errors.push(`Failed to fix team_clues table: ${err.message}`); + } + + // Return result summary + return Response.json({ + ...result, + message: + result.errors.length === 0 + ? "Database schema fixed successfully" + : "Database fix completed with some errors", + }); + } catch (error) { + console.error("Database fix error:", error); + return Response.json( + { error: "Failed to fix database: " + error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/fix-qr-codes/route.js b/app/api/admin/fix-qr-codes/route.js new file mode 100644 index 0000000..6601244 --- /dev/null +++ b/app/api/admin/fix-qr-codes/route.js @@ -0,0 +1,80 @@ +import { getToken } from "next-auth/jwt"; +import { query, run } from "@/lib/db"; + +export const dynamic = "force-dynamic"; // Disable caching + +// Ensure the user is an admin +async function verifyAdmin(request) { + const token = await getToken({ + req: request, + secret: + process.env.NEXTAUTH_SECRET || + "your-fallback-secret-should-be-at-least-32-chars", + }); + + if (!token) { + return { authorized: false, error: "Unauthorized", status: 401 }; + } + + if (token.role !== "admin") { + return { authorized: false, error: "Admin access required", status: 403 }; + } + + return { authorized: true, token }; +} + +// POST - Fix all QR codes in the database to follow the QR[id] format +export async function POST(request) { + try { + // Verify admin access + const { authorized, error, status } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + // Get all clues ordered by ID to maintain sequence + const clues = await query("SELECT id FROM clues ORDER BY id ASC"); + + // Count of fixed clues + let fixedCount = 0; + + // Start a transaction + await run("BEGIN TRANSACTION"); + + try { + // Assign sequential QR codes (QR1, QR2, QR3, etc.) based on array index + // This ensures no gaps in the QR sequence regardless of DB IDs + for (let i = 0; i < clues.length; i++) { + const sequentialQrCode = `QR${i + 1}`; + + // Update the clue with the sequential QR code + await run("UPDATE clues SET qr_code = ? WHERE id = ?", [ + sequentialQrCode, + clues[i].id, + ]); + + fixedCount++; + } + + // Commit changes + await run("COMMIT"); + + return Response.json({ + success: true, + message: `QR codes fixed successfully. Set ${fixedCount} clues to sequential QR codes without gaps.`, + totalClues: clues.length, + fixedClues: fixedCount, + }); + } catch (error) { + // Roll back on error + await run("ROLLBACK"); + throw error; + } + } catch (error) { + console.error("Fix QR codes error:", error); + return Response.json( + { error: "Failed to fix QR codes: " + error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/clues/[clueId]/route.js b/app/api/clues/[clueId]/route.js index e8ffef4..64d0f02 100644 --- a/app/api/clues/[clueId]/route.js +++ b/app/api/clues/[clueId]/route.js @@ -41,14 +41,23 @@ export async function GET(request, context) { return Response.json({ error: "Clue not found" }, { status: 404 }); } + // Get timestamp parameter to determine if this is a refresh call + const timestamp = request.nextUrl?.searchParams?.get("t"); + const isRefresh = timestamp && !isNaN(parseInt(timestamp)); + // Check if this team has found this clue before const found = await queryOne( "SELECT * FROM team_clues WHERE team_id = ? AND clue_id = ?", [token.teamId, clueId] ); - // If not found yet, mark it as found now - if (!found) { + // Only mark as "foundNow" if: + // 1. The clue was not found before + // 2. This is not a refresh request (or we can't determine) + // 3. The user is not an admin (admins don't need "foundNow" animations) + let foundNow = false; + + if (!found && (!isRefresh || token.role !== "admin")) { try { // First ensure the team_clues table exists await queryOne(` @@ -67,25 +76,41 @@ export async function GET(request, context) { "INSERT INTO team_clues (team_id, clue_id) VALUES (?, ?)", [token.teamId, clueId] ); + + // Set foundNow flag for non-admin users only + foundNow = token.role !== "admin"; + + console.log( + `Clue ${clueId} marked as found for team ${token.teamId}, foundNow=${foundNow}` + ); } catch (err) { console.error("Error marking clue as found:", err); // Continue even if there's an error recording the find } } + // Add different cache headers for admins vs regular users + const cacheHeaders = + token.role === "admin" + ? { + "Cache-Control": "private, max-age=10", // Allow 10 seconds of caching for admins + Pragma: "no-cache", + Expires: "0", + } + : { + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }; + return Response.json( { ...clue, - found: !!found, - foundNow: !found, + found: true, // Always return as found since the user is viewing it + foundNow, // Only true during initial discovery for non-admins + isAdmin: token.role === "admin", // Add flag so client knows user is admin }, - { - headers: { - "Cache-Control": "no-store, no-cache, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - } + { headers: cacheHeaders } ); } catch (error) { console.error("Error fetching clue:", error); diff --git a/app/api/hunt/timer/route.js b/app/api/hunt/timer/route.js index 475926f..c157962 100644 --- a/app/api/hunt/timer/route.js +++ b/app/api/hunt/timer/route.js @@ -218,19 +218,41 @@ export async function GET(request) { ? huntActiveSetting.value === "true" : false; + // Use the actual timer status as the source of truth if it exists + const isTimerActive = timerStatus ? !!timerStatus.active : false; + + // Determine the actual active state by checking both sources + // This ensures we don't miss an active timer due to inconsistent settings + const isActuallyActive = huntActive || isTimerActive; + if (!timerStatus) { return Response.json({ - active: huntActive, // Use settings value if timer not found + active: isActuallyActive, + timerActive: false, startTime: null, endTime: null, + serverTime: new Date().toISOString(), + timestamp: Date.now(), }); } - return Response.json({ - active: huntActive, // Return settings value for consistency - timerActive: !!timerStatus.active, // Also return timer status for admin panel + // Add cache-busting timestamp to ensure fresh data + const responseData = { + // Return consistent activity status from both sources + active: isActuallyActive, + timerActive: isTimerActive, startTime: timerStatus.start_time, endTime: timerStatus.end_time, + serverTime: new Date().toISOString(), + timestamp: Date.now(), + }; + + return Response.json(responseData, { + headers: { + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, }); } catch (error) { console.error("Error getting timer status:", error); diff --git a/clue_hunt.db b/clue_hunt.db index 6ca083594d91e094543251b10c7dd402180c51df..09f235a33d2d24bc0e27c1ef11842ef6b979765a 100644 GIT binary patch delta 2983 zcmbuBZ)_7~7{Kp(*SoIm?(N!@GKBb+C9>f-?!A9)54y2G1*ZeHSrUWj=(Z9X#)@4h z(P(amkoa-)Os1bS%2)rylI(*ajKmKlN|40xNex6lNF*UKLY7E^?_FEg>$<72>9u#y zy}$Q)-sgGV=XvLD({mrY=eGo1TL^*(aHrroFLB<1&B_P*=y@%Jn&{4w}WQ;3apc9Itk7Bk~h*r%}!s4t;)N7`;wJ=2s_X5K{gVJkmvW<=Gd0lKdlbs+ilr)*kR3hmC?+feVfc< ztj}EWZMFV22&eKBnPM(K4b!QY(p4+Uw!Bsto5)XQ?Kf~BwZHesP#W&oQ66LpOww(Q z>ntVdyKdUwLyVg{y{|G-D-$^oH8b31^DAzXFMLkNo{L7w3xlTMZQJVf&h}OJU@QNJ zZvv~nY5tu^9qzOY*s(gh6{a!dd=BH09-I_J>Ae4Yk_?(hxH$EQNR{Tfvr#~Thu~Xq z2bka@xB|W|MTg%8hk%=kx=zkc7sraZ)7b*?Lztm-D>=)(y$Q{+cRktm4DEr1sLuc0lottfJq>5kGN|k1TS$qGSUp;NfOek{pdw?W5%dqt@EQgsG)2~=I3H7mT0JN+P=0}o z!y7jb!Na&v8^_PJ%?RYXlvcv%#Vnpd9=`K@HEd^~1gK2mC*{{_(Z z2ykzy1(J6_YbcnZ+QW9fWhRQb*R#dK?5B^v2!t|j%u(RJMSH+s-~qT_3NHTy82lVG z4wW9<__0TAa)u4Xw6cCFQDd-drIToVEra(H%ZqG)d_`a{x`(Mr`ZxL#Jx34Go2g%^ zFWp-y!S%>>&Go+PxT}j?A-^H#$zx;(@h7pou+W8bkX zTU?V9ysS8qABW49BjbRR(UFp_zQmC1u6k0Hv5u2&0Wx^bpgAxRlD5|*N5ZBfIF z6w{E%CxqI#R_Bs=QFJC_Wjr$WRgBi3ROlKAHCU;HbEK!yO06BC@Nu!8vE|6<-k8yA zN-fQ|)R38R=E@bRs^!h$gajD9SF-M=M%8{Y1qX=O} za%O$m-PpL^MvPV1jwrkocQhBQ$gX(Eh&O`D@s6vt@wO7nmJb@cEGD^lJ3@vR?*g)U z>GLr8ki^>|)^IXhd_>$4o!bsiX33G!wRi)NTb3R~NZ;mGtJ2b-rF;yKKnr)7yNuiI zo1C7sTDtU9VhjmwlpI8nA{9h2A;yTonABvWmqL@; zKOnN=A_(9*I&uV1!;kW2;bx`e zwl#*1KX*BsSCN$ZHr36)y0V3uoDm*nr)^Tpb& zQNchooVBq}7<;WNN`iim1gE1s49W<8!3XHUXRu(y2`;R_C1B|wb*EW1^Je*8sgBm5>nCMD#y^nE|+5B$=Dfdxi&uvc4{fbWn?0Np^u<%DXj@7)Y<{b zh)iJ4m5y0&kNTd@V}Wwh9g^;R1BNVbf7`3$9vt%J=#VyiP@!m+ACztvDvj3%TdFt5 z5?u-g#G`Te4FgB*_Q4r^{4!^I1NgAL?#JMb>qa6Ob+62CpfI~;^D4eU7O%6y`SuK% zA)U6n8uo9$^kbUhh*%tn_E!ySlZj~jS~L=!or%O|j9J|{oirlJc*1B;+YrEk?VlQ! G1AhR+Pp*^z diff --git a/lib/db.js b/lib/db.js index 5f574e4..e691c18 100644 --- a/lib/db.js +++ b/lib/db.js @@ -17,36 +17,113 @@ export function getDb() { export function queryOne(sql, params = []) { return new Promise((resolve, reject) => { - getDb().get(sql, params, (err, row) => { + const db = getDb(); + if (!db) { + reject(new Error("Database connection not available")); + return; + } + + db.get(sql, params, function (err, row) { if (err) { + console.error("SQL Error in queryOne():", err.message); + console.error("SQL Statement:", sql); + console.error("Parameters:", JSON.stringify(params)); reject(err); - } else { - resolve(row); + return; } + + resolve(row); }); }); } export function query(sql, params = []) { return new Promise((resolve, reject) => { - getDb().all(sql, params, (err, rows) => { + const db = getDb(); + if (!db) { + reject(new Error("Database connection not available")); + return; + } + + db.all(sql, params, function (err, rows) { if (err) { + console.error("SQL Error in query():", err.message); + console.error("SQL Statement:", sql); + console.error("Parameters:", JSON.stringify(params)); reject(err); - } else { - resolve(rows); + return; } + + resolve(rows || []); }); }); } export function run(sql, params = []) { return new Promise((resolve, reject) => { - getDb().run(sql, params, function (err) { + const db = getDb(); + if (!db) { + const error = new Error("Database connection not available"); + console.error(error); + reject(error); + return; + } + + // Add better logging + if ( + sql.toLowerCase().includes("begin") || + sql.toLowerCase().includes("commit") || + sql.toLowerCase().includes("rollback") + ) { + console.log(`Transaction operation: ${sql}`); + } + + db.run(sql, params, function (err) { if (err) { + console.error("SQL Error in run():", err.message); + console.error("SQL Statement:", sql); + console.error("Parameters:", JSON.stringify(params)); reject(err); - } else { - resolve({ lastID: this.lastID, changes: this.changes }); + return; } + + resolve({ + lastID: this.lastID, + changes: this.changes, + }); }); }); } + +// New function to explicitly enable foreign keys +export function enableForeignKeys() { + return run("PRAGMA foreign_keys = ON;") + .then(() => console.log("Foreign key constraints enabled")) + .catch((err) => console.error("Error enabling foreign keys:", err)); +} + +// New function to run multiple statements as a transaction +export async function runTransaction(statements) { + try { + // Begin transaction + await run("BEGIN TRANSACTION"); + + // Execute all statements in order + for (const [sql, params] of statements) { + await run(sql, params); + } + + // Commit on success + await run("COMMIT"); + return { success: true }; + } catch (error) { + // Rollback on any error + try { + await run("ROLLBACK"); + console.log("Transaction rolled back due to error"); + } catch (rollbackError) { + console.error("Error during rollback:", rollbackError); + } + throw error; + } +} diff --git a/utils/timerService.js b/utils/timerService.js index b30721f..64b2eb9 100644 --- a/utils/timerService.js +++ b/utils/timerService.js @@ -4,26 +4,41 @@ // Store the hunt timer information in localStorage for persistence export const setHuntTimer = (startTime, endTime = null, isActive = true) => { - const timerData = { - startTime, - endTime, - isActive, - lastUpdated: new Date().toISOString(), - }; - localStorage.setItem("huntTimerData", JSON.stringify(timerData)); -}; + if (!startTime) { + console.error("Cannot set timer without a start time"); + return false; + } -// Clear the hunt timer completely -export const clearHuntTimer = () => { - localStorage.removeItem("huntTimerData"); - localStorage.removeItem("teamHuntStartTime"); // Remove legacy timer as well + try { + const timerData = { + startTime, + endTime, + isActive, + lastUpdated: new Date().toISOString(), + }; + + localStorage.setItem("huntTimerData", JSON.stringify(timerData)); + + // Also save to sessionStorage as a fallback + // This can survive page refreshes even if localStorage is cleared + sessionStorage.setItem("huntTimerData", JSON.stringify(timerData)); + return true; + } catch (error) { + console.error("Error setting hunt timer data:", error); + return false; + } }; // Get the current hunt timer data with improved error handling export const getHuntTimer = () => { try { // First try to get timer data from localStorage - const timerData = localStorage.getItem("huntTimerData"); + let timerData = localStorage.getItem("huntTimerData"); + + // If not in localStorage, try sessionStorage as fallback + if (!timerData) { + timerData = sessionStorage.getItem("huntTimerData"); + } if (!timerData) { // Try to migrate from old format if exists @@ -39,9 +54,9 @@ export const getHuntTimer = () => { // Save the migrated data for future use localStorage.setItem("huntTimerData", JSON.stringify(migratedData)); + sessionStorage.setItem("huntTimerData", JSON.stringify(migratedData)); return migratedData; } - console.log("No hunt timer data found"); return null; } @@ -61,6 +76,7 @@ export const getHuntTimer = () => { // Try to recover by clearing corrupt data try { localStorage.removeItem("huntTimerData"); + sessionStorage.removeItem("huntTimerData"); } catch (e) { // Ignore errors when trying to clean up } @@ -69,28 +85,94 @@ export const getHuntTimer = () => { } }; -// Calculate and format elapsed time from a given start time +// Clear the hunt timer completely +export const clearHuntTimer = () => { + try { + localStorage.removeItem("huntTimerData"); + sessionStorage.removeItem("huntTimerData"); + localStorage.removeItem("teamHuntStartTime"); // Remove legacy timer as well + return true; + } catch (error) { + console.error("Error clearing hunt timer data:", error); + return false; + } +}; + +// Calculate and format elapsed time from a given start time with high accuracy export const formatElapsedTime = (startTimeStr) => { if (!startTimeStr) return "00:00:00"; - const startTime = new Date(startTimeStr).getTime(); - const now = Date.now(); - const elapsed = Math.floor((now - startTime) / 1000); + try { + const startTime = new Date(startTimeStr).getTime(); - // Handle negative time (shouldn't happen in normal usage) - if (elapsed < 0) return "00:00:00"; + // Validate that we have a valid timestamp + if (isNaN(startTime)) { + console.error("Invalid start time:", startTimeStr); + return "00:00:00"; + } - const hours = Math.floor(elapsed / 3600) - .toString() - .padStart(2, "0"); - const minutes = Math.floor((elapsed % 3600) / 60) - .toString() - .padStart(2, "0"); - const seconds = Math.floor(elapsed % 60) - .toString() - .padStart(2, "0"); + const now = Date.now(); + const elapsed = Math.floor((now - startTime) / 1000); - return `${hours}:${minutes}:${seconds}`; + // Handle negative time (shouldn't happen in normal usage) + if (elapsed < 0) { + console.warn("Negative elapsed time detected:", elapsed); + return "00:00:00"; + } + + const hours = Math.floor(elapsed / 3600) + .toString() + .padStart(2, "0"); + const minutes = Math.floor((elapsed % 3600) / 60) + .toString() + .padStart(2, "0"); + const seconds = Math.floor(elapsed % 60) + .toString() + .padStart(2, "0"); + + return `${hours}:${minutes}:${seconds}`; + } catch (error) { + console.error("Error formatting elapsed time:", error); + return "00:00:00"; + } +}; + +// Function to fetch current timer state from server and update local storage +export const syncTimerWithServer = async () => { + try { + const response = await fetch("/api/hunt/timer", { + credentials: "include", + headers: { + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + }, + }); + + if (!response.ok) { + console.error("Server responded with status:", response.status); + return { success: false, error: `Server error: ${response.status}` }; + } + + const data = await response.json(); + + if (data.active && data.startTime) { + setHuntTimer(data.startTime, data.endTime); + return { + success: true, + data: { + startTime: data.startTime, + endTime: data.endTime, + active: data.active, + }, + }; + } else { + clearHuntTimer(); + return { success: true, data: null }; + } + } catch (error) { + console.error("Error syncing timer with server:", error); + return { success: false, error: error.message }; + } }; // Hook function to use in components to get formatted elapsed time