Yearly completion and winner stats #29
@@ -80,6 +80,11 @@
|
||||
Modal 100%
|
||||
</button>
|
||||
</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">
|
||||
<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
|
||||
@@ -387,6 +392,49 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -62,6 +62,16 @@ window.onload = async function () {
|
||||
const selectAllBox = document.getElementById('select-all-movies');
|
||||
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 editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
|
||||
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
|
||||
@@ -83,6 +93,10 @@ window.onload = async function () {
|
||||
|
||||
let moviesById = new Map();
|
||||
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({
|
||||
title: 'Félicitations! very nice 🎉🎉🎉',
|
||||
@@ -460,6 +474,168 @@ window.onload = async function () {
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
||||
@@ -1001,6 +1177,91 @@ window.onload = async function () {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1135,6 +1396,10 @@ window.onload = async function () {
|
||||
activeYearInput.value = desired;
|
||||
}
|
||||
}
|
||||
|
||||
// Winners year dropdown (optional tab)
|
||||
setWinnerYearOptions(cleanedYears);
|
||||
applyWinnerFormFromCache();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
+124
-5
@@ -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) {
|
||||
try {
|
||||
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 statsRes = await fetch(statsUrl, {
|
||||
const [statsRes, winners, completions] = await Promise.all([
|
||||
fetch(statsUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}),
|
||||
fetchWinners(),
|
||||
fetchCompletions(),
|
||||
]);
|
||||
|
||||
if (!statsRes.ok) {
|
||||
throw new Error('Failed to fetch user stats');
|
||||
@@ -68,6 +181,9 @@ window.onload = async function () {
|
||||
const table = document.querySelector('.user-table');
|
||||
const spinner = document.getElementById('loading-spinner');
|
||||
|
||||
renderWinners(winners);
|
||||
renderCompletions(completions);
|
||||
|
||||
// Ensure watchedRatio is treated as a number and sort in descending order
|
||||
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
||||
|
||||
@@ -117,6 +233,9 @@ window.onload = async function () {
|
||||
});
|
||||
} catch (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
|
||||
|
||||
@@ -62,6 +62,24 @@
|
||||
<div class="container mt-5">
|
||||
<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 -->
|
||||
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
|
||||
@@ -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