feat: Add winners tab and 100% completion stats
Adds a new section to the admin control panel for managing yearly winners. Also includes a new section on the user statistics page to display users who have completed 100% of movies for each year. Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
5a2b40357e
commit
9a7063b9dc
@@ -1,13 +1,16 @@
|
||||
const express = require("express");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const Setting = require("../models/Setting");
|
||||
const Movie = require("../models/Movie");
|
||||
const User = require("../models/User");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
||||
const COMPLETION_MODAL_KEY = "completion_modal_content";
|
||||
const WINNERS_BY_YEAR_KEY = "winners_by_year";
|
||||
|
||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||
title: "Félicitations! very nice 🎉🎉🎉",
|
||||
@@ -24,6 +27,31 @@ function parseOscarYear(raw) {
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseOptionalPoints(raw) {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null) return null;
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
// Points can be any finite number, but we store a rounded int for consistency.
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
function normalizeWinnersByYear(rawValue) {
|
||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const year = parseOscarYear(k);
|
||||
if (!year) continue;
|
||||
const entry = v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
||||
const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
|
||||
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
|
||||
const points = parseOptionalPoints(entry.points);
|
||||
out[String(year)] = { userId, points };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeHtml(raw) {
|
||||
// Basic, defensive sanitization (admin-controlled input, rendered for all users).
|
||||
// We remove script tags and inline event handlers. This is not a complete HTML sanitizer.
|
||||
@@ -153,5 +181,108 @@ router.put("/completion-modal", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Public: get winners by year (optionally with points)
|
||||
router.get("/winners", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||
const normalized = normalizeWinnersByYear(existing?.value);
|
||||
|
||||
const entries = Object.entries(normalized)
|
||||
.map(([yearStr, v]) => ({
|
||||
year: Number(yearStr),
|
||||
userId: v.userId,
|
||||
points: v.points ?? null,
|
||||
}))
|
||||
.filter((e) => parseOscarYear(e.year))
|
||||
.sort((a, b) => b.year - a.year);
|
||||
|
||||
const userIds = entries.map((e) => e.userId).filter(Boolean);
|
||||
const users = userIds.length
|
||||
? await User.find({ _id: { $in: userIds } }).select("name").lean()
|
||||
: [];
|
||||
const nameById = new Map(users.map((u) => [String(u._id), u.name]));
|
||||
|
||||
return res.status(200).json({
|
||||
winners: entries.map((e) => ({
|
||||
year: e.year,
|
||||
userId: e.userId,
|
||||
name: nameById.get(String(e.userId)) || null,
|
||||
points: e.points ?? null,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: set/clear winner for a given year. Points optional.
|
||||
// Body: { userId: "<mongoId>" | "" | null, points?: number|null }
|
||||
router.put("/winners/:year", 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 year = parseOscarYear(req.params?.year);
|
||||
if (!year) {
|
||||
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||
}
|
||||
|
||||
const rawUserId = req.body?.userId;
|
||||
const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : "";
|
||||
const points = parseOptionalPoints(req.body?.points);
|
||||
|
||||
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||
const winners = normalizeWinnersByYear(existing?.value);
|
||||
|
||||
// Clear winner for year
|
||||
if (!userId) {
|
||||
delete winners[String(year)];
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: WINNERS_BY_YEAR_KEY },
|
||||
{ $set: { value: winners } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
return res.status(200).json({ year, winner: null });
|
||||
}
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(userId)) {
|
||||
return res.status(400).json({ message: "userId invalide" });
|
||||
}
|
||||
|
||||
const winnerUser = await User.findById(userId).select("name").lean();
|
||||
if (!winnerUser) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
winners[String(year)] = { userId: String(userId), points };
|
||||
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: WINNERS_BY_YEAR_KEY },
|
||||
{ $set: { value: winners } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
year,
|
||||
winner: {
|
||||
year,
|
||||
userId: String(userId),
|
||||
name: winnerUser.name || null,
|
||||
points: points ?? null,
|
||||
},
|
||||
});
|
||||
} 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;
|
||||
|
||||
|
||||
@@ -167,6 +167,85 @@ router.get("/stats", verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get users who have 100% completion per year (across all years in DB)
|
||||
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
|
||||
router.get("/completions", verifyToken, async (req, res) => {
|
||||
try {
|
||||
const yearsRaw = await Movie.distinct("year");
|
||||
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
|
||||
.map((y) => Number(y))
|
||||
.filter((y) => parseOscarYear(y))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
if (years.length === 0) {
|
||||
return res.status(200).json({ years: [], totals: {}, completersByYear: {} });
|
||||
}
|
||||
|
||||
// Fetch imdb_id + year for all movies that have a valid year
|
||||
const movies = await Movie.find({ year: { $in: years } }).select("imdb_id year").lean();
|
||||
|
||||
const imdbIdsByYear = new Map();
|
||||
const totals = {};
|
||||
|
||||
movies.forEach((m) => {
|
||||
const year = Number(m?.year);
|
||||
if (!parseOscarYear(year)) return;
|
||||
const imdb = typeof m?.imdb_id === "string" ? m.imdb_id : "";
|
||||
if (!imdb) return;
|
||||
if (!imdbIdsByYear.has(year)) imdbIdsByYear.set(year, new Set());
|
||||
imdbIdsByYear.get(year).add(imdb);
|
||||
});
|
||||
|
||||
for (const y of years) {
|
||||
const set = imdbIdsByYear.get(y);
|
||||
totals[String(y)] = set ? set.size : 0;
|
||||
}
|
||||
|
||||
const users = await User.find().select("name watchedMovies.imdb_id").lean();
|
||||
|
||||
const completersByYear = {};
|
||||
for (const y of years) {
|
||||
const total = totals[String(y)] || 0;
|
||||
if (total <= 0) {
|
||||
completersByYear[String(y)] = [];
|
||||
continue;
|
||||
}
|
||||
const yearSet = imdbIdsByYear.get(y) || new Set();
|
||||
|
||||
const completers = [];
|
||||
users.forEach((u) => {
|
||||
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
|
||||
const watchedSet = new Set(
|
||||
watched
|
||||
.map((wm) => (typeof wm?.imdb_id === "string" ? wm.imdb_id : ""))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
for (const imdb of watchedSet) {
|
||||
if (yearSet.has(imdb)) count += 1;
|
||||
}
|
||||
|
||||
if (count === total) {
|
||||
completers.push({
|
||||
name: u?.name || "(sans nom)",
|
||||
watchedCount: count,
|
||||
totalMoviesCount: total,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
completers.sort((a, b) => String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" }));
|
||||
completersByYear[String(y)] = completers;
|
||||
}
|
||||
|
||||
return res.status(200).json({ years, totals, completersByYear });
|
||||
} catch (err) {
|
||||
console.error("Erreur dans l'extraction des completers:", err);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Register User
|
||||
router.post("/register", async (req, res) => {
|
||||
const { name, email, password, verifoof } = req.body;
|
||||
|
||||
Reference in New Issue
Block a user