Merge pull request #29 from quoije/cursor/yearly-completion-and-winner-stats-2e76

Yearly completion and winner stats
This commit was merged in pull request #29.
This commit is contained in:
Mathieu Moran
2025-12-21 15:41:04 -05:00
committed by GitHub
6 changed files with 666 additions and 6 deletions
+48
View File
@@ -80,6 +80,11 @@
Modal 100% Modal 100%
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-winners-tab" data-bs-toggle="tab" data-bs-target="#admin-winners" type="button" role="tab" aria-controls="admin-winners" aria-selected="false">
Gagnants
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false"> <button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
Backup / Restore Backup / Restore
@@ -387,6 +392,49 @@
</div> </div>
</div> </div>
</div> </div>
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="card p-3">
<h4 class="mb-1">Définir le gagnant par année</h4>
<div class="text-muted mb-3">
Choisis une année, un utilisateur, et des points (optionnel). Laisse le champ points vide si tu ne veux pas l’afficher.
</div>
<div class="row g-3">
<div class="col-md-4">
<label for="winner_year" class="form-label">Année</label>
<select id="winner_year" class="form-select"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="col-md-5">
<label for="winner_user" class="form-label">Utilisateur</label>
<select id="winner_user" class="form-select">
<option value="">—</option>
</select>
<div class="form-text">Si tu choisis “—”, le gagnant est supprimé pour l’année.</div>
</div>
<div class="col-md-3">
<label for="winner_points" class="form-label">Points (optionnel)</label>
<input id="winner_points" type="number" class="form-control" placeholder="ex: 42" min="0" step="1">
</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="button" id="winner_reload" class="btn btn-outline-primary">Recharger</button>
<button type="button" id="winner_clear" class="btn btn-outline-danger">Supprimer</button>
<button type="button" id="winner_save" class="btn btn-warning">Enregistrer</button>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Gagnant actuel</div>
<div id="winner_current" class="border rounded p-2 bg-light">—</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
+265
View File
@@ -62,6 +62,16 @@ window.onload = async function () {
const selectAllBox = document.getElementById('select-all-movies'); const selectAllBox = document.getElementById('select-all-movies');
const selectedCountEl = document.getElementById('selected-count'); const selectedCountEl = document.getElementById('selected-count');
// Winners tab elements
const adminWinnersTabBtn = document.getElementById('admin-winners-tab');
const winnerYearSelect = document.getElementById('winner_year');
const winnerUserSelect = document.getElementById('winner_user');
const winnerPointsEl = document.getElementById('winner_points');
const winnerReloadBtn = document.getElementById('winner_reload');
const winnerClearBtn = document.getElementById('winner_clear');
const winnerSaveBtn = document.getElementById('winner_save');
const winnerCurrentEl = document.getElementById('winner_current');
const editMovieModalEl = document.getElementById('editMovieModal'); const editMovieModalEl = document.getElementById('editMovieModal');
const editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null; const editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle'); const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
@@ -83,6 +93,10 @@ window.onload = async function () {
let moviesById = new Map(); let moviesById = new Map();
let activeYear = null; let activeYear = null;
let winnersLoadedOnce = false;
let winnerUsersLoadedOnce = false;
let winnersByYear = new Map(); // year -> { year, userId, name, points }
let winnerUsers = []; // admin list: {id, name, email, ...}
const DEFAULT_COMPLETION_MODAL = Object.freeze({ const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉', title: 'Félicitations! very nice 🎉🎉🎉',
@@ -460,6 +474,168 @@ window.onload = async function () {
return n; return n;
} }
async function fetchWinners() {
const res = await fetch('/api/settings/winners', { method: 'GET' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return Array.isArray(data?.winners) ? data.winners : [];
}
async function saveWinner(year, userId, points) {
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
userId: userId ? String(userId) : '',
points: points === undefined ? null : points,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
function buildWinnerUsersOptions(users) {
if (!winnerUserSelect) return;
const list = Array.isArray(users) ? users : [];
const previous = String(winnerUserSelect.value || '');
winnerUserSelect.innerHTML = '';
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '—';
winnerUserSelect.appendChild(emptyOpt);
list.forEach((u) => {
const opt = document.createElement('option');
opt.value = String(u?.id || '');
const label = u?.name ? u.name : u?.email ? u.email : '(sans nom)';
opt.textContent = label;
winnerUserSelect.appendChild(opt);
});
const stillExists = list.map((u) => String(u?.id || '')).includes(previous);
winnerUserSelect.value = stillExists ? previous : '';
}
function setWinnerYearOptions(cleanedYears) {
if (!winnerYearSelect) return;
const years = Array.isArray(cleanedYears) ? cleanedYears : [];
const previous = String(winnerYearSelect.value || '');
winnerYearSelect.innerHTML = '';
if (!years.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Aucune année (ajoute un film)';
opt.disabled = true;
opt.selected = true;
winnerYearSelect.appendChild(opt);
winnerYearSelect.disabled = true;
if (winnerSaveBtn) winnerSaveBtn.disabled = true;
if (winnerClearBtn) winnerClearBtn.disabled = true;
return;
}
years.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
winnerYearSelect.appendChild(opt);
});
winnerYearSelect.disabled = false;
if (winnerSaveBtn) winnerSaveBtn.disabled = false;
if (winnerClearBtn) winnerClearBtn.disabled = false;
const stillExists = years.map(String).includes(previous);
const desired = stillExists
? previous
: (activeYear && years.map(String).includes(String(activeYear)))
? String(activeYear)
: String(years[0]);
winnerYearSelect.value = desired;
}
function getWinnerForYear(year) {
const y = parseYear(year);
if (!y) return null;
return winnersByYear.get(String(y)) || null;
}
function renderWinnerCurrent() {
if (!winnerCurrentEl || !winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) {
winnerCurrentEl.textContent = '—';
return;
}
const w = getWinnerForYear(y);
if (!w) {
winnerCurrentEl.textContent = `${y}: (aucun gagnant)`;
return;
}
const points = w.points === null || w.points === undefined ? null : Number(w.points);
const pointsLabel = points === null || Number.isNaN(points) ? '' : ` — ${points} pts`;
winnerCurrentEl.textContent = `${y}: ${w.name || '(utilisateur supprimé)'}${pointsLabel}`;
}
function applyWinnerFormFromCache() {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) return;
const w = getWinnerForYear(y);
if (winnerUserSelect) {
winnerUserSelect.value = w?.userId ? String(w.userId) : '';
}
if (winnerPointsEl) {
const pts = w?.points;
winnerPointsEl.value = pts === null || pts === undefined ? '' : String(pts);
}
renderWinnerCurrent();
}
async function loadWinners(options = {}) {
const force = !!options.force;
if (winnersLoadedOnce && !force) return;
const list = await fetchWinners();
winnersLoadedOnce = true;
winnersByYear = new Map(
list
.filter((w) => parseYear(w?.year))
.map((w) => [
String(w.year),
{
year: Number(w.year),
userId: String(w.userId || ''),
name: w.name || null,
points: w.points ?? null,
}
])
);
applyWinnerFormFromCache();
}
async function loadWinnerUsers(options = {}) {
const force = !!options.force;
if (winnerUsersLoadedOnce && !force) return;
const users = await fetchAdminUsers();
winnerUsersLoadedOnce = true;
winnerUsers = Array.isArray(users) ? users : [];
winnerUsers.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' }));
buildWinnerUsersOptions(winnerUsers);
applyWinnerFormFromCache();
}
async function fetchActiveYear() { async function fetchActiveYear() {
try { try {
const res = await fetch('/api/settings/year', { method: 'GET' }); const res = await fetch('/api/settings/year', { method: 'GET' });
@@ -1001,6 +1177,91 @@ window.onload = async function () {
dbRestoreRunBtn.addEventListener('click', runRestore); dbRestoreRunBtn.addEventListener('click', runRestore);
} }
// Winners tab wiring
async function initWinnersTab(options = {}) {
const force = !!options.force;
try {
await Promise.all([
loadWinners({ force }),
loadWinnerUsers({ force }),
]);
renderWinnerCurrent();
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
}
}
if (adminWinnersTabBtn) {
adminWinnersTabBtn.addEventListener('shown.bs.tab', () => initWinnersTab({ force: false }));
}
if (winnerYearSelect) {
winnerYearSelect.addEventListener('change', () => applyWinnerFormFromCache());
}
if (winnerReloadBtn) {
winnerReloadBtn.addEventListener('click', async () => {
winnerReloadBtn.disabled = true;
const old = winnerReloadBtn.textContent;
winnerReloadBtn.textContent = 'Chargement...';
try {
await initWinnersTab({ force: true });
showResponse('success', 'Gagnants rechargés.');
} finally {
winnerReloadBtn.disabled = false;
winnerReloadBtn.textContent = old;
}
});
}
if (winnerClearBtn) {
winnerClearBtn.addEventListener('click', async () => {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) return;
const ok = window.confirm(`Supprimer le gagnant pour ${y} ?`);
if (!ok) return;
try {
if (winnerUserSelect) winnerUserSelect.value = '';
if (winnerPointsEl) winnerPointsEl.value = '';
await saveWinner(y, '', null);
await loadWinners({ force: true });
showResponse('success', `Gagnant supprimé pour ${y}.`);
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
}
});
}
if (winnerSaveBtn) {
winnerSaveBtn.addEventListener('click', async () => {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) {
showResponse('warning', 'Année invalide.');
return;
}
const userId = String(winnerUserSelect?.value || '').trim();
const pointsRaw = String(winnerPointsEl?.value || '').trim();
const points = pointsRaw === '' ? null : Number(pointsRaw);
const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points);
winnerSaveBtn.disabled = true;
const old = winnerSaveBtn.textContent;
winnerSaveBtn.textContent = 'Enregistrement...';
try {
await saveWinner(y, userId, pointsToSend);
await loadWinners({ force: true });
showResponse('success', `Gagnant enregistré pour ${y}.`);
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
winnerSaveBtn.disabled = false;
winnerSaveBtn.textContent = old;
}
});
}
form.addEventListener('submit', async function (e) { form.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
@@ -1135,6 +1396,10 @@ window.onload = async function () {
activeYearInput.value = desired; activeYearInput.value = desired;
} }
} }
// Winners year dropdown (optional tab)
setWinnerYearOptions(cleanedYears);
applyWinnerFormFromCache();
} catch (_) { } catch (_) {
// ignore // ignore
} }
+124 -5
View File
@@ -13,6 +13,117 @@ window.onload = async function () {
} }
} }
async function fetchWinners() {
try {
const res = await fetch('/api/settings/winners', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch winners');
const data = await res.json().catch(() => ({}));
return Array.isArray(data?.winners) ? data.winners : [];
} catch (_) {
return [];
}
}
async function fetchCompletions() {
try {
const res = await fetch('/api/users/completions', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch completions');
const data = await res.json().catch(() => ({}));
return data && typeof data === 'object' ? data : null;
} catch (_) {
return null;
}
}
function renderWinners(winners) {
const winnersList = document.getElementById('winners-list');
if (!winnersList) return;
const list = Array.isArray(winners) ? winners : [];
if (!list.length) {
winnersList.innerHTML = '<div class="list-group-item text-muted">Aucun gagnant défini.</div>';
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 = `
<div><strong>${year || '—'}</strong> : ${name}${pointsLabel}</div>
`;
winnersList.appendChild(item);
});
}
function renderCompletions(completions) {
const root = document.getElementById('completers-by-year');
if (!root) return;
if (!completions || typeof completions !== 'object') {
root.innerHTML = '<div class="text-muted">Impossible de charger les finisseurs 100%.</div>';
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 = '<div class="text-muted">Aucune année trouvée.</div>';
return;
}
// Build a simple accordion
const accordionId = 'completersAccordion';
root.innerHTML = `<div class="accordion" id="${accordionId}"></div>`;
const acc = root.querySelector('.accordion');
years.forEach((y, idx) => {
const yearStr = String(y);
const total = Number(totals?.[yearStr] ?? 0);
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
const count = completers.length;
const collapseId = `completers-${yearStr}`;
const headingId = `heading-${yearStr}`;
const show = idx === 0 ? 'show' : '';
const collapsed = idx === 0 ? '' : 'collapsed';
const expanded = idx === 0 ? 'true' : 'false';
const bodyHtml = count
? `<ul class="list-group list-group-flush">
${completers.map((u) => `<li class="list-group-item">${u?.name || '(sans nom)'}</li>`).join('')}
</ul>`
: `<div class="text-muted">Aucun utilisateur à 100%.</div>`;
const item = document.createElement('div');
item.className = 'accordion-item';
item.innerHTML = `
<h2 class="accordion-header" id="${headingId}">
<button class="accordion-button ${collapsed}" type="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${expanded}" aria-controls="${collapseId}">
${yearStr} — ${count} finisseur(s) (total films: ${total})
</button>
</h2>
<div id="${collapseId}" class="accordion-collapse collapse ${show}" aria-labelledby="${headingId}" data-bs-parent="#${accordionId}">
<div class="accordion-body">
${bodyHtml}
</div>
</div>
`;
acc.appendChild(item);
});
}
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
@@ -52,12 +163,14 @@ window.onload = async function () {
} }
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats'; const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
const statsRes = await fetch(statsUrl, { const [statsRes, winners, completions] = await Promise.all([
fetch(statsUrl, {
method: 'GET', method: 'GET',
headers: { headers: { 'Authorization': `Bearer ${token}` }
'Authorization': `Bearer ${token}` }),
} fetchWinners(),
}); fetchCompletions(),
]);
if (!statsRes.ok) { if (!statsRes.ok) {
throw new Error('Failed to fetch user stats'); throw new Error('Failed to fetch user stats');
@@ -68,6 +181,9 @@ window.onload = async function () {
const table = document.querySelector('.user-table'); const table = document.querySelector('.user-table');
const spinner = document.getElementById('loading-spinner'); const spinner = document.getElementById('loading-spinner');
renderWinners(winners);
renderCompletions(completions);
// Ensure watchedRatio is treated as a number and sort in descending order // Ensure watchedRatio is treated as a number and sort in descending order
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio)); stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
@@ -117,6 +233,9 @@ window.onload = async function () {
}); });
} catch (error) { } catch (error) {
console.error('Error loading user statistics:', error); console.error('Error loading user statistics:', error);
// Still attempt to render these sections even if stats fail (best effort)
try { renderWinners(await fetchWinners()); } catch (_) {}
try { renderCompletions(await fetchCompletions()); } catch (_) {}
} }
// Log-off functionality // Log-off functionality
+18
View File
@@ -62,6 +62,24 @@
<div class="container mt-5"> <div class="container mt-5">
<h2>Statistiques des utilisateurs</h2> <h2>Statistiques des utilisateurs</h2>
<!-- Winners section -->
<div class="mt-4">
<h4 class="mb-2">Gagnants</h4>
<div class="text-muted mb-2">Définis dans l’admin (optionnel).</div>
<div id="winners-list" class="list-group mb-4">
<div class="list-group-item text-muted">Chargement…</div>
</div>
</div>
<!-- 100% completers section -->
<div class="mt-4">
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
<div class="text-muted mb-2">Completion = 100% pour l’année (tous les films de l’année vus).</div>
<div id="completers-by-year" class="mb-4">
<div class="text-muted">Chargement…</div>
</div>
</div>
<!-- Spinner shown while loading --> <!-- Spinner shown while loading -->
<div id="loading-spinner" class="spinner-border text-primary" role="status"> <div id="loading-spinner" class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
+131
View File
@@ -1,13 +1,16 @@
const express = require("express"); const express = require("express");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Setting = require("../models/Setting"); const Setting = require("../models/Setting");
const Movie = require("../models/Movie"); const Movie = require("../models/Movie");
const User = require("../models/User");
const router = express.Router(); const router = express.Router();
const ACTIVE_YEAR_KEY = "active_oscar_year"; const ACTIVE_YEAR_KEY = "active_oscar_year";
const COMPLETION_MODAL_KEY = "completion_modal_content"; const COMPLETION_MODAL_KEY = "completion_modal_content";
const WINNERS_BY_YEAR_KEY = "winners_by_year";
const DEFAULT_COMPLETION_MODAL = Object.freeze({ const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: "Félicitations! very nice 🎉🎉🎉", title: "Félicitations! very nice 🎉🎉🎉",
@@ -24,6 +27,31 @@ function parseOscarYear(raw) {
return n; 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) { function sanitizeHtml(raw) {
// Basic, defensive sanitization (admin-controlled input, rendered for all users). // 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. // 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; module.exports = router;
+79
View File
@@ -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 // Register User
router.post("/register", async (req, res) => { router.post("/register", async (req, res) => {
const { name, email, password, verifoof } = req.body; const { name, email, password, verifoof } = req.body;