bug fixes and added stats viewer to admin leaderboard.

This commit is contained in:
bobbert committed 2025-03-20 19:19:28 +00:00
1 parent 7075a816c3
commit d60f52fef3
7 files changed
+1046 -233

No files matched your search

+395 -181
View File
@@ -29,6 +29,11 @@ export default function AdminCluesPage() {
qr_code: "", // Add QR code field
});
const [isReorderMode, setIsReorderMode] = useState(false);
const [reorderingClue, setReorderingClue] = useState(null);
const [newPosition, setNewPosition] = useState(1);
const [reorderMessage, setReorderMessage] = useState("");
// Check if user is admin
useEffect(() => {
if (status === "authenticated") {
@@ -91,17 +96,21 @@ export default function AdminCluesPage() {
try {
setLoading(true);
// Add timestamp parameter to bust cache
// Make sure to get all clues with a high limit to avoid pagination issues
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",
});
const response = await fetch(
`/api/admin/clues?t=${timestamp}&limit=100`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
},
credentials: "include",
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@@ -117,8 +126,11 @@ 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);
// Sort the clues by ID to ensure correct ordering
const sortedClues = [...data].sort((a, b) => a.id - b.id);
console.log("Fetched clues:", sortedClues);
setClues(sortedClues);
setError("");
// Update lastRefresh timestamp
setLastRefresh(Date.now());
@@ -311,6 +323,138 @@ export default function AdminCluesPage() {
}
};
// Start reordering a clue
const handleReorderStart = (clue) => {
// Disable auto-refresh when starting to reorder
setAutoRefreshEnabled(false);
setReorderingClue(clue);
setNewPosition(clue.id); // Default to current position
setIsReorderMode(true);
};
// Handle position selection change
const handlePositionChange = (e) => {
setNewPosition(parseInt(e.target.value));
};
// Cancel reordering
const handleCancelReorder = () => {
setIsReorderMode(false);
setReorderingClue(null);
// Re-enable auto-refresh
setAutoRefreshEnabled(true);
};
// Submit reorder request
const handleReorderSubmit = async (e) => {
e.preventDefault();
if (!reorderingClue || newPosition < 1 || newPosition > clues.length) {
setError("Invalid position selected");
return;
}
try {
setLoading(true);
setReorderMessage("");
// Store reordering info for success message
const clueTitle = reorderingClue.title;
const targetPosition = newPosition;
const response = await fetch("/api/admin/clues/reorder", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
clueId: reorderingClue.id,
newPosition: newPosition,
}),
credentials: "include",
});
const data = await response.json();
if (!response.ok) {
throw new Error(
data.error || `Failed to reorder clue (Status: ${response.status})`
);
}
// Success message
setReorderMessage(
`Clue "${clueTitle}" moved to position ${targetPosition} successfully`
);
// Reset all state to ensure fresh data
setClues([]);
setIsReorderMode(false);
setReorderingClue(null);
setError("");
// Force a complete refresh with delay to allow database to complete operations
setTimeout(async () => {
try {
await fetchClues();
console.log("Clues refreshed after reordering");
} catch (refreshError) {
console.error("Error refreshing clues after reorder:", refreshError);
setError(
"Reordering succeeded but failed to refresh the list. Please reload the page."
);
}
}, 800);
} catch (err) {
console.error("Error reordering clue:", err);
setError(err.message);
setLoading(false);
}
};
// Add a function to force a hard refresh that bypasses cache
const forceHardRefresh = async () => {
try {
setLoading(true);
setClues([]);
// Clear browser cache for this specific request
const timestamp = Date.now();
const response = await fetch(
`/api/admin/clues?t=${timestamp}&forceRefresh=true`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
credentials: "include",
}
);
if (!response.ok) {
throw new Error(`Failed to refresh clues (Status: ${response.status})`);
}
const data = await response.json();
// Sort the clues by ID to ensure correct ordering
const sortedClues = [...data].sort((a, b) => a.id - b.id);
console.log("Force refreshed clues:", sortedClues);
setClues(sortedClues);
setLastRefresh(Date.now());
setNextRefresh(10);
} catch (err) {
console.error("Error during hard refresh:", err);
setError(`Refresh failed: ${err.message}. Try reloading the page.`);
} finally {
setLoading(false);
}
};
if (status === "loading" || loading) {
return <div className="admin-page loading">Loading...</div>;
}
@@ -350,199 +494,254 @@ export default function AdminCluesPage() {
{error && <p className="error-message">{error}</p>}
{fixMessage && <p className="success-message">{fixMessage}</p>}
{/* Add New Clue Form */}
{!isEditMode ? (
{/* Reordering Modal */}
{isReorderMode ? (
<div className="admin-card">
<h2>Add New Clue</h2>
<form onSubmit={handleAddClue}>
<h2>Change Clue Position</h2>
<form onSubmit={handleReorderSubmit}>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
value={newClue.title}
onChange={handleInputChange}
required
<label htmlFor="clue-position">
Move clue "{reorderingClue?.title}" to new position:
</label>
<select
id="clue-position"
value={newPosition}
onChange={handlePositionChange}
className="admin-input"
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={newClue.description}
onChange={handleInputChange}
required
className="admin-textarea"
></textarea>
</div>
<div className="form-group">
<label htmlFor="location">Location Hint</label>
<input
type="text"
id="location"
name="location"
value={newClue.location}
onChange={handleInputChange}
className="admin-input"
/>
</div>
{/* Add QR code field with new label */}
<div className="form-group">
<label htmlFor="qr_code">QR Answer Value</label>
<input
type="text"
id="qr_code"
name="qr_code"
value={newClue.qr_code}
onChange={handleInputChange}
className="admin-input"
placeholder="QR1, QR2, etc."
/>
>
{clues.map((_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}
</option>
))}
</select>
<div className="help-text">
QR codes should follow the format QR[number] (e.g., QR1, QR2).
Leave blank to auto-generate.
</div>
</div>
<button type="submit" className="admin-button">
Add Clue
</button>
</form>
</div>
) : (
<div className="admin-card">
<h2>Edit Clue</h2>
<form onSubmit={handleUpdateClue}>
<div className="form-group">
<label htmlFor="edit-title">Title</label>
<input
type="text"
id="edit-title"
name="title"
value={editingClue.title}
onChange={handleEditChange}
required
className="admin-input"
/>
</div>
<div className="form-group">
<label htmlFor="edit-description">Description</label>
<textarea
id="edit-description"
name="description"
value={editingClue.description}
onChange={handleEditChange}
required
className="admin-textarea"
></textarea>
</div>
<div className="form-group">
<label htmlFor="edit-location">Location Hint</label>
<input
type="text"
id="edit-location"
name="location"
value={editingClue.location}
onChange={handleEditChange}
className="admin-input"
/>
</div>
<div className="form-group">
<label htmlFor="edit-qrcode">QR Answer Value</label>
<input
type="text"
id="edit-qrcode"
name="qr_code"
value={editingClue.qr_code}
onChange={handleEditChange}
className="admin-input"
/>
<div className="help-text">
QR codes should follow the format QR[number] (e.g., QR1, QR2).
The QR code value determines what users scan to find this
clue.
This will change the sequence of this clue and update all clue
positions accordingly. QR codes will also be reorganized to
maintain the same sequence.
</div>
</div>
<div className="form-actions">
<button type="submit" className="admin-button">
Update Clue
Change Position
</button>
<button
type="button"
className="admin-button cancel-button"
onClick={handleCancelEdit}
onClick={handleCancelReorder}
>
Cancel
</button>
</div>
</form>
</div>
)}
) : (
<>
{/* Add New Clue Form */}
{!isEditMode ? (
<div className="admin-card">
<h2>Add New Clue</h2>
<form onSubmit={handleAddClue}>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
value={newClue.title}
onChange={handleInputChange}
required
className="admin-input"
/>
</div>
{/* Existing Clues List */}
<div className="admin-card">
<h2>Existing Clues</h2>
<p className="info-text">
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].
</p>
<div className="last-updated">
Last updated: {new Date(lastRefresh).toLocaleTimeString()}
{autoRefreshEnabled && !isEditMode && (
<span> (next refresh in {nextRefresh}s)</span>
)}
</div>
{clues.length === 0 ? (
<p className="no-clues">No clues found.</p>
) : (
<div className="clues-list">
{clues.map((clue) => (
<div key={clue.id} className="clue-item">
<div className="clue-header">
<h3>{clue.title}</h3>
<div className="clue-actions">
<button
className="edit-button"
onClick={() => handleEditStart(clue)}
>
Edit
</button>
<button
className="delete-button"
onClick={() => handleDeleteClue(clue.id, clue.qr_code)}
>
Delete
</button>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={newClue.description}
onChange={handleInputChange}
required
className="admin-textarea"
></textarea>
</div>
<div className="form-group">
<label htmlFor="location">Location Hint</label>
<input
type="text"
id="location"
name="location"
value={newClue.location}
onChange={handleInputChange}
className="admin-input"
/>
</div>
{/* Add QR code field with new label */}
<div className="form-group">
<label htmlFor="qr_code">QR Answer Value</label>
<input
type="text"
id="qr_code"
name="qr_code"
value={newClue.qr_code}
onChange={handleInputChange}
className="admin-input"
placeholder="QR1, QR2, etc."
/>
<div className="help-text">
QR codes should follow the format QR[number] (e.g., QR1,
QR2). Leave blank to auto-generate.
</div>
</div>
<p className="clue-description">{clue.description}</p>
{clue.location && (
<p className="clue-location">
<strong>Location:</strong> {clue.location}
</p>
)}
<p className="clue-position">
<strong>Position:</strong> Clue #{clue.id} in sequence
</p>
<p className="clue-qr-code">
<strong>QR Code:</strong> {clue.qr_code || `QR${clue.id}`}
</p>
<button type="submit" className="admin-button">
Add Clue
</button>
</form>
</div>
) : (
<div className="admin-card">
<h2>Edit Clue</h2>
<form onSubmit={handleUpdateClue}>
<div className="form-group">
<label htmlFor="edit-title">Title</label>
<input
type="text"
id="edit-title"
name="title"
value={editingClue.title}
onChange={handleEditChange}
required
className="admin-input"
/>
</div>
<div className="form-group">
<label htmlFor="edit-description">Description</label>
<textarea
id="edit-description"
name="description"
value={editingClue.description}
onChange={handleEditChange}
required
className="admin-textarea"
></textarea>
</div>
<div className="form-group">
<label htmlFor="edit-location">Location Hint</label>
<input
type="text"
id="edit-location"
name="location"
value={editingClue.location}
onChange={handleEditChange}
className="admin-input"
/>
</div>
<div className="form-group">
<label htmlFor="edit-qrcode">QR Answer Value</label>
<input
type="text"
id="edit-qrcode"
name="qr_code"
value={editingClue.qr_code}
onChange={handleEditChange}
className="admin-input"
/>
<div className="help-text">
QR codes should follow the format QR[number] (e.g., QR1,
QR2). The QR code value determines what users scan to find
this clue.
</div>
</div>
<div className="form-actions">
<button type="submit" className="admin-button">
Update Clue
</button>
<button
type="button"
className="admin-button cancel-button"
onClick={handleCancelEdit}
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Existing Clues List */}
<div className="admin-card">
<h2>Existing Clues</h2>
{reorderMessage && (
<p className="success-message">{reorderMessage}</p>
)}
<p className="info-text">
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].
</p>
<div className="last-updated">
Last updated: {new Date(lastRefresh).toLocaleTimeString()}
{autoRefreshEnabled && !isEditMode && (
<span> (next refresh in {nextRefresh}s)</span>
)}
</div>
{clues.length === 0 ? (
<p className="no-clues">No clues found.</p>
) : (
<div className="clues-list">
{clues.map((clue) => (
<div key={clue.id} className="clue-item">
<div className="clue-header">
<h3>{clue.title}</h3>
<div className="clue-actions">
<button
className="reorder-button"
onClick={() => handleReorderStart(clue)}
>
Reorder
</button>
<button
className="edit-button"
onClick={() => handleEditStart(clue)}
>
Edit
</button>
<button
className="delete-button"
onClick={() =>
handleDeleteClue(clue.id, clue.qr_code)
}
>
Delete
</button>
</div>
</div>
<p className="clue-description">{clue.description}</p>
{clue.location && (
<p className="clue-location">
<strong>Location:</strong> {clue.location}
</p>
)}
<p className="clue-qr-code">
<strong>QR Code:</strong>{" "}
{clue.qr_code || `QR${clue.id}`}
</p>
</div>
))}
</div>
))}
)}
</div>
)}
</div>
</>
)}
</div>
{/* Admin Navigation Bar */}
@@ -898,6 +1097,21 @@ export default function AdminCluesPage() {
text-align: right;
font-style: italic;
}
.reorder-button {
background-color: #9c27b0;
color: white;
border: none;
border-radius: 4px;
padding: 0.4rem 0.6rem;
cursor: pointer;
font-size: 0.9rem;
margin-right: 8px;
}
.reorder-button:hover {
background-color: #7b1fa2;
}
`}</style>
</div>
);
+279 -33
View File
@@ -9,6 +9,10 @@ export default function AdminLeaderboardPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const router = useRouter();
// Add state for expanded team details
const [expandedTeam, setExpandedTeam] = useState(null);
const [teamDetails, setTeamDetails] = useState({});
const [loadingDetails, setLoadingDetails] = useState(false);
// Check if user is admin
useEffect(() => {
@@ -100,6 +104,45 @@ export default function AdminLeaderboardPage() {
}
}, [session]);
// New function to toggle team details dropdown and fetch data
const toggleTeamDetails = async (teamId) => {
if (expandedTeam === teamId) {
// Close dropdown if already open
setExpandedTeam(null);
} else {
// Open dropdown for this team
setExpandedTeam(teamId);
// Only fetch if we don't already have data for this team
if (!teamDetails[teamId]) {
setLoadingDetails(true);
try {
const response = await fetch(`/api/admin/teams/${teamId}/stats`, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch team details");
}
const data = await response.json();
setTeamDetails((prev) => ({
...prev,
[teamId]: data,
}));
} catch (err) {
console.error("Error fetching team details:", err);
} finally {
setLoadingDetails(false);
}
}
}
};
if (status === "loading" || loading) {
return <div className="admin-page loading">Loading...</div>;
}
@@ -146,40 +189,152 @@ export default function AdminLeaderboardPage() {
</thead>
<tbody>
{teams.map((team, index) => (
<tr key={team.id}>
<td>{index + 1}</td>
<td>
{team.name} ({team.code})
</td>
<td>{team.memberCount}</td>
<td>
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${team.progress}%` }}
></div>
<span className="progress-text">
{team.cluesFound} / {team.totalClues} (
{team.progress}%)
</span>
</div>
</td>
<td>
{team.lastActivity
? new Date(team.lastActivity).toLocaleString()
: "Never"}
</td>
<td>
<button
className="reset-button"
onClick={() =>
handleResetTeamProgress(team.id, team.name)
}
<>
<tr key={team.id}>
<td>{index + 1}</td>
<td>
{team.name} ({team.code})
</td>
<td>{team.memberCount}</td>
<td>
<div className="progress-container">
<div
className="progress-bar"
style={{ width: `${team.progress}%` }}
></div>
<span className="progress-text">
{team.cluesFound} / {team.totalClues} (
{team.progress}%)
</span>
</div>
</td>
<td>
{team.lastActivity
? new Date(team.lastActivity).toLocaleString()
: "Never"}
</td>
<td>
<div className="action-buttons">
<button
className="reset-button"
onClick={() =>
handleResetTeamProgress(team.id, team.name)
}
>
Reset
</button>
<button
className="details-button"
onClick={() => toggleTeamDetails(team.id)}
>
{expandedTeam === team.id
? "Hide Details"
: "View Details"}
</button>
</div>
</td>
</tr>
{/* Team details dropdown */}
{expandedTeam === team.id && (
<tr
key={`details-${team.id}`}
className="team-details-row"
>
Reset Progress
</button>
</td>
</tr>
<td colSpan={6}>
<div className="team-details-container">
{loadingDetails && !teamDetails[team.id] ? (
<div className="details-loading">
Loading team details...
</div>
) : teamDetails[team.id] ? (
<>
<h3>Team Stats for {team.name}</h3>
{/* Found clues section */}
<div className="details-section">
<h4>
Found Clues (
{teamDetails[team.id].foundClues.length})
</h4>
{teamDetails[team.id].foundClues.length >
0 ? (
<table className="details-table">
<thead>
<tr>
<th>Title</th>
<th>Found At</th>
</tr>
</thead>
<tbody>
{teamDetails[team.id].foundClues.map(
(clue) => (
<tr key={`found-${clue.id}`}>
<td>{clue.title}</td>
<td>
{new Date(
clue.found_at
).toLocaleString()}
</td>
</tr>
)
)}
</tbody>
</table>
) : (
<p className="no-data-message">
No clues found yet
</p>
)}
</div>
{/* Skipped clues section */}
<div className="details-section">
<h4>
Skipped Clues (
{teamDetails[team.id].skippedClues.length}
)
</h4>
{teamDetails[team.id].skippedClues.length >
0 ? (
<table className="details-table">
<thead>
<tr>
<th>Title</th>
<th>Skipped At</th>
</tr>
</thead>
<tbody>
{teamDetails[
team.id
].skippedClues.map((clue) => (
<tr key={`skipped-${clue.id}`}>
<td>{clue.title}</td>
<td>
{new Date(
clue.found_at
).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="no-data-message">
No clues skipped
</p>
)}
</div>
</>
) : (
<div className="details-error">
Error loading team details
</div>
)}
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
@@ -401,6 +556,97 @@ export default function AdminLeaderboardPage() {
font-size: 0.8rem;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.7);
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.details-button {
background-color: #3f51b5;
color: white;
border: none;
border-radius: 4px;
padding: 0.4rem 0.6rem;
cursor: pointer;
font-size: 0.9rem;
}
.details-button:hover {
background-color: #303f9f;
}
.team-details-row {
background-color: #f9f9f9;
}
.team-details-container {
padding: 1.5rem;
margin: 0.5rem 0;
border: 1px solid #e0e0e0;
border-radius: 4px;
background-color: white;
}
.team-details-container h3 {
color: #3f51b5;
margin-top: 0;
margin-bottom: 1rem;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 0.5rem;
}
.details-section {
margin-bottom: 1.5rem;
}
.details-section h4 {
color: #68543c;
margin-bottom: 0.75rem;
}
.details-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.details-table th {
background-color: #f5f5f5;
font-weight: 600;
text-align: left;
padding: 0.6rem;
border-bottom: 1px solid #ddd;
}
.details-table td {
padding: 0.6rem;
border-bottom: 1px solid #eee;
}
.details-table tr:nth-child(even) {
background-color: #fafafa;
}
.details-loading {
padding: 1rem;
text-align: center;
color: #666;
}
.details-error {
padding: 1rem;
text-align: center;
color: #d32f2f;
background-color: #ffebee;
border-radius: 4px;
}
.no-data-message {
font-style: italic;
color: #666;
}
`}</style>
</div>
);
+34 -17
View File
@@ -354,32 +354,49 @@ export default function CluePage({ params }) {
}
try {
// Handle multiple QR code formats: "QR123", "123", or even "Clue 123"
let scannedClueId;
// Try regex patterns
if (!data || !clue) return;
// Normalize the scanned QR code value
const normalizedInput = data.trim().toUpperCase();
// Try to use the QR code field, but fall back to a generated one if it's missing or empty
let expectedQrCode = (clue.qr_code || "").trim().toUpperCase();
// If QR code is missing or empty, generate a fallback based on the clue ID
if (!expectedQrCode) {
expectedQrCode = `QR${clue.id}`.toUpperCase();
}
// First try a direct match with the QR code
if (normalizedInput === expectedQrCode) {
// Exact match with the clue's QR code
setScanMessage("✓ Correct QR code found!");
markClueAsFound(clueId);
return;
}
// If not a direct match, try to extract the numeric value
const qrPattern = /^QR[^0-9]*(\d+)$/i;
const cluePattern = /^clue[^0-9]*(\d+)$/i;
const numberPattern = /^(\d+)$/;
let match =
data.match(qrPattern) ||
data.match(cluePattern) ||
data.match(numberPattern);
// Extract the number from the scanned input
const inputMatch =
normalizedInput.match(qrPattern) ||
normalizedInput.match(cluePattern) ||
normalizedInput.match(numberPattern);
if (match) {
scannedClueId = parseInt(match[1], 10);
} else {
// Try direct parsing as a last resort
scannedClueId = parseInt(data, 10);
}
// Extract the number from the expected QR code
const expectedMatch = expectedQrCode.match(qrPattern);
if (!isNaN(scannedClueId) && scannedClueId === parseInt(clueId)) {
// Correct QR code scanned, mark as found
if (inputMatch && expectedMatch && inputMatch[1] === expectedMatch[1]) {
// The numeric parts match
setScanMessage("✓ Correct QR code found!");
markClueAsFound(clueId);
} else if (!isNaN(scannedClueId)) {
} else if (inputMatch) {
// Found a number in the input, but it doesn't match
setScanMessage(
`QR code for clue ${scannedClueId} scanned, but you need clue ${clueId}. Please scan the correct code.`
`QR code "${data}" scanned, but you need to scan "${expectedQrCode}". Please scan the correct code.`
);
} else {
setScanMessage(
+238
View File
@@ -0,0 +1,238 @@
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 };
}
// POST - Reorder a clue to a new position
export async function POST(request) {
try {
// Verify admin access
const { authorized, error, status } = await verifyAdmin(request);
if (!authorized) {
return Response.json({ error }, { status });
}
// Parse the request body
const { clueId, newPosition } = await request.json();
// Validate required parameters
if (!clueId || !newPosition) {
return Response.json(
{ error: "Clue ID and new position are required" },
{ status: 400 }
);
}
// Validate parameters are numbers
if (isNaN(parseInt(clueId)) || isNaN(parseInt(newPosition))) {
return Response.json(
{ error: "Clue ID and new position must be numbers" },
{ status: 400 }
);
}
// Get all clues to validate new position is in range
const allClues = await query(
"SELECT id, qr_code FROM clues ORDER BY id ASC"
);
console.log("All clues before reordering:", allClues);
if (parseInt(newPosition) < 1 || parseInt(newPosition) > allClues.length) {
return Response.json(
{ error: `New position must be between 1 and ${allClues.length}` },
{ status: 400 }
);
}
// Check if clue exists
const clue = await queryOne("SELECT id, title FROM clues WHERE id = ?", [
clueId,
]);
if (!clue) {
return Response.json({ error: "Clue not found" }, { status: 404 });
}
// Start a transaction for the reordering operation
await run("BEGIN TRANSACTION");
try {
// Get current position (1-based index)
const currentPosition =
allClues.findIndex((c) => c.id === parseInt(clueId)) + 1;
console.log(
`Reordering clue ${clueId} from position ${currentPosition} to ${newPosition}`
);
if (currentPosition === parseInt(newPosition)) {
// No change needed, clue is already in the desired position
await run("ROLLBACK");
return Response.json({
success: true,
message: "Clue is already in the requested position",
});
}
// Create a temporary table with all clue data
await run(`
CREATE TEMPORARY TABLE temp_clues AS
SELECT id, title, description, location, qr_code, created_at
FROM clues
ORDER BY id ASC
`);
// Create a new array with the reordered clues
const reorderedClues = [...allClues];
const [clueToMove] = reorderedClues.splice(currentPosition - 1, 1);
reorderedClues.splice(parseInt(newPosition) - 1, 0, clueToMove);
console.log(
"Reordered clues array:",
reorderedClues.map((c) => c.id)
);
// Create ID mapping before deleting anything
const idMapping = {};
for (let i = 0; i < reorderedClues.length; i++) {
idMapping[reorderedClues[i].id] = i + 1; // New sequential IDs
}
console.log("ID Mapping:", idMapping);
// Get all team_clues entries before deleting
const teamClues = await query(
"SELECT team_id, clue_id, found_at, skipped FROM team_clues"
);
console.log(`Found ${teamClues.length} team_clues records to update`);
// Reset SQLite sequence before deletion to avoid autoincrement issues
await run("DELETE FROM sqlite_sequence WHERE name='clues'");
// Clear the clues table
await run("DELETE FROM clues");
console.log("Deleted all clues for reordering");
// Insert the clues back in their new order with new IDs
for (let i = 0; i < reorderedClues.length; i++) {
const originalClue = reorderedClues[i];
// Get the full clue data from the temp table
const clueData = await queryOne(
`
SELECT title, description, location, created_at FROM temp_clues WHERE id = ?
`,
[originalClue.id]
);
if (!clueData) {
throw new Error(
`Failed to retrieve data for clue ID ${originalClue.id} from temp table`
);
}
// Determine the new QR code based on new position
const newQrCode = `QR${i + 1}`;
const newId = i + 1;
// Insert with the new ID (position)
const insertResult = await run(
`
INSERT INTO clues (id, title, description, location, qr_code, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`,
[
newId,
clueData.title,
clueData.description,
clueData.location || "",
newQrCode,
clueData.created_at,
]
);
console.log(
`Inserted clue with new ID ${newId}, was previously ${originalClue.id}, result:`,
insertResult
);
}
// Delete all team_clues entries
await run("DELETE FROM team_clues");
console.log("Deleted all team_clues records for updating");
// Reinsert with updated clue_id values
let updatedCount = 0;
for (const tc of teamClues) {
if (idMapping[tc.clue_id]) {
await run(
`
INSERT INTO team_clues (team_id, clue_id, found_at, skipped)
VALUES (?, ?, ?, ?)
`,
[tc.team_id, idMapping[tc.clue_id], tc.found_at, tc.skipped]
);
updatedCount++;
}
}
console.log(
`Reinserted ${updatedCount} team_clues records with updated IDs`
);
// Drop the temporary table
await run("DROP TABLE temp_clues");
// Update SQLite sequence to match our new highest ID
await run(`INSERT INTO sqlite_sequence (name, seq) VALUES ('clues', ${reorderedClues.length})
ON CONFLICT(name) DO UPDATE SET seq = ${reorderedClues.length}`);
// Force SQLite to update all internal references
await run("VACUUM");
// Commit transaction
await run("COMMIT");
console.log("Transaction committed successfully");
// Verify the changes
const verifyClues = await query(
"SELECT id, title FROM clues ORDER BY id ASC"
);
console.log("Clues after reordering:", verifyClues);
return Response.json({
success: true,
message: `Clue successfully moved from position ${currentPosition} to position ${newPosition}`,
});
} catch (error) {
// Rollback transaction on error
await run("ROLLBACK");
console.error("Error during reordering transaction:", error);
throw error;
}
} catch (error) {
console.error("Error reordering clue:", error);
return Response.json(
{ error: "Failed to reorder clue: " + error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,98 @@
import { getToken } from "next-auth/jwt";
import { queryOne, 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 };
}
// GET - Fetch detailed stats for a specific team
export async function GET(request, context) {
try {
// Verify admin access
const { authorized, error, status } = await verifyAdmin(request);
if (!authorized) {
return Response.json({ error }, { status });
}
// Get teamId parameter
const { teamId } = await context.params;
// Validate teamId is a number
if (isNaN(parseInt(teamId))) {
return Response.json({ error: "Invalid team ID" }, { status: 400 });
}
// Check if team exists
const team = await queryOne("SELECT id, name FROM teams WHERE id = ?", [
teamId,
]);
if (!team) {
return Response.json({ error: "Team not found" }, { status: 404 });
}
// Get all found clues (not skipped)
const foundClues = await query(
`
SELECT c.id, c.title, tc.found_at
FROM team_clues tc
JOIN clues c ON tc.clue_id = c.id
WHERE tc.team_id = ? AND (tc.skipped = 0 OR tc.skipped IS NULL)
ORDER BY tc.found_at ASC
`,
[teamId]
);
// Get all skipped clues
const skippedClues = await query(
`
SELECT c.id, c.title, tc.found_at
FROM team_clues tc
JOIN clues c ON tc.clue_id = c.id
WHERE tc.team_id = ? AND tc.skipped = 1
ORDER BY tc.found_at ASC
`,
[teamId]
);
// Return the data
return Response.json(
{
teamId: parseInt(teamId),
teamName: team.name,
foundClues,
skippedClues,
},
{
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
}
);
} catch (error) {
console.error("Error fetching team stats:", error);
return Response.json(
{ error: "Failed to fetch team stats: " + error.message },
{ status: 500 }
);
}
}
+2 -2
View File
@@ -33,9 +33,9 @@ export async function GET(request, context) {
return Response.json({ error: "Invalid clue ID" }, { status: 400 });
}
// Get the specific clue
// Get the specific clue - UPDATED to include qr_code field
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]
);
BIN
View File
Binary file not shown.