WIP: Add points system for movies and picks, admin configuration, and stats page updates
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
const express = require("express");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const multer = require("multer");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { PDFParse } = require("pdf-parse");
|
||||
const OscarCategory = require("../models/OscarCategory");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for PDF uploads
|
||||
const upload = multer({
|
||||
dest: path.join(__dirname, '../uploads/'),
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PDF files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to verify token
|
||||
function verifyToken(req, res, next) {
|
||||
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
|
||||
? req.headers['authorization'].split(' ')[1]
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
return res.status(403).json({ message: "No token provided, access denied." });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
|
||||
if (err) {
|
||||
return res.status(401).json({ message: "Invalid or expired token" });
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function isAdmin(decoded) {
|
||||
return !!decoded?.admin;
|
||||
}
|
||||
|
||||
function parseOscarYear(raw) {
|
||||
if (raw === undefined || raw === null || raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n)) return null;
|
||||
if (n < 1900 || n > 3000) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Helper function to clean nominee name
|
||||
function cleanNomineeName(line) {
|
||||
return line.replace(/^[-☐•]\s*/g, '').replace(/^-\s*☐\s*/, '').trim();
|
||||
}
|
||||
|
||||
// Helper function to check if a line is a valid nominee
|
||||
function isValidNomineeLine(line, skipPatterns) {
|
||||
// Skip if it looks like a category header
|
||||
if (line.match(/^(.+?)[\t\s]+\d+$/) || line.match(/^\d+\./)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip header/metadata lines
|
||||
if (skipPatterns.test(line)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nomineeName = cleanNomineeName(line);
|
||||
if (!nomineeName || nomineeName.length === 0 || nomineeName.length >= 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const upperLine = nomineeName.toUpperCase();
|
||||
// Skip if it looks like a header (but allow category names that might appear in nominees with commas)
|
||||
const isHeader = upperLine.match(/^(YOUR|SCORE|NAME|COVERS|OSCARS|PARTY|PROPS|SHEET)$/) ||
|
||||
(upperLine.match(/^(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)$/) &&
|
||||
!nomineeName.includes(',') && nomineeName.length < 30);
|
||||
|
||||
return !isHeader;
|
||||
}
|
||||
|
||||
// Parse PDF text and extract categories/nominees
|
||||
async function parsePDFText(pdfText) {
|
||||
const categories = [];
|
||||
const lines = pdfText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
|
||||
// First pass: identify all category headers and their positions
|
||||
const categoryHeaders = [];
|
||||
const skipPatterns = /^(COVERS|OSCARS|PARTY|PROPS|SHEET|YOUR|NAME|SCORE|____|Your Name|Your Score)/i;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip header/metadata lines
|
||||
if (skipPatterns.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match category in format: "Category Name\tNumber" or "Category Name Number"
|
||||
const categoryMatch = line.match(/^(.+?)[\t\s]+(\d+)$/);
|
||||
if (categoryMatch) {
|
||||
const categoryName = categoryMatch[1].trim().replace(/\*\*/g, '').trim();
|
||||
const categoryNumber = parseInt(categoryMatch[2]);
|
||||
|
||||
// Skip invalid entries
|
||||
if (categoryName.length < 3 || categoryNumber < 1 || categoryNumber > 50) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out false positives - must contain common Oscar category keywords
|
||||
const upperName = categoryName.toUpperCase();
|
||||
const isOscarCategory = upperName.includes('BEST') ||
|
||||
upperName.includes('DIRECTOR') ||
|
||||
upperName.includes('ACTOR') ||
|
||||
upperName.includes('ACTRESS') ||
|
||||
upperName.includes('PICTURE') ||
|
||||
upperName.includes('SCREENPLAY') ||
|
||||
upperName.includes('EDITING') ||
|
||||
upperName.includes('CINEMATOGRAPHY') ||
|
||||
upperName.includes('COSTUME') ||
|
||||
upperName.includes('SOUND') ||
|
||||
upperName.includes('VISUAL') ||
|
||||
upperName.includes('EFFECTS') ||
|
||||
upperName.includes('ORIGINAL') ||
|
||||
upperName.includes('ADAPTED') ||
|
||||
upperName.includes('DOCUMENTARY') ||
|
||||
upperName.includes('ANIMATED') ||
|
||||
upperName.includes('INTERNATIONAL') ||
|
||||
upperName.includes('PRODUCTION') ||
|
||||
upperName.includes('MAKEUP') ||
|
||||
upperName.includes('HAIRSTYLING') ||
|
||||
upperName.includes('LIVE-ACTION') ||
|
||||
upperName.includes('SHORT');
|
||||
|
||||
// Also allow "Makeup and Hairstyling" format
|
||||
if (!isOscarCategory && !upperName.match(/^(MAKEUP|PRODUCTION|SOUND)$/)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
categoryHeaders.push({
|
||||
index: i,
|
||||
categoryNumber: categoryNumber,
|
||||
categoryName: categoryName,
|
||||
rawText: line
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also try format: "Number. Category Name" (fallback)
|
||||
const altCategoryMatch = line.match(/^(\d+)\.\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*-)?$/);
|
||||
if (altCategoryMatch) {
|
||||
const categoryNumber = parseInt(altCategoryMatch[1]);
|
||||
let categoryName = altCategoryMatch[2].trim().replace(/\*\*/g, '').replace(/^-\s*/, '').trim();
|
||||
|
||||
if (categoryName.length < 3 || categoryNumber < 1 || categoryNumber > 50) {
|
||||
continue;
|
||||
}
|
||||
|
||||
categoryHeaders.push({
|
||||
index: i,
|
||||
categoryNumber: categoryNumber,
|
||||
categoryName: categoryName,
|
||||
rawText: line
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort category headers by their position in the document
|
||||
categoryHeaders.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Second pass: collect nominees for each category
|
||||
// In the PDF, nominees can appear BEFORE their category header
|
||||
for (let h = 0; h < categoryHeaders.length; h++) {
|
||||
const header = categoryHeaders[h];
|
||||
const prevHeaderIndex = h > 0 ? categoryHeaders[h - 1].index : -1;
|
||||
const nextHeaderIndex = h < categoryHeaders.length - 1 ? categoryHeaders[h + 1].index : lines.length;
|
||||
|
||||
const nominees = [];
|
||||
|
||||
// Collect nominees that appear AFTER this category header (until next category)
|
||||
// Focus on nominees after the header for better accuracy
|
||||
for (let i = header.index + 1; i < nextHeaderIndex; i++) {
|
||||
const line = lines[i];
|
||||
if (isValidNomineeLine(line, skipPatterns)) {
|
||||
const nomineeName = cleanNomineeName(line);
|
||||
// Skip obviously wrong entries like "- 1 of 1 --"
|
||||
if (nomineeName && !nomineeName.match(/^-?\s*\d+\s*of\s*\d+\s*-?$/)) {
|
||||
nominees.push({ name: nomineeName, isWinner: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueNominees = [];
|
||||
const seen = new Set();
|
||||
for (const nom of nominees) {
|
||||
const key = nom.name.toLowerCase().trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueNominees.push(nom);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add category if it has nominees
|
||||
if (uniqueNominees.length > 0) {
|
||||
categories.push({
|
||||
categoryNumber: header.categoryNumber,
|
||||
categoryName: header.categoryName,
|
||||
nominees: uniqueNominees,
|
||||
rawText: header.rawText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort categories by category number
|
||||
categories.sort((a, b) => a.categoryNumber - b.categoryNumber);
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
// Import categories from PDF (admin only)
|
||||
router.post("/import-pdf", verifyToken, upload.single('pdf'), async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: "No PDF file uploaded" });
|
||||
}
|
||||
|
||||
const year = parseOscarYear(req.body.year) || new Date().getFullYear();
|
||||
const pdfPath = req.file.path;
|
||||
|
||||
// Parse PDF
|
||||
let pdfText;
|
||||
try {
|
||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
||||
const parser = new PDFParse({ data: pdfBuffer });
|
||||
const textResult = await parser.getText();
|
||||
pdfText = textResult.text;
|
||||
|
||||
// Cleanup parser
|
||||
await parser.destroy();
|
||||
} catch (err) {
|
||||
// Cleanup file
|
||||
fs.unlinkSync(pdfPath);
|
||||
return res.status(400).json({ message: `Error parsing PDF: ${err.message}` });
|
||||
}
|
||||
|
||||
// Cleanup uploaded file
|
||||
fs.unlinkSync(pdfPath);
|
||||
|
||||
// Parse categories from text
|
||||
const parsedCategories = await parsePDFText(pdfText);
|
||||
|
||||
if (parsedCategories.length === 0) {
|
||||
return res.status(400).json({ message: "No categories found in PDF. Please check the format." });
|
||||
}
|
||||
|
||||
// Save categories to database
|
||||
const savedCategories = [];
|
||||
for (const cat of parsedCategories) {
|
||||
const category = await OscarCategory.findOneAndUpdate(
|
||||
{ year, categoryNumber: cat.categoryNumber },
|
||||
{
|
||||
year,
|
||||
categoryNumber: cat.categoryNumber,
|
||||
categoryName: cat.categoryName,
|
||||
nominees: cat.nominees,
|
||||
rawText: cat.rawText
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
savedCategories.push(category);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Successfully imported ${savedCategories.length} categories`,
|
||||
year,
|
||||
categories: savedCategories
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error importing PDF:", err);
|
||||
// Cleanup file if it exists
|
||||
if (req.file && req.file.path) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (_) {}
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually create/update categories (admin only)
|
||||
router.post("/create", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { year, categoryNumber, categoryName, nominees } = req.body;
|
||||
const yearNum = parseOscarYear(year) || new Date().getFullYear();
|
||||
|
||||
if (!categoryNumber || !categoryName || !Array.isArray(nominees) || nominees.length === 0) {
|
||||
return res.status(400).json({ message: "categoryNumber, categoryName, and nominees array are required" });
|
||||
}
|
||||
|
||||
const nomineesArray = nominees.map(n => ({
|
||||
name: typeof n === 'string' ? n : n.name,
|
||||
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
|
||||
}));
|
||||
|
||||
const category = await OscarCategory.findOneAndUpdate(
|
||||
{ year: yearNum, categoryNumber },
|
||||
{
|
||||
year: yearNum,
|
||||
categoryNumber,
|
||||
categoryName,
|
||||
nominees: nomineesArray
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
res.json({ message: "Category saved successfully", category });
|
||||
} catch (err) {
|
||||
console.error("Error creating category:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all categories for a year (admin view with management)
|
||||
router.get("/list", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const year = parseOscarYear(req.query.year) || new Date().getFullYear();
|
||||
const categories = await OscarCategory.find({ year })
|
||||
.sort({ categoryNumber: 1 })
|
||||
.lean();
|
||||
|
||||
res.json({ year, categories });
|
||||
} catch (err) {
|
||||
console.error("Error fetching categories:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a category (admin only)
|
||||
router.put("/:categoryId", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { categoryName, nominees } = req.body;
|
||||
|
||||
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
|
||||
return res.status(400).json({ message: "categoryName and nominees array are required" });
|
||||
}
|
||||
|
||||
const nomineesArray = nominees.map(n => ({
|
||||
name: typeof n === 'string' ? n : n.name,
|
||||
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
|
||||
}));
|
||||
|
||||
const category = await OscarCategory.findByIdAndUpdate(
|
||||
req.params.categoryId,
|
||||
{
|
||||
categoryName,
|
||||
nominees: nomineesArray
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ message: "Category not found" });
|
||||
}
|
||||
|
||||
res.json({ message: "Category updated successfully", category });
|
||||
} catch (err) {
|
||||
console.error("Error updating category:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark winner for a category (admin only)
|
||||
router.put("/:categoryId/winner", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { winnerName } = req.body;
|
||||
if (!winnerName) {
|
||||
return res.status(400).json({ message: "winnerName is required" });
|
||||
}
|
||||
|
||||
const category = await OscarCategory.findById(req.params.categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ message: "Category not found" });
|
||||
}
|
||||
|
||||
// Mark all nominees as not winners, then mark the selected one as winner
|
||||
category.nominees = category.nominees.map(n => ({
|
||||
...n.toObject(),
|
||||
isWinner: n.name === winnerName
|
||||
}));
|
||||
|
||||
await category.save();
|
||||
|
||||
res.json({ message: "Winner marked successfully", category });
|
||||
} catch (err) {
|
||||
console.error("Error marking winner:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a category (admin only)
|
||||
router.delete("/:categoryId", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const category = await OscarCategory.findByIdAndDelete(req.params.categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ message: "Category not found" });
|
||||
}
|
||||
|
||||
res.json({ message: "Category deleted successfully" });
|
||||
} catch (err) {
|
||||
console.error("Error deleting category:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadsDir = path.join(__dirname, '../uploads/');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
const express = require("express");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const OscarCategory = require("../models/OscarCategory");
|
||||
const OscarPick = require("../models/OscarPick");
|
||||
const User = require("../models/User");
|
||||
const Setting = require("../models/Setting");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
||||
|
||||
async function getOrInitActiveYear() {
|
||||
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
|
||||
if (existing && typeof existing.value === "number") return existing.value;
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
|
||||
// Middleware to verify token
|
||||
function verifyToken(req, res, next) {
|
||||
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
|
||||
? req.headers['authorization'].split(' ')[1]
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
return res.status(403).json({ message: "No token provided, access denied." });
|
||||
}
|
||||
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
|
||||
if (err) {
|
||||
return res.status(401).json({ message: "Invalid or expired token" });
|
||||
}
|
||||
req.user = decoded;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function isAdmin(decoded) {
|
||||
return !!decoded?.admin;
|
||||
}
|
||||
|
||||
function parseOscarYear(raw) {
|
||||
if (raw === undefined || raw === null || raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n)) return null;
|
||||
if (n < 1900 || n > 3000) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Get all categories for a year (for users to see what to pick)
|
||||
router.get("/categories", verifyToken, async (req, res) => {
|
||||
try {
|
||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||
const categories = await OscarCategory.find({ year })
|
||||
.sort({ categoryNumber: 1 })
|
||||
.lean();
|
||||
|
||||
res.json({ year, categories });
|
||||
} catch (err) {
|
||||
console.error("Error fetching categories:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Submit picks (user)
|
||||
router.post("/submit", verifyToken, async (req, res) => {
|
||||
try {
|
||||
const { year, picks } = req.body;
|
||||
const userId = req.user.id;
|
||||
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
|
||||
|
||||
if (!Array.isArray(picks) || picks.length === 0) {
|
||||
return res.status(400).json({ message: "Picks array is required" });
|
||||
}
|
||||
|
||||
// Verify categories exist for this year
|
||||
const categories = await OscarCategory.find({ year: yearNum });
|
||||
if (categories.length === 0) {
|
||||
return res.status(400).json({ message: "No categories found for this year. Please import categories first." });
|
||||
}
|
||||
|
||||
// Validate picks
|
||||
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||
for (const pick of picks) {
|
||||
if (!pick.categoryNumber || !pick.selectedNominee) {
|
||||
return res.status(400).json({ message: "Each pick must have categoryNumber and selectedNominee" });
|
||||
}
|
||||
const category = categoryMap.get(pick.categoryNumber);
|
||||
if (!category) {
|
||||
return res.status(400).json({ message: `Category ${pick.categoryNumber} not found` });
|
||||
}
|
||||
const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee);
|
||||
if (!nomineeExists) {
|
||||
return res.status(400).json({ message: `Nominee "${pick.selectedNominee}" not found in category ${pick.categoryNumber}` });
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update picks
|
||||
const pickData = {
|
||||
userId,
|
||||
year: yearNum,
|
||||
picks: picks.map(p => ({
|
||||
categoryNumber: p.categoryNumber,
|
||||
categoryName: categoryMap.get(p.categoryNumber).categoryName,
|
||||
selectedNominee: p.selectedNominee
|
||||
}))
|
||||
};
|
||||
|
||||
const existingPick = await OscarPick.findOne({ userId, year: yearNum });
|
||||
if (existingPick) {
|
||||
existingPick.picks = pickData.picks;
|
||||
existingPick.submittedAt = new Date();
|
||||
await existingPick.save();
|
||||
return res.json({ message: "Picks updated successfully", pick: existingPick });
|
||||
} else {
|
||||
const newPick = new OscarPick(pickData);
|
||||
await newPick.save();
|
||||
return res.json({ message: "Picks submitted successfully", pick: newPick });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error submitting picks:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's own picks
|
||||
router.get("/my-picks", verifyToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||
|
||||
const pick = await OscarPick.findOne({ userId, year })
|
||||
.populate('userId', 'name email')
|
||||
.lean();
|
||||
|
||||
if (!pick) {
|
||||
return res.json({ pick: null, year });
|
||||
}
|
||||
|
||||
res.json({ pick, year });
|
||||
} catch (err) {
|
||||
console.error("Error fetching user picks:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all picks (admin only) - for viewing all users' picks
|
||||
router.get("/all", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||
|
||||
const picks = await OscarPick.find({ year })
|
||||
.populate('userId', 'name email')
|
||||
.sort({ submittedAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Also get categories to show nominee options
|
||||
const categories = await OscarCategory.find({ year })
|
||||
.sort({ categoryNumber: 1 })
|
||||
.lean();
|
||||
|
||||
res.json({ year, picks, categories });
|
||||
} catch (err) {
|
||||
console.error("Error fetching all picks:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate scores for all users (admin only)
|
||||
router.post("/calculate-scores", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { year } = req.body;
|
||||
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
|
||||
|
||||
// Get all categories with winners marked
|
||||
const categories = await OscarCategory.find({ year: yearNum }).lean();
|
||||
const categoryWinners = new Map();
|
||||
|
||||
categories.forEach(cat => {
|
||||
const winner = cat.nominees.find(n => n.isWinner);
|
||||
if (winner) {
|
||||
categoryWinners.set(cat.categoryNumber, winner.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (categoryWinners.size === 0) {
|
||||
return res.status(400).json({ message: "No winners marked for any category. Please mark winners first." });
|
||||
}
|
||||
|
||||
// Get all picks for this year with user info
|
||||
const allPicks = await OscarPick.find({ year: yearNum })
|
||||
.populate('userId', 'name email')
|
||||
.lean();
|
||||
|
||||
// Calculate scores
|
||||
const scores = [];
|
||||
for (const pick of allPicks) {
|
||||
let correctCount = 0;
|
||||
const pickDetails = [];
|
||||
|
||||
pick.picks.forEach(p => {
|
||||
const correctWinner = categoryWinners.get(p.categoryNumber);
|
||||
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
|
||||
if (isCorrect) correctCount++;
|
||||
|
||||
pickDetails.push({
|
||||
categoryNumber: p.categoryNumber,
|
||||
categoryName: p.categoryName,
|
||||
selectedNominee: p.selectedNominee,
|
||||
correctWinner: correctWinner || null,
|
||||
isCorrect
|
||||
});
|
||||
});
|
||||
|
||||
// Update the pick with score
|
||||
await OscarPick.findByIdAndUpdate(pick._id, { score: correctCount });
|
||||
|
||||
scores.push({
|
||||
userId: pick.userId?._id || pick.userId,
|
||||
userName: pick.userId?.name || 'Unknown',
|
||||
userEmail: pick.userId?.email || '',
|
||||
score: correctCount,
|
||||
totalCategories: categories.length,
|
||||
pickDetails
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
scores.sort((a, b) => b.score - a.score);
|
||||
|
||||
res.json({
|
||||
message: "Scores calculated successfully",
|
||||
year: yearNum,
|
||||
scores,
|
||||
totalCategories: categories.length,
|
||||
categoriesWithWinners: categoryWinners.size
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calculating scores:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get scores for all users (admin only)
|
||||
router.get("/scores", verifyToken, async (req, res) => {
|
||||
try {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||
|
||||
const picks = await OscarPick.find({ year })
|
||||
.populate('userId', 'name email')
|
||||
.sort({ score: -1, submittedAt: 1 })
|
||||
.lean();
|
||||
|
||||
const categories = await OscarCategory.find({ year }).lean();
|
||||
const categoryWinners = new Map();
|
||||
categories.forEach(cat => {
|
||||
const winner = cat.nominees.find(n => n.isWinner);
|
||||
if (winner) {
|
||||
categoryWinners.set(cat.categoryNumber, winner.name);
|
||||
}
|
||||
});
|
||||
|
||||
const scores = picks.map(pick => {
|
||||
let correctCount = 0;
|
||||
const pickDetails = [];
|
||||
|
||||
// Create a map of all categories for reference
|
||||
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||
|
||||
pick.picks.forEach(p => {
|
||||
const correctWinner = categoryWinners.get(p.categoryNumber);
|
||||
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
|
||||
if (isCorrect) correctCount++;
|
||||
|
||||
const category = categoryMap.get(p.categoryNumber);
|
||||
const allNominees = category ? category.nominees.map(n => n.name) : [];
|
||||
|
||||
pickDetails.push({
|
||||
categoryNumber: p.categoryNumber,
|
||||
categoryName: p.categoryName,
|
||||
selectedNominee: p.selectedNominee,
|
||||
correctWinner: correctWinner || null,
|
||||
isCorrect,
|
||||
allNominees: allNominees
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
userId: pick.userId?._id || pick.userId,
|
||||
userName: pick.userId?.name || 'Unknown',
|
||||
userEmail: pick.userId?.email || '',
|
||||
score: pick.score !== null && pick.score !== undefined ? pick.score : correctCount,
|
||||
totalCategories: categories.length,
|
||||
pickDetails,
|
||||
submittedAt: pick.submittedAt
|
||||
};
|
||||
});
|
||||
|
||||
scores.sort((a, b) => b.score - a.score);
|
||||
|
||||
res.json({ year, scores, totalCategories: categories.length, categoriesWithWinners: categoryWinners.size });
|
||||
} catch (err) {
|
||||
console.error("Error fetching scores:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -14,6 +14,7 @@ const WINNERS_BY_YEAR_KEY = "winners_by_year";
|
||||
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
|
||||
const APP_VERSION_KEY = "app_version_control";
|
||||
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
|
||||
const POINTS_CONFIG_KEY = "points_config";
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
@@ -30,6 +31,11 @@ const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
|
||||
showProgress: true,
|
||||
});
|
||||
|
||||
const DEFAULT_POINTS_CONFIG = Object.freeze({
|
||||
pointsPerMovie: 1,
|
||||
pointsPerCorrectPick: 1,
|
||||
});
|
||||
|
||||
function parseOscarYear(raw) {
|
||||
if (raw === undefined || raw === null || raw === "") return null;
|
||||
const n = Number(raw);
|
||||
@@ -749,5 +755,50 @@ router.delete("/winners/:year/:userId", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function normalizePointsConfig(rawValue) {
|
||||
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const pointsPerMovie = typeof v.pointsPerMovie === "number" && v.pointsPerMovie >= 0 ? Math.round(v.pointsPerMovie) : DEFAULT_POINTS_CONFIG.pointsPerMovie;
|
||||
const pointsPerCorrectPick = typeof v.pointsPerCorrectPick === "number" && v.pointsPerCorrectPick >= 0 ? Math.round(v.pointsPerCorrectPick) : DEFAULT_POINTS_CONFIG.pointsPerCorrectPick;
|
||||
return { pointsPerMovie, pointsPerCorrectPick };
|
||||
}
|
||||
|
||||
// Public: get points configuration
|
||||
router.get("/points-config", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value");
|
||||
const normalized = normalizePointsConfig(existing?.value);
|
||||
return res.status(200).json(normalized);
|
||||
} catch (err) {
|
||||
return res.status(200).json(DEFAULT_POINTS_CONFIG);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: set points configuration
|
||||
router.put("/points-config", async (req, res) => {
|
||||
const token = req.headers.authorization?.split(" ")[1];
|
||||
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
if (!decoded.admin) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const normalized = normalizePointsConfig(req.body);
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: POINTS_CONFIG_KEY },
|
||||
{ $set: { value: normalized } },
|
||||
{ upsert: true, new: true }
|
||||
).select("value");
|
||||
|
||||
return res.status(200).json(normalizePointsConfig(updated?.value));
|
||||
} catch (err) {
|
||||
if (err instanceof jwt.JsonWebTokenError) {
|
||||
return res.status(401).json({ message: "Invalid or expired token" });
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
+41
-1
@@ -6,6 +6,7 @@ const User = require("../models/User");
|
||||
const Movie = require("../models/Movie");
|
||||
const Setting = require("../models/Setting");
|
||||
const PlaybackProgress = require("../models/PlaybackProgress");
|
||||
const OscarPick = require("../models/OscarPick");
|
||||
const axios = require("axios");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -142,6 +143,32 @@ router.get("/stats", verifyToken, async (req, res) => {
|
||||
return res.status(400).json({ message: "Aucun films disponible." });
|
||||
}
|
||||
|
||||
// Get points configuration
|
||||
const POINTS_CONFIG_KEY = "points_config";
|
||||
const pointsConfigSetting = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value").lean();
|
||||
const pointsConfig = pointsConfigSetting?.value && typeof pointsConfigSetting.value === "object"
|
||||
? {
|
||||
pointsPerMovie: typeof pointsConfigSetting.value.pointsPerMovie === "number" ? pointsConfigSetting.value.pointsPerMovie : 1,
|
||||
pointsPerCorrectPick: typeof pointsConfigSetting.value.pointsPerCorrectPick === "number" ? pointsConfigSetting.value.pointsPerCorrectPick : 1,
|
||||
}
|
||||
: { pointsPerMovie: 1, pointsPerCorrectPick: 1 };
|
||||
|
||||
// Get all picks for this year with scores
|
||||
let picksByUserId = new Map();
|
||||
try {
|
||||
const allPicks = await OscarPick.find({ year })
|
||||
.select("userId score")
|
||||
.lean();
|
||||
|
||||
allPicks.forEach(pick => {
|
||||
const userId = String(pick.userId);
|
||||
picksByUserId.set(userId, pick.score !== null && pick.score !== undefined ? pick.score : 0);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error fetching picks for stats:", err);
|
||||
// Continue without picks - users will just have 0 pick points
|
||||
}
|
||||
|
||||
// Build stats for each user
|
||||
const userStats = users.map((user) => {
|
||||
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
|
||||
@@ -175,13 +202,26 @@ router.get("/stats", verifyToken, async (req, res) => {
|
||||
const watchedCount = watchedMoviesDetails.length;
|
||||
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
|
||||
|
||||
// Calculate points
|
||||
const userId = String(user._id);
|
||||
const correctPicks = picksByUserId.get(userId) || 0;
|
||||
const moviePoints = watchedCount * pointsConfig.pointsPerMovie;
|
||||
const pickPoints = correctPicks * pointsConfig.pointsPerCorrectPick;
|
||||
const totalPoints = moviePoints + pickPoints;
|
||||
|
||||
return {
|
||||
name: user.name,
|
||||
userId: userId,
|
||||
year,
|
||||
watchedCount,
|
||||
totalMoviesCount,
|
||||
watchedRatio: `${watchedRatio}%`,
|
||||
watchedMovies: watchedMoviesDetails // Include movie details
|
||||
watchedMovies: watchedMoviesDetails, // Include movie details
|
||||
correctPicks: correctPicks,
|
||||
moviePoints: moviePoints,
|
||||
pickPoints: pickPoints,
|
||||
totalPoints: totalPoints,
|
||||
pointsConfig: pointsConfig
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user