feat: Manage app version from admin panel

Removes GitHub dependency for app version display.

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-21 22:15:15 +00:00
co-authored by mathieumoran1
parent ad211b11bb
commit f5bd8709a0
5 changed files with 648 additions and 84 deletions
+199
View File
@@ -12,6 +12,9 @@ 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 crypto = require("crypto");
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: "Félicitations! very nice 🎉🎉🎉",
@@ -143,6 +146,51 @@ function normalizeCompletionModal(rawValue) {
};
}
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;
@@ -369,6 +417,157 @@ router.get("/winners", async (req, res) => {
}
});
// 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: [] });
}
});
// 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 }
+81 -81
View File
@@ -1,29 +1,23 @@
const axios = require("axios");
const express = require("express");
const moment = require("moment-timezone");
const Setting = require("../models/Setting");
const router = express.Router();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
const TIMEZONE = "America/New_York";
// Cache is stored in the exact response shape expected by the client.
let latestCommitCache = null;
// Avoid noisy logs when GitHub isn't configured or is temporarily unavailable.
let lastLogTime = 0;
const LOG_THROTTLE_MS = 60 * 60 * 1000; // 1h
// Stored in Mongo via Setting model (admin-controlled).
// Shape:
// {
// activeId: string|null,
// versions: [{ id, version, message, dateISO }]
// }
const APP_VERSION_KEY = "app_version_control";
function nowFormatted() {
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
}
function getGitHubConfig() {
const owner = typeof process.env.GITHUB_OWNER === "string" ? process.env.GITHUB_OWNER.trim() : "";
const repo = typeof process.env.GITHUB_REPO === "string" ? process.env.GITHUB_REPO.trim() : "";
return { owner, repo, configured: !!owner && !!repo };
}
function getPackageVersion() {
try {
// routes/version.js -> ../package.json
@@ -34,82 +28,88 @@ function getPackageVersion() {
}
}
function setFallbackCache(message, { configured } = { configured: false }) {
latestCommitCache = {
version: getPackageVersion(),
message: message || "Version info not available.",
author: "",
date: nowFormatted(),
configured: !!configured,
source: "fallback",
};
}
function normalizeAppVersionControl(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const versions = Array.isArray(v.versions) ? v.versions : [];
function logThrottled(...args) {
const t = Date.now();
if (t - lastLogTime < LOG_THROTTLE_MS) return;
lastLogTime = t;
// eslint-disable-next-line no-console
console.error(...args);
}
async function fetchLatestCommitFromGitHub() {
const { owner, repo, configured } = getGitHubConfig();
if (!configured) {
// Never attempt GitHub calls if not configured.
setFallbackCache("Version endpoint is not configured.", { configured: false });
return;
}
try {
const url = `https://api.github.com/repos/${owner}/${repo}/commits`;
const response = await axios.get(url, {
headers: {
"Content-Type": "application/json",
},
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.slice(0, 500) : "";
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 || null,
message: String(message || ""),
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;
});
if (response.data && response.data.length > 0) {
const latestCommit = response.data[0];
latestCommitCache = {
version: String(latestCommit.sha || "").substring(0, 5),
message: latestCommit?.commit?.message || "",
author: latestCommit?.commit?.author?.name || "",
date: moment(latestCommit?.commit?.author?.date || Date.now())
.tz(TIMEZONE)
.format("YYYY-MM-DD HH:mm:ss"),
configured: true,
source: "github",
};
} else {
throw new Error("No commits found.");
}
} catch (error) {
// Keep serving a non-500 response, and don't spam logs.
logThrottled("Error fetching commit:", error?.message || error);
setFallbackCache("GitHub commit data is not available.", { configured: true });
}
}
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
// Initialize cache (without crashing / spamming when not configured).
const initialCfg = getGitHubConfig();
if (!initialCfg.configured) {
setFallbackCache("Version endpoint is not configured.", { configured: false });
} else {
fetchLatestCommitFromGitHub();
const timer = setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
// Don't keep the process alive just for this polling.
if (typeof timer.unref === "function") timer.unref();
// If activeId is invalid, fall back to newest entry.
return {
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
versions: cleaned,
};
}
// Route to get the latest commit (cached)
router.get("/", async (req, res) => {
// Always return something (client clears footer on non-2xx).
if (!latestCommitCache) {
const cfg = getGitHubConfig();
setFallbackCache("Version info not available.", { configured: cfg.configured });
// Always return 200 and never rely on GitHub.
try {
const setting = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(setting?.value);
const active = control.activeId
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
: null;
if (!active) {
return res.status(200).json({
version: getPackageVersion(),
message: "Version non configurée (définis-la dans Admin → Version).",
author: "",
date: nowFormatted(),
configured: false,
source: "fallback",
});
}
const date = active.dateISO
? moment(active.dateISO).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss")
: nowFormatted();
return res.status(200).json({
version: active.version,
message: active.message || "",
author: "",
date,
configured: true,
source: "admin",
});
} catch (_) {
// If DB is down, still return something useful.
return res.status(200).json({
version: getPackageVersion(),
message: "Version indisponible (DB).",
author: "",
date: nowFormatted(),
configured: false,
source: "fallback",
});
}
return res.status(200).json(latestCommitCache);
});
module.exports = router;