Aucun gagnant défini.
';
+ return;
+ }
+
+ // Ensure sorted by year desc
+ list.sort((a, b) => Number(b?.year || 0) - Number(a?.year || 0));
+ winnersList.innerHTML = '';
+
+ list.forEach((w) => {
+ const year = Number(w?.year);
+ const name = w?.name || '(utilisateur supprimé)';
+ const points = w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points);
+ const pointsLabel = points === null || Number.isNaN(points) ? '' : ` — ${points} pts`;
+ const item = document.createElement('div');
+ item.className = 'list-group-item d-flex justify-content-between align-items-center';
+ item.innerHTML = `
+ Impossible de charger les finisseurs 100%.
';
+ return;
+ }
+
+ const years = Array.isArray(completions?.years) ? completions.years : [];
+ const totals = completions?.totals && typeof completions.totals === 'object' ? completions.totals : {};
+ const byYear = completions?.completersByYear && typeof completions.completersByYear === 'object' ? completions.completersByYear : {};
+
+ if (!years.length) {
+ root.innerHTML = 'Aucune année trouvée.
';
+ return;
+ }
+
+ // Build a simple accordion
+ const accordionId = 'completersAccordion';
+ root.innerHTML = `Aucun utilisateur à 100%.
`;
+
+ const item = document.createElement('div');
+ item.className = 'accordion-item';
+ item.innerHTML = `
+
+
Statistiques des utilisateurs
+
+
+
Gagnants
+
Définis dans l’admin (optionnel).
+
+
+
+
+
+
Utilisateurs ayant terminé 100% (par année)
+
Completion = 100% pour l’année (tous les films de l’année vus).
+
+
+
Loading...
diff --git a/routes/settings.js b/routes/settings.js
index 6a41d87..30359eb 100644
--- a/routes/settings.js
+++ b/routes/settings.js
@@ -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: "" | "" | 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;
diff --git a/routes/users.js b/routes/users.js
index 21574ce..1e033a5 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -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;