ajout des champs date de naissance et gender sur l'inscription, modification de la permition de lancer le jeu concour par l'administrateur, envoie des mail a chaque lot remporter
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
const bcrypt = require("bcrypt");
|
||||
const crypto = require("crypto");
|
||||
const prisma = require("../config/prisma");
|
||||
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
|
||||
const transporter = require("../config/mailer");
|
||||
const { getGameWindow } = require("../utils/gameWindow");
|
||||
|
||||
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
||||
|
||||
@@ -34,7 +35,6 @@ function computeAgeStats(users) {
|
||||
|
||||
const getStats = async (req, res) => {
|
||||
try {
|
||||
await expireOverdueClaims();
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const [
|
||||
@@ -80,9 +80,11 @@ const getStats = async (req, res) => {
|
||||
ticketsUsed,
|
||||
ticketsRemaining: ticketsTotal - ticketsUsed,
|
||||
participations,
|
||||
claimsPending,
|
||||
// L'expiration n'est pas écrite en base : une fois la fenêtre de retrait
|
||||
// fermée, les PENDING restants comptent comme annulés.
|
||||
claimsPending: gameWindow.isOpen ? claimsPending : 0,
|
||||
claimsDelivered,
|
||||
claimsCancelled,
|
||||
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
|
||||
users,
|
||||
gameWindow,
|
||||
ageStats: computeAgeStats(clientsWithBirthdate),
|
||||
@@ -215,6 +217,31 @@ const createEmployee = async (req, res) => {
|
||||
|
||||
const drawWinner = async (req, res) => {
|
||||
try {
|
||||
// Le grand gagnant est tiré parmi tous les participants : le tirage n'a de
|
||||
// sens qu'une fois la période de participation close. Avant l'ouverture,
|
||||
// il n'existe aucun participant à départager.
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
if (!gameWindow.configured) {
|
||||
return res.status(409).json({
|
||||
message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!gameWindow.hasStarted) {
|
||||
return res.status(409).json({
|
||||
message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`,
|
||||
startDate: gameWindow.startDate,
|
||||
});
|
||||
}
|
||||
|
||||
if (gameWindow.isParticipationOpen) {
|
||||
return res.status(409).json({
|
||||
message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`,
|
||||
participationDeadline: gameWindow.participationDeadline,
|
||||
});
|
||||
}
|
||||
|
||||
const existingWinner = await prisma.grandPrizeWinner.findFirst({
|
||||
include: {
|
||||
user: {
|
||||
@@ -283,9 +310,41 @@ const drawWinner = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Le tirage est un acte irréversible réalisé sous contrôle d'huissier :
|
||||
// un email qui ne part pas ne doit surtout pas l'invalider. On signale
|
||||
// simplement l'échec pour que l'administrateur prévienne autrement.
|
||||
let emailSent = true;
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: savedWinner.user.email,
|
||||
subject: "Vous avez gagné le gros lot Thé Tip Top !",
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<h2 style="color:#006B6B">Félicitations ${savedWinner.user.firstname} !</h2>
|
||||
<p>Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu,
|
||||
et c'est vous qui remportez le gros lot :</p>
|
||||
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
|
||||
${savedWinner.prize}
|
||||
</div>
|
||||
<p>Notre équipe vous recontactera très prochainement pour convenir des
|
||||
modalités de remise de votre lot.</p>
|
||||
<p style="color:#888;font-size:13px">Merci de votre participation et à bientôt chez Thé Tip Top.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (mailError) {
|
||||
emailSent = false;
|
||||
console.error("Erreur envoi email au grand gagnant:", mailError);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Tirage au sort effectué avec succès.",
|
||||
message: emailSent
|
||||
? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email."
|
||||
: "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.",
|
||||
totalEligibleParticipants: participants.length,
|
||||
emailSent,
|
||||
winner: savedWinner,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -296,6 +355,52 @@ const drawWinner = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont
|
||||
// découlent les deux échéances. Une seule ligne de configuration existe, on
|
||||
// la met donc à jour si elle est déjà présente.
|
||||
const setGameSettings = async (req, res) => {
|
||||
try {
|
||||
const { startDate } = req.body;
|
||||
const parsedStartDate = new Date(startDate);
|
||||
|
||||
const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
|
||||
if (existing) {
|
||||
await prisma.gameSettings.update({
|
||||
where: { id: existing.id },
|
||||
data: { startDate: parsedStartDate },
|
||||
});
|
||||
} else {
|
||||
await prisma.gameSettings.create({ data: { startDate: parsedStartDate } });
|
||||
}
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
return res.json({
|
||||
message: existing
|
||||
? "Dates du jeu-concours mises à jour."
|
||||
: "Jeu-concours lancé avec succès.",
|
||||
gameWindow,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur configuration du jeu:", error);
|
||||
return res.status(500).json({
|
||||
message: "Erreur serveur lors de la configuration du jeu-concours.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getGameSettings = async (req, res) => {
|
||||
try {
|
||||
return res.json({ gameWindow: await getGameWindow() });
|
||||
} catch (error) {
|
||||
console.error("Erreur lecture configuration du jeu:", error);
|
||||
return res.status(500).json({
|
||||
message: "Erreur serveur lors de la lecture de la configuration.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getGrandPrizeWinner = async (req, res) => {
|
||||
try {
|
||||
const winner = await prisma.grandPrizeWinner.findFirst({
|
||||
@@ -329,4 +434,6 @@ module.exports = {
|
||||
createEmployee,
|
||||
drawWinner,
|
||||
getGrandPrizeWinner,
|
||||
setGameSettings,
|
||||
getGameSettings,
|
||||
};
|
||||
@@ -62,11 +62,17 @@ const register = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject: "Votre code de validation Thé Tip Top",
|
||||
html: `
|
||||
// Le code de validation est le seul moyen d'activer le compte. Si l'envoi
|
||||
// échoue, laisser l'utilisateur en base le bloquerait définitivement : il
|
||||
// ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email
|
||||
// déjà pris). On annule donc la création pour que la tentative reste
|
||||
// rejouable.
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject: "Votre code de validation Thé Tip Top",
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||
<p>Merci pour votre inscription sur Thé Tip Top.</p>
|
||||
@@ -77,7 +83,21 @@ const register = async (req, res) => {
|
||||
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
} catch (mailError) {
|
||||
console.error("Erreur envoi du code de validation:", mailError);
|
||||
|
||||
try {
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
} catch (rollbackError) {
|
||||
console.error("Echec de l'annulation de l'inscription:", rollbackError);
|
||||
}
|
||||
|
||||
return res.status(502).json({
|
||||
message:
|
||||
"Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Inscription réussie. Un code de validation a été envoyé par email.",
|
||||
@@ -435,10 +455,13 @@ const deleteAccount = async (req, res) => {
|
||||
// 4. Supprimer les participations
|
||||
await tx.participation.deleteMany({ where: { userId } });
|
||||
|
||||
// 5. Libérer les tickets utilisés par ce user
|
||||
// 5. Détacher les tickets du compte sans les remettre en jeu : un code
|
||||
// figure sur un ticket de caisse physique et n'est jouable qu'une
|
||||
// fois. Le réinitialiser permettrait de rejouer indéfiniment le même
|
||||
// code en supprimant puis recréant un compte.
|
||||
await tx.ticket.updateMany({
|
||||
where: { userId },
|
||||
data: { userId: null, isUsed: false, usedAt: null },
|
||||
data: { userId: null },
|
||||
});
|
||||
|
||||
// 6. Supprimer l'utilisateur
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const prisma = require("../config/prisma");
|
||||
const { expireOverdueClaims } = require("../utils/gameWindow");
|
||||
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||
|
||||
const getGainByTicketCode = async (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
|
||||
await expireOverdueClaims();
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { code: code.toUpperCase() },
|
||||
@@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => {
|
||||
},
|
||||
claim: {
|
||||
id: ticket.participation.claim.id,
|
||||
status: ticket.participation.claim.status,
|
||||
status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow),
|
||||
claimedAt: ticket.participation.claim.claimedAt,
|
||||
},
|
||||
});
|
||||
@@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const employeeId = req.user.id;
|
||||
|
||||
await expireOverdueClaims();
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const claim = await prisma.claim.findUnique({
|
||||
where: { id: Number(id) },
|
||||
@@ -87,7 +87,7 @@ const deliverClaim = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (claim.status === "CANCELLED") {
|
||||
if (effectiveClaimStatus(claim.status, gameWindow) === "CANCELLED") {
|
||||
return res.status(400).json({
|
||||
message: "Ce gain est expiré et ne peut plus être remis.",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
const prisma = require("../config/prisma");
|
||||
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
|
||||
const transporter = require("../config/mailer");
|
||||
const {
|
||||
getGameWindow,
|
||||
effectiveClaimStatus,
|
||||
participationClosedMessage,
|
||||
} = require("../utils/gameWindow");
|
||||
|
||||
// Levée par la transaction quand le ticket a été consommé entre-temps.
|
||||
class TicketDejaUtiliseError extends Error {}
|
||||
|
||||
const participate = async (req, res) => {
|
||||
try {
|
||||
@@ -7,9 +15,13 @@ const participate = async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
if (!gameWindow.isOpen) {
|
||||
|
||||
// Un code n'est jouable que pendant les 30 jours de jeu : ni avant
|
||||
// l'ouverture, ni pendant la prolongation réservée aux retraits.
|
||||
if (!gameWindow.isParticipationOpen) {
|
||||
return res.status(410).json({
|
||||
message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.",
|
||||
message: participationClosedMessage(gameWindow),
|
||||
phase: gameWindow.phase,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,16 +45,24 @@ const participate = async (req, res) => {
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const updatedTicket = await tx.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
// Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées
|
||||
// peuvent le franchir ensemble. On repasse donc par un update
|
||||
// conditionnel, atomique, qui ne touche le ticket que s'il est encore
|
||||
// libre — le perdant de la course obtient 0 ligne modifiée.
|
||||
const { count } = await tx.ticket.updateMany({
|
||||
where: { id: ticket.id, isUsed: false },
|
||||
data: {
|
||||
isUsed: true,
|
||||
usedAt: new Date(),
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
lot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) throw new TicketDejaUtiliseError();
|
||||
|
||||
const updatedTicket = await tx.ticket.findUnique({
|
||||
where: { id: ticket.id },
|
||||
include: { lot: true },
|
||||
});
|
||||
|
||||
const participation = await tx.participation.create({
|
||||
@@ -67,8 +87,41 @@ const participate = async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
// La participation est déjà enregistrée et consultable dans « Mes gains » :
|
||||
// un email qui ne part pas ne doit pas l'annuler ni consommer le ticket
|
||||
// pour rien. On se contente de signaler l'échec.
|
||||
let emailSent = true;
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: req.user.email,
|
||||
subject: `Vous avez gagné : ${result.ticket.lot.name} !`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<h2 style="color:#006B6B">Bravo ${req.user.firstname} !</h2>
|
||||
<p>Votre code <strong>${result.ticket.code}</strong> est gagnant. Vous remportez :</p>
|
||||
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
|
||||
${result.ticket.lot.name}
|
||||
</div>
|
||||
${result.ticket.lot.description ? `<p style="text-align:center;color:#666">${result.ticket.lot.description}</p>` : ""}
|
||||
<p>Présentez-vous en boutique avec ce code pour retirer votre lot${
|
||||
gameWindow.claimDeadline
|
||||
? `, avant le <strong>${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}</strong>`
|
||||
: ""
|
||||
}.</p>
|
||||
<p style="color:#888;font-size:13px">Retrouvez à tout moment vos gains dans votre espace « Mes gains ».</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (mailError) {
|
||||
emailSent = false;
|
||||
console.error("Erreur envoi email de gain:", mailError);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Participation enregistrée avec succès.",
|
||||
emailSent,
|
||||
gain: {
|
||||
name: result.ticket.lot.name,
|
||||
description: result.ticket.lot.description,
|
||||
@@ -80,6 +133,12 @@ const participate = async (req, res) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketDejaUtiliseError) {
|
||||
return res.status(409).json({
|
||||
message: "Ce code a déjà été utilisé.",
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Erreur participation:", error);
|
||||
return res.status(500).json({
|
||||
message: "Erreur serveur lors de la participation.",
|
||||
@@ -91,7 +150,7 @@ const myGains = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
await expireOverdueClaims();
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const participations = await prisma.participation.findMany({
|
||||
where: { userId },
|
||||
@@ -112,7 +171,9 @@ const myGains = async (req, res) => {
|
||||
lot: participation.lot.name,
|
||||
description: participation.lot.description,
|
||||
value: participation.lot.value,
|
||||
claimStatus: participation.claim?.status,
|
||||
claimStatus: participation.claim
|
||||
? effectiveClaimStatus(participation.claim.status, gameWindow)
|
||||
: undefined,
|
||||
wonAt: participation.createdAt,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -6,12 +6,14 @@ const {
|
||||
createEmployee,
|
||||
drawWinner,
|
||||
getGrandPrizeWinner,
|
||||
setGameSettings,
|
||||
getGameSettings,
|
||||
} = require("../controllers/admin.controller");
|
||||
|
||||
const authMiddleware = require("../middlewares/auth.middleware");
|
||||
const roleMiddleware = require("../middlewares/role.middleware");
|
||||
const validate = require("../middlewares/validate.middleware");
|
||||
const { createEmployeeSchema } = require("../validations/admin.validation");
|
||||
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -109,4 +111,44 @@ router.post("/draw", drawWinner);
|
||||
*/
|
||||
router.get("/draw", getGrandPrizeWinner);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/admin/game-settings:
|
||||
* get:
|
||||
* summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits)
|
||||
* tags:
|
||||
* - Admin
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Configuration courante du jeu-concours
|
||||
* put:
|
||||
* summary: Lance le jeu-concours ou corrige sa date de début
|
||||
* tags:
|
||||
* - Admin
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - startDate
|
||||
* properties:
|
||||
* startDate:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* example: 2026-09-01T00:00:00.000Z
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Jeu-concours lancé ou mis à jour
|
||||
* 400:
|
||||
* description: Date de début invalide
|
||||
*/
|
||||
router.get("/game-settings", getGameSettings);
|
||||
router.put("/game-settings", validate(gameSettingsSchema), setGameSettings);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require("express");
|
||||
const { participate, myGains } = require("../controllers/game.controller");
|
||||
const authMiddleware = require("../middlewares/auth.middleware");
|
||||
const roleMiddleware = require("../middlewares/role.middleware");
|
||||
const validate = require("../middlewares/validate.middleware");
|
||||
const { participateSchema } = require("../validations/game.validation");
|
||||
|
||||
@@ -20,7 +21,9 @@ const router = express.Router();
|
||||
* description: Participation enregistrée
|
||||
*/
|
||||
|
||||
router.post("/participate", authMiddleware, validate(participateSchema), participate);
|
||||
// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue
|
||||
// consommerait des tickets et fausserait les statistiques.
|
||||
router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
|
||||
* description: Liste des gains
|
||||
*/
|
||||
|
||||
router.get("/my-gains", authMiddleware, myGains);
|
||||
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,23 +1,41 @@
|
||||
const prisma = require("../config/prisma");
|
||||
|
||||
// Le concours traverse cinq états, et non deux : confondre « pas encore
|
||||
// ouvert » avec « terminé » produit des messages faux côté joueur et laisse
|
||||
// passer le tirage au sort avant le début du jeu.
|
||||
const PHASES = {
|
||||
NOT_CONFIGURED: "NOT_CONFIGURED",
|
||||
SCHEDULED: "SCHEDULED",
|
||||
OPEN: "OPEN",
|
||||
CLAIM_ONLY: "CLAIM_ONLY",
|
||||
CLOSED: "CLOSED",
|
||||
};
|
||||
|
||||
const GAME_DURATION_DAYS = 30;
|
||||
const CLAIM_EXTENSION_DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Fenêtre pendant laquelle un joueur peut à la fois tester son code et
|
||||
// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter
|
||||
// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré
|
||||
// comme non démarré et donc ouvert (comportement de dev par défaut).
|
||||
async function getGameWindow() {
|
||||
// Deux échéances distinctes à partir de startDate :
|
||||
// - participationDeadline (30 j) : dernier moment pour tester un code ;
|
||||
// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné.
|
||||
// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu.
|
||||
// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non
|
||||
// configuré et donc ouvert (comportement de dev par défaut).
|
||||
async function getGameWindow(now = new Date()) {
|
||||
const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
|
||||
// Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas :
|
||||
// rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut
|
||||
// reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé.
|
||||
if (!settings) {
|
||||
return {
|
||||
configured: false,
|
||||
phase: PHASES.NOT_CONFIGURED,
|
||||
startDate: null,
|
||||
participationDeadline: null,
|
||||
claimDeadline: null,
|
||||
isOpen: true,
|
||||
isParticipationOpen: false,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,26 +44,57 @@ async function getGameWindow() {
|
||||
settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS
|
||||
);
|
||||
|
||||
const hasStarted = now >= settings.startDate;
|
||||
const isParticipationOpen = hasStarted && now <= participationDeadline;
|
||||
const isOpen = hasStarted && now <= claimDeadline;
|
||||
|
||||
let phase;
|
||||
if (!hasStarted) phase = PHASES.SCHEDULED;
|
||||
else if (isParticipationOpen) phase = PHASES.OPEN;
|
||||
else if (isOpen) phase = PHASES.CLAIM_ONLY;
|
||||
else phase = PHASES.CLOSED;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
phase,
|
||||
startDate: settings.startDate,
|
||||
participationDeadline,
|
||||
claimDeadline,
|
||||
isOpen: new Date() <= claimDeadline,
|
||||
hasStarted,
|
||||
isParticipationOpen,
|
||||
isOpen,
|
||||
};
|
||||
}
|
||||
|
||||
// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre
|
||||
// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture
|
||||
// de claims, ce qui suffit pour ce projet.
|
||||
async function expireOverdueClaims() {
|
||||
const window = await getGameWindow();
|
||||
if (window.isOpen) return;
|
||||
|
||||
await prisma.claim.updateMany({
|
||||
where: { status: "PENDING" },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en
|
||||
// base : un PENDING dont le délai de retrait est passé est présenté comme
|
||||
// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée
|
||||
// suffit donc à rétablir les gains, et aucune lecture ne modifie la base.
|
||||
function effectiveClaimStatus(status, gameWindow) {
|
||||
if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED";
|
||||
return status;
|
||||
}
|
||||
|
||||
module.exports = { getGameWindow, expireOverdueClaims };
|
||||
// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque
|
||||
// phase a sa propre explication, avec la date utile quand elle existe.
|
||||
function participationClosedMessage(gameWindow) {
|
||||
switch (gameWindow.phase) {
|
||||
case PHASES.NOT_CONFIGURED:
|
||||
return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement.";
|
||||
case PHASES.SCHEDULED:
|
||||
return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`;
|
||||
case PHASES.CLAIM_ONLY:
|
||||
return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés.";
|
||||
default:
|
||||
return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.";
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGameWindow,
|
||||
effectiveClaimStatus,
|
||||
participationClosedMessage,
|
||||
PHASES,
|
||||
GAME_DURATION_DAYS,
|
||||
CLAIM_EXTENSION_DAYS,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({
|
||||
gender: z.string().optional(),
|
||||
});
|
||||
|
||||
const gameSettingsSchema = z.object({
|
||||
startDate: z
|
||||
.string()
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createEmployeeSchema,
|
||||
gameSettingsSchema,
|
||||
};
|
||||
Reference in New Issue
Block a user