Files
oscars-pool/routes/categories.js

375 lines
12 KiB
JavaScript

const express = require("express");
const jwt = require("jsonwebtoken");
const path = require("path");
const fs = require("fs");
const OscarCategory = require("../models/OscarCategory");
const OscarPick = require("../models/OscarPick");
const router = express.Router();
// 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 (number first or name first with number)
if (line.match(/^(\d+)[\t\s]+(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)/i) ||
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;
}
// Skip lines that are just numbers or dashes
if (nomineeName.match(/^-?\s*\d+\s*-?$/) || nomineeName.match(/^-+\s*$/)) {
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;
}
// 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 });
}
});
// Get a single category by ID (admin only) - must come after /list route
router.get("/: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.findById(req.params.categoryId).lean();
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
res.json({ category });
} catch (err) {
console.error("Error fetching category:", 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 { categoryNumber, categoryName, nominees } = req.body;
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
return res.status(400).json({ message: "categoryName and nominees array are required" });
}
if (categoryNumber !== undefined && categoryNumber !== null) {
const categoryNum = parseInt(categoryNumber);
if (!Number.isInteger(categoryNum) || categoryNum < 1 || categoryNum > 50) {
return res.status(400).json({ message: "categoryNumber must be between 1 and 50" });
}
}
const nomineesArray = nominees.map(n => ({
name: typeof n === 'string' ? n : n.name,
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
}));
// Get the current category to check year and handle categoryNumber conflicts
const currentCategory = await OscarCategory.findById(req.params.categoryId);
if (!currentCategory) {
return res.status(404).json({ message: "Category not found" });
}
const year = currentCategory.year;
const updateData = {
categoryName,
nominees: nomineesArray
};
// If categoryNumber is being changed, check for conflicts and update existing picks
if (categoryNumber !== undefined && categoryNumber !== null) {
const newCategoryNum = parseInt(categoryNumber);
if (newCategoryNum !== currentCategory.categoryNumber) {
const oldCategoryNum = currentCategory.categoryNumber;
// Check if another category with this number already exists for this year
const existingCategory = await OscarCategory.findOne({
year,
categoryNumber: newCategoryNum,
_id: { $ne: req.params.categoryId }
});
if (existingCategory) {
return res.status(400).json({
message: `Category number ${newCategoryNum} already exists for year ${year}`
});
}
// Update all existing picks that reference the old category number
// Match by old categoryNumber and old categoryName to ensure we're updating the right picks
const picksToUpdate = await OscarPick.find({ year });
let updatedPicksCount = 0;
for (const pick of picksToUpdate) {
if (pick.picks && Array.isArray(pick.picks)) {
let needsUpdate = false;
const updatedPicks = pick.picks.map(p => {
// Match by both old categoryNumber and old categoryName to be safe
if (p.categoryNumber === oldCategoryNum && p.categoryName === currentCategory.categoryName) {
needsUpdate = true;
return {
...p,
categoryNumber: newCategoryNum,
categoryName: categoryName // Update categoryName too if it changed
};
}
return p;
});
if (needsUpdate) {
pick.picks = updatedPicks;
await pick.save();
updatedPicksCount++;
}
}
}
updateData.categoryNumber = newCategoryNum;
// Log the update (optional, for debugging)
if (updatedPicksCount > 0) {
console.log(`Updated ${updatedPicksCount} pick(s) when changing category number from ${oldCategoryNum} to ${newCategoryNum}`);
}
}
}
const category = await OscarCategory.findByIdAndUpdate(
req.params.categoryId,
updateData,
{ 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;
const isClearing = !winnerName || winnerName === '' || winnerName === null;
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
if (isClearing) {
// Clear all winners (set all nominees to not winners)
category.nominees = category.nominees.map(n => ({
...n.toObject(),
isWinner: false
}));
await category.save();
return res.json({ message: "Winner cleared successfully", category });
}
// 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 });
}
});
// Helper function to clean up picks for deleted categories
async function cleanupPicksForDeletedCategories(year) {
try {
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const allPicks = await OscarPick.find({ year });
let totalCleaned = 0;
for (const pick of allPicks) {
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
if (pick.picks.length !== originalLength) {
await pick.save();
totalCleaned += (originalLength - pick.picks.length);
}
}
}
if (totalCleaned > 0) {
console.log(`Cleaned up ${totalCleaned} pick(s) for deleted categories in year ${year}`);
}
return totalCleaned;
} catch (err) {
console.error("Error cleaning up picks:", err);
throw err;
}
}
// 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.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
const year = category.year;
// Delete the category
await OscarCategory.findByIdAndDelete(req.params.categoryId);
// Clean up picks for deleted categories
await cleanupPicksForDeletedCategories(year);
res.json({ message: "Category deleted successfully" });
} catch (err) {
console.error("Error deleting category:", err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;