Files
oscars-pool/routes/settings.js
T
Mathieu Moran 70b309d1ae Add visibility toggle for picks features and rename Mes choix to Mes picks
- Renamed 'Mes choix' to 'Mes picks' across all pages and JavaScript files
- Added visibility toggle in control panel (Réglages > Visibilité) to control:
  - Display of 'Mes picks' button in navigation
  - Display of 'Bon picks' column in user statistics
- Combined both visibility options into single toggle for simplicity
- Added backend API endpoints for visibility configuration
- Implemented localStorage caching for instant visibility updates
- Fixed flash of content issues when navigating between pages
- Updated stats endpoint to calculate correct picks on-the-fly from current winners
- Renamed 'Bonnes réponses' to 'Bon picks' in user statistics table
2025-12-27 22:03:01 -05:00

856 lines
31 KiB
JavaScript

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 OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
const VISIBILITY_CONFIG_KEY = "visibility_config";
const crypto = require("crypto");
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: "Félicitations! very nice 🎉🎉🎉",
bodyText: "",
videoSrc: "video/reward.mp4",
bodyHtml: "",
});
const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
// Controls the admin-only debug/status block in player.html
showSource: true,
showProgress: true,
});
const DEFAULT_POINTS_CONFIG = Object.freeze({
pointsPerMovie: 1,
pointsPerCorrectPick: 1,
});
const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
showPicksButton: true,
showBonPicksColumn: true,
});
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
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 parseIsoDateString(raw) {
// Expected format: YYYY-MM-DD
if (raw === undefined || raw === null) return null;
const s = String(raw).trim();
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isInteger(year) || year < 1900 || year > 3000) return null;
if (!Number.isInteger(month) || month < 1 || month > 12) return null;
if (!Number.isInteger(day) || day < 1 || day > 31) return null;
// Validate calendar date (handles month/day overflow like Feb 30).
const d = new Date(Date.UTC(year, month - 1, day));
if (
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== month - 1 ||
d.getUTCDate() !== day
) {
return null;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function normalizeOscarDatesByYear(rawValue) {
// Stored format: { "2026": "2026-03-15" }
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 date = parseIsoDateString(v);
if (!date) continue;
out[String(year)] = date;
}
return out;
}
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 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;
}
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.
const html = String(raw || "");
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, "");
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "")
.replace(/\son\w+=\S+/gi, "");
return withoutOnAttrs.trim();
}
function normalizeCompletionModal(rawValue) {
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const title = typeof value.title === "string" ? value.title : DEFAULT_COMPLETION_MODAL.title;
const bodyText = typeof value.bodyText === "string" ? value.bodyText : DEFAULT_COMPLETION_MODAL.bodyText;
const videoSrc = typeof value.videoSrc === "string" ? value.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc;
const bodyHtml = typeof value.bodyHtml === "string" ? value.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml;
return {
title: String(title || "").slice(0, 200).trim() || DEFAULT_COMPLETION_MODAL.title,
bodyText: String(bodyText || "").slice(0, 8000),
videoSrc: String(videoSrc || "").slice(0, 2048).trim(),
bodyHtml: sanitizeHtml(String(bodyHtml || "").slice(0, 20000)),
};
}
function normalizePlayerAdminStatusUi(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showSource : !!v.showSource;
const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showProgress : !!v.showProgress;
return { showSource, showProgress };
}
function createId() {
try {
// Node 16+ / 18+: randomUUID
if (crypto && typeof crypto.randomUUID === "function") return crypto.randomUUID();
} catch (_) {}
// Fallback: time-based
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function normalizeAppVersionControl(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const versions = Array.isArray(v.versions) ? v.versions : [];
const cleaned = versions
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
.map((e) => {
const id = typeof e.id === "string" ? e.id.trim() : "";
const version = typeof e.version === "string" ? e.version.trim() : "";
const message = typeof e.message === "string" ? e.message : "";
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
const d = new Date(dateISO);
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
return {
id: id || null,
version: version ? version.slice(0, 80) : null,
message: String(message || "").slice(0, 500),
dateISO: safeDateISO,
};
})
.filter((e) => e.id && e.version)
.sort((a, b) => {
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
return bd - ad;
});
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
return {
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
versions: cleaned,
};
}
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
// Initialize from latest movie year if available, else from current year.
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
.sort({ year: -1 })
.select("year");
const fallbackYear = latestMovie?.year || new Date().getFullYear();
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return year;
}
// Public: get current active Oscar year used by the site.
router.get("/year", async (req, res) => {
try {
const year = await getOrInitActiveYear();
return res.status(200).json({ year });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Public: get Oscar date for a given year (with fallback).
// - If no custom date is configured, fallback is March 15 of that year (legacy behavior).
router.get("/oscar-date", async (req, res) => {
try {
const year = parseOscarYear(req.query?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
const configured = dates[String(year)] || null;
const effectiveDate = configured || `${String(year)}-03-15`;
return res.status(200).json({
year,
date: configured, // configured value (null if unset)
effectiveDate, // always present
});
} catch (err) {
// Even if DB is down, keep the legacy fallback.
const year = parseOscarYear(req.query?.year);
if (year) {
return res.status(200).json({ year, date: null, effectiveDate: `${String(year)}-03-15` });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get all configured Oscar dates.
router.get("/oscar-dates", async (req, res) => {
try {
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
return res.status(200).json({ dates });
} catch (err) {
return res.status(200).json({ dates: {} });
}
});
// Admin-only: set/clear Oscar date for a given year.
// Body: { date: "YYYY-MM-DD" } or { date: "" } to clear.
router.put("/oscar-date/: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 rawDate = req.body?.date;
const date = parseIsoDateString(rawDate);
const wantsClear = rawDate === "" || rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim() === "");
if (!wantsClear && !date) {
return res.status(400).json({ message: "Date invalide (format attendu: YYYY-MM-DD)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
if (wantsClear) {
delete dates[String(year)];
} else {
dates[String(year)] = date;
}
await Setting.findOneAndUpdate(
{ key: OSCAR_DATES_BY_YEAR_KEY },
{ $set: { value: dates } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, date: dates[String(year)] || 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 });
}
});
// Admin-only: set current active Oscar year.
router.put("/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.body?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const updated = await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return res.status(200).json({ year: updated.value });
} 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 });
}
});
// Public: get the "100% completion" modal content for checklist.
router.get("/completion-modal", async (req, res) => {
try {
const existing = await Setting.findOne({ key: COMPLETION_MODAL_KEY }).select("value");
const normalized = normalizeCompletionModal(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
// If DB is down, still return a sane default so the client can render.
return res.status(200).json(DEFAULT_COMPLETION_MODAL);
}
});
// Admin-only: set the "100% completion" modal content for checklist.
router.put("/completion-modal", 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 normalized = normalizeCompletionModal(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: COMPLETION_MODAL_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizeCompletionModal(updated?.value));
} 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 });
}
});
// 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)
.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);
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 });
}
});
// Public: get app version control (active + list)
router.get("/app-version", async (req, res) => {
try {
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const active = control.activeId
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
: null;
return res.status(200).json({ active, versions: control.versions });
} catch (_) {
return res.status(200).json({ active: null, versions: [] });
}
});
// Public: get player admin status UI flags (source/progress visibility)
router.get("/player-admin-status-ui", async (req, res) => {
try {
const existing = await Setting.findOne({ key: PLAYER_ADMIN_STATUS_UI_KEY }).select("value");
const normalized = normalizePlayerAdminStatusUi(existing?.value);
return res.status(200).json(normalized);
} catch (_) {
return res.status(200).json(DEFAULT_PLAYER_ADMIN_STATUS_UI);
}
});
// Admin-only: update player admin status UI flags
// Body: { showSource?: boolean, showProgress?: boolean }
router.put("/player-admin-status-ui", 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 normalized = normalizePlayerAdminStatusUi(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: PLAYER_ADMIN_STATUS_UI_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizePlayerAdminStatusUi(updated?.value));
} 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 });
}
});
// Admin-only: create a new version entry (optionally activates if none or if activate=true)
// Body: { version: "1.2.3", message?: "..." }
router.post("/app-version", 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 version = typeof req.body?.version === "string" ? req.body.version.trim() : "";
const message = typeof req.body?.message === "string" ? req.body.message : "";
if (!version) return res.status(400).json({ message: "Version requise (ex: 1.0.0)" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const entry = {
id: createId(),
version: version.slice(0, 80),
message: String(message || "").slice(0, 500),
dateISO: new Date().toISOString(),
};
const next = {
activeId: control.activeId,
versions: [entry, ...(Array.isArray(control.versions) ? control.versions : [])],
};
const wantsActivate = String(req.query?.activate || "").toLowerCase() === "true";
if (!next.activeId || wantsActivate) {
next.activeId = entry.id;
}
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} 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 });
}
});
// Admin-only: set active version by id
// Body: { id: "<versionId>" }
router.put("/app-version/active", 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 id = typeof req.body?.id === "string" ? req.body.id.trim() : "";
if (!id) return res.status(400).json({ message: "id requis" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const exists = control.versions.find((v) => String(v.id) === String(id));
if (!exists) return res.status(404).json({ message: "Version introuvable" });
const next = { activeId: String(id), versions: control.versions };
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} 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 });
}
});
// Admin-only: delete a version entry by id (re-picks active if needed)
router.delete("/app-version/:id", 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 id = typeof req.params?.id === "string" ? req.params.id.trim() : "";
if (!id) return res.status(400).json({ message: "id requis" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const nextVersions = control.versions.filter((v) => String(v.id) !== String(id));
const next = {
activeId: control.activeId && String(control.activeId) === String(id) ? null : control.activeId,
versions: nextVersions,
};
if (!next.activeId && nextVersions.length) next.activeId = String(nextVersions[0].id);
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} 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 });
}
});
// 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: "<mongoId>", 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" });
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 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);
// Clear all 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, winners: [] });
}
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" });
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 },
{ $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 },
winners: winners[String(year)] || [],
});
} 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 });
}
});
// 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 });
}
});
function normalizePointsConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const pointsPerMovie = typeof v.pointsPerMovie === "number" && v.pointsPerMovie >= 0 ? Math.round(v.pointsPerMovie) : DEFAULT_POINTS_CONFIG.pointsPerMovie;
const pointsPerCorrectPick = typeof v.pointsPerCorrectPick === "number" && v.pointsPerCorrectPick >= 0 ? Math.round(v.pointsPerCorrectPick) : DEFAULT_POINTS_CONFIG.pointsPerCorrectPick;
return { pointsPerMovie, pointsPerCorrectPick };
}
function normalizeVisibilityConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showPicksButton = typeof v.showPicksButton === "boolean" ? v.showPicksButton : DEFAULT_VISIBILITY_CONFIG.showPicksButton;
const showBonPicksColumn = typeof v.showBonPicksColumn === "boolean" ? v.showBonPicksColumn : DEFAULT_VISIBILITY_CONFIG.showBonPicksColumn;
return { showPicksButton, showBonPicksColumn };
}
// Public: get points configuration
router.get("/points-config", async (req, res) => {
try {
const existing = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value");
const normalized = normalizePointsConfig(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
return res.status(200).json(DEFAULT_POINTS_CONFIG);
}
});
// Admin-only: set points configuration
router.put("/points-config", 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 normalized = normalizePointsConfig(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: POINTS_CONFIG_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizePointsConfig(updated?.value));
} 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 });
}
});
// Public: get visibility configuration
router.get("/visibility-config", async (req, res) => {
try {
const existing = await Setting.findOne({ key: VISIBILITY_CONFIG_KEY }).select("value");
const normalized = normalizeVisibilityConfig(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
return res.status(200).json(DEFAULT_VISIBILITY_CONFIG);
}
});
// Admin-only: set visibility configuration
router.put("/visibility-config", 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 normalized = normalizeVisibilityConfig(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: VISIBILITY_CONFIG_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizeVisibilityConfig(updated?.value));
} 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;