diff --git a/public/control.html b/public/control.html index db7be66..bccef94 100644 --- a/public/control.html +++ b/public/control.html @@ -397,9 +397,10 @@
-

Définir le gagnant par année

+

Définir les gagnants par année (égalité)

- Choisis une année, un utilisateur, et des points (optionnel). Laisse le champ points vide si tu ne veux pas l’afficher. + Tu peux ajouter plusieurs gagnants pour la même année (ex: égalité au même nombre de points). + Choisis un utilisateur + des points (optionnel), puis clique Ajouter.
@@ -413,7 +414,7 @@ -
Si tu choisis “—”, le gagnant est supprimé pour l’année.
+
Pour retirer un gagnant, utilise la liste “Gagnants actuels” ci-dessous.
@@ -423,12 +424,12 @@
- - + +
-
Gagnant actuel
+
Gagnants actuels
—
diff --git a/public/js/app_control.js b/public/js/app_control.js index b732f58..8ba6e91 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -95,7 +95,7 @@ window.onload = async function () { let activeYear = null; let winnersLoadedOnce = false; let winnerUsersLoadedOnce = false; - let winnersByYear = new Map(); // year -> { year, userId, name, points } + let winnersByYear = new Map(); // year -> [{ year, userId, name, points }] let winnerUsers = []; // admin list: {id, name, email, ...} const DEFAULT_COMPLETION_MODAL = Object.freeze({ @@ -484,7 +484,7 @@ window.onload = async function () { return Array.isArray(data?.winners) ? data.winners : []; } - async function saveWinner(year, userId, points) { + async function addWinner(year, userId, points) { const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, { method: 'PUT', headers: { @@ -504,6 +504,19 @@ window.onload = async function () { return data; } + async function removeWinner(year, userId) { + const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}/${encodeURIComponent(String(userId))}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` }, + }); + 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 : []; @@ -579,27 +592,81 @@ window.onload = async function () { winnerCurrentEl.textContent = '—'; return; } - const w = getWinnerForYear(y); - if (!w) { + const list = getWinnerForYear(y); + const winners = Array.isArray(list) ? list : []; + if (!winners.length) { 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}`; + + // Sort for stable display: points desc (null last), then name + const sorted = winners.slice().sort((a, b) => { + const ap = a?.points === null || a?.points === undefined ? null : Number(a.points); + const bp = b?.points === null || b?.points === undefined ? null : Number(b.points); + if (ap === null && bp !== null) return 1; + if (ap !== null && bp === null) return -1; + if (ap !== null && bp !== null && ap !== bp) return bp - ap; + return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' }); + }); + + const rows = sorted.map((w) => { + const name = w?.name || '(utilisateur supprimé)'; + const pts = w?.points === null || w?.points === undefined ? null : Number(w.points); + const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` — ${pts} pts`; + const uid = String(w?.userId || ''); + return ` +
+
${name}${ptsLabel}
+ +
+ `; + }).join(''); + + winnerCurrentEl.innerHTML = ` +
${y}
+ ${rows} + `; + + winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => { + btn.addEventListener('click', async () => { + const yearRaw = btn.getAttribute('data-winner-remove-year'); + const userRaw = btn.getAttribute('data-winner-remove-user'); + const yy = parseYear(yearRaw); + const uid = String(userRaw || '').trim(); + if (!yy || !uid) return; + btn.disabled = true; + const old = btn.textContent; + btn.textContent = '...'; + try { + await removeWinner(yy, uid); + await loadWinners({ force: true }); + showResponse('success', `Gagnant retiré pour ${yy}.`); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + btn.disabled = false; + btn.textContent = old; + } + }); + }); } 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) : ''; + winnerUserSelect.value = ''; } if (winnerPointsEl) { - const pts = w?.points; - winnerPointsEl.value = pts === null || pts === undefined ? '' : String(pts); + // Convenience: if all winners share the same non-null points, pre-fill it. + const list = getWinnerForYear(y); + const winners = Array.isArray(list) ? list : []; + const ptsVals = winners + .map((w) => (w?.points === null || w?.points === undefined) ? null : Number(w.points)) + .filter((n) => Number.isFinite(n)); + const unique = Array.from(new Set(ptsVals)); + winnerPointsEl.value = unique.length === 1 ? String(unique[0]) : ''; } renderWinnerCurrent(); } @@ -609,19 +676,21 @@ window.onload = async function () { 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, - } - ]) - ); + const grouped = new Map(); + (Array.isArray(list) ? list : []) + .filter((w) => parseYear(w?.year)) + .forEach((w) => { + const key = String(w.year); + const arr = grouped.get(key) || []; + arr.push({ + year: Number(w.year), + userId: String(w.userId || ''), + name: w.name || null, + points: w.points ?? null, + }); + grouped.set(key, arr); + }); + winnersByYear = grouped; applyWinnerFormFromCache(); } @@ -1219,12 +1288,12 @@ window.onload = async function () { if (!winnerYearSelect) return; const y = parseYear(winnerYearSelect.value); if (!y) return; - const ok = window.confirm(`Supprimer le gagnant pour ${y} ?`); + const ok = window.confirm(`Supprimer tous les gagnants pour ${y} ?`); if (!ok) return; try { if (winnerUserSelect) winnerUserSelect.value = ''; if (winnerPointsEl) winnerPointsEl.value = ''; - await saveWinner(y, '', null); + await addWinner(y, '', null); await loadWinners({ force: true }); showResponse('success', `Gagnant supprimé pour ${y}.`); } catch (err) { @@ -1242,6 +1311,10 @@ window.onload = async function () { return; } const userId = String(winnerUserSelect?.value || '').trim(); + if (!userId) { + showResponse('warning', 'Choisis un utilisateur.'); + return; + } const pointsRaw = String(winnerPointsEl?.value || '').trim(); const points = pointsRaw === '' ? null : Number(pointsRaw); const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points); @@ -1250,9 +1323,9 @@ window.onload = async function () { const old = winnerSaveBtn.textContent; winnerSaveBtn.textContent = 'Enregistrement...'; try { - await saveWinner(y, userId, pointsToSend); + await addWinner(y, userId, pointsToSend); await loadWinners({ force: true }); - showResponse('success', `Gagnant enregistré pour ${y}.`); + showResponse('success', `Gagnant ajouté pour ${y}.`); } catch (err) { showResponse('danger', err.message || 'Erreur réseau'); } finally { diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js index b7891af..1ae3d69 100644 --- a/public/js/app_user-stat.js +++ b/public/js/app_user-stat.js @@ -49,19 +49,51 @@ window.onload = async function () { return; } - // Ensure sorted by year desc - list.sort((a, b) => Number(b?.year || 0) - Number(a?.year || 0)); + // Group by year (supports ties) + const byYear = new Map(); + list.forEach((w) => { + const y = Number(w?.year || 0); + if (!Number.isInteger(y) || y < 1900 || y > 3000) return; + const arr = byYear.get(String(y)) || []; + arr.push({ + year: y, + name: w?.name || '(utilisateur supprimé)', + points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points), + }); + byYear.set(String(y), arr); + }); + + const years = Array.from(byYear.keys()).map(Number).sort((a, b) => b - a); 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`; + years.forEach((year) => { + const winnersForYear = byYear.get(String(year)) || []; + const sorted = winnersForYear.slice().sort((a, b) => { + const ap = a.points === null || Number.isNaN(a.points) ? null : a.points; + const bp = b.points === null || Number.isNaN(b.points) ? null : b.points; + if (ap === null && bp !== null) return 1; + if (ap !== null && bp === null) return -1; + if (ap !== null && bp !== null && ap !== bp) return bp - ap; + return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' }); + }); + const item = document.createElement('div'); - item.className = 'list-group-item d-flex justify-content-between align-items-center'; + item.className = 'list-group-item'; + + const winnersHtml = sorted + .map((w) => { + const pts = w.points === null || Number.isNaN(w.points) ? null : w.points; + const ptsLabel = pts === null ? '' : ` — ${pts} pts`; + return `
${w.name}${ptsLabel}
`; + }) + .join(''); + item.innerHTML = ` -
${year || '—'} : ${name}${pointsLabel}
+
+
${year}
+ ${sorted.length} +
+
${winnersHtml}
`; winnersList.appendChild(item); }); diff --git a/routes/settings.js b/routes/settings.js index 30359eb..c011e59 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -38,17 +38,33 @@ function parseOptionalPoints(raw) { } function normalizeWinnersByYear(rawValue) { + // Stored format: + // - New: { "2026": [ { userId, points? }, { userId, points? } ] } + // - Legacy: { "2026": { userId, points? } } 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 }; + + const list = Array.isArray(v) ? v : v && typeof v === "object" ? [v] : []; + const dedup = new Map(); // userId -> {userId, points} + + for (const rawEntry of list) { + const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {}; + const userId = typeof entry.userId === "string" ? entry.userId.trim() : ""; + if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue; + const points = parseOptionalPoints(entry.points); + dedup.set(String(userId), { userId: String(userId), points }); + } + + const normalizedList = Array.from(dedup.values()); + if (normalizedList.length) { + out[String(year)] = normalizedList; + } } + return out; } @@ -188,11 +204,13 @@ router.get("/winners", async (req, res) => { const normalized = normalizeWinnersByYear(existing?.value); const entries = Object.entries(normalized) - .map(([yearStr, v]) => ({ - year: Number(yearStr), - userId: v.userId, - points: v.points ?? null, - })) + .flatMap(([yearStr, list]) => + (Array.isArray(list) ? list : []).map((v) => ({ + year: Number(yearStr), + userId: v.userId, + points: v.points ?? null, + })) + ) .filter((e) => parseOscarYear(e.year)) .sort((a, b) => b.year - a.year); @@ -215,8 +233,10 @@ router.get("/winners", async (req, res) => { } }); -// Admin-only: set/clear winner for a given year. Points optional. -// Body: { userId: "" | "" | null, points?: number|null } +// Admin-only: add/upsert/clear winners for a given year. Points optional. +// - Clear all for year: { userId: "" } (legacy behavior) +// - Add/upsert winner for year: { userId: "", points?: number|null } +// - Replace full list for year: { winners: [{ userId, points? }, ...] } 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" }); @@ -232,14 +252,31 @@ router.put("/winners/:year", async (req, res) => { return res.status(400).json({ message: "Année invalide (ex: 2026)" }); } + const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value"); + const winners = normalizeWinnersByYear(existing?.value); + + // Replace list mode: { winners: [...] } + if (Array.isArray(req.body?.winners)) { + const replacement = normalizeWinnersByYear({ [String(year)]: req.body.winners })[String(year)] || []; + if (!replacement.length) { + delete winners[String(year)]; + } else { + winners[String(year)] = replacement; + } + await Setting.findOneAndUpdate( + { key: WINNERS_BY_YEAR_KEY }, + { $set: { value: winners } }, + { upsert: true, new: true } + ); + return res.status(200).json({ year, winners: winners[String(year)] || [] }); + } + + // Add/upsert/clear mode (legacy-compatible): { userId, points? } 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 + // Clear all for year if (!userId) { delete winners[String(year)]; await Setting.findOneAndUpdate( @@ -247,7 +284,7 @@ router.put("/winners/:year", async (req, res) => { { $set: { value: winners } }, { upsert: true, new: true } ); - return res.status(200).json({ year, winner: null }); + return res.status(200).json({ year, winners: [] }); } if (!mongoose.Types.ObjectId.isValid(userId)) { @@ -255,11 +292,13 @@ router.put("/winners/:year", async (req, res) => { } const winnerUser = await User.findById(userId).select("name").lean(); - if (!winnerUser) { - return res.status(404).json({ message: "User not found" }); - } + if (!winnerUser) return res.status(404).json({ message: "User not found" }); - winners[String(year)] = { userId: String(userId), points }; + const key = String(year); + const current = Array.isArray(winners[key]) ? winners[key] : []; + const next = current.filter((w) => String(w?.userId || "") !== String(userId)); + next.push({ userId: String(userId), points }); + winners[key] = next; await Setting.findOneAndUpdate( { key: WINNERS_BY_YEAR_KEY }, @@ -269,12 +308,8 @@ router.put("/winners/:year", async (req, res) => { return res.status(200).json({ year, - winner: { - year, - userId: String(userId), - name: winnerUser.name || null, - points: points ?? null, - }, + winner: { year, userId: String(userId), name: winnerUser.name || null, points: points ?? null }, + winners: winners[String(year)] || [], }); } catch (err) { if (err instanceof jwt.JsonWebTokenError) { @@ -284,5 +319,47 @@ router.put("/winners/:year", async (req, res) => { } }); +// Admin-only: remove a specific winner for a given year. +router.delete("/winners/:year/:userId", 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 userId = typeof req.params?.userId === "string" ? req.params.userId.trim() : ""; + if (!mongoose.Types.ObjectId.isValid(userId)) { + return res.status(400).json({ message: "userId invalide" }); + } + + const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value"); + const winners = normalizeWinnersByYear(existing?.value); + const key = String(year); + const current = Array.isArray(winners[key]) ? winners[key] : []; + const next = current.filter((w) => String(w?.userId || "") !== String(userId)); + if (next.length) winners[key] = next; + else delete winners[key]; + + await Setting.findOneAndUpdate( + { key: WINNERS_BY_YEAR_KEY }, + { $set: { value: winners } }, + { upsert: true, new: true } + ); + + return res.status(200).json({ year, winners: winners[key] || [] }); + } 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;