`,
- });
+ });
+ } 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
diff --git a/backend/src/controllers/employee.controller.js b/backend/src/controllers/employee.controller.js
index 7dff989..33d8468 100644
--- a/backend/src/controllers/employee.controller.js
+++ b/backend/src/controllers/employee.controller.js
@@ -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.",
});
diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js
index 31f3345..9725af2 100644
--- a/backend/src/controllers/game.controller.js
+++ b/backend/src/controllers/game.controller.js
@@ -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: `
+
+
Bravo ${req.user.firstname} !
+
Votre code ${result.ticket.code} est gagnant. Vous remportez :
+
+ ${result.ticket.lot.name}
+
+ ${result.ticket.lot.description ? `
${result.ticket.lot.description}
` : ""}
+
Présentez-vous en boutique avec ce code pour retirer votre lot${
+ gameWindow.claimDeadline
+ ? `, avant le ${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}`
+ : ""
+ }.
+
Retrouvez à tout moment vos gains dans votre espace « Mes gains ».
+
+ `,
+ });
+ } 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,
})),
});
diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js
index e5ee7cf..acd0e54 100644
--- a/backend/src/routes/admin.routes.js
+++ b/backend/src/routes/admin.routes.js
@@ -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;
\ No newline at end of file
diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js
index 26a5b03..69c4eaa 100644
--- a/backend/src/routes/game.routes.js
+++ b/backend/src/routes/game.routes.js
@@ -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;
\ No newline at end of file
diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js
index eaa34f1..65c19c1 100644
--- a/backend/src/utils/gameWindow.js
+++ b/backend/src/utils/gameWindow.js
@@ -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,
+};
diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js
index 6ea9852..c6771bf 100644
--- a/backend/src/validations/admin.validation.js
+++ b/backend/src/validations/admin.validation.js
@@ -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,
};
\ No newline at end of file
diff --git a/backend/tests/drawNotification.test.js b/backend/tests/drawNotification.test.js
new file mode 100644
index 0000000..80c26f9
--- /dev/null
+++ b/backend/tests/drawNotification.test.js
@@ -0,0 +1,106 @@
+const request = require("supertest");
+const app = require("../src/app");
+const prisma = require("../src/config/prisma");
+const jwt = require("jsonwebtoken");
+const bcrypt = require("bcrypt");
+const mailer = require("../src/config/mailer");
+
+jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
+
+const DAY = 86400000;
+const PREFIX = "notif-";
+const CODE = "NOTIFTICK1";
+let ctx = {};
+
+async function cleanup() {
+ const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
+ const ids = us.map((u) => u.id);
+ const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
+ await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
+ await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.ticket.deleteMany({ where: { code: CODE } });
+ await prisma.user.deleteMany({ where: { id: { in: ids } } });
+}
+
+const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
+const jourJ = (d) =>
+ prisma.gameSettings.update({
+ where: { id: ctx.settingsId },
+ data: { startDate: new Date(Date.now() - d * DAY) },
+ });
+
+beforeAll(async () => {
+ await cleanup();
+ const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
+ ctx.settingsId = row.id;
+ ctx.startDateOriginale = row.startDate;
+
+ const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
+ await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } });
+
+ const h = await bcrypt.hash("Password123", 10);
+ ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
+ ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
+ ctx.tAdmin = tk(ctx.admin);
+
+ await jourJ(1);
+ mailer.sendMail.mockResolvedValue(true);
+ await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201);
+ await jourJ(35);
+});
+
+afterAll(async () => {
+ await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
+ await cleanup();
+ await prisma.$disconnect();
+});
+
+describe("Notification du grand gagnant", () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ test("le gagnant recoit un email a son adresse", async () => {
+ mailer.sendMail.mockResolvedValue(true);
+
+ const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201);
+
+ expect(res.body.emailSent).toBe(true);
+ expect(mailer.sendMail).toHaveBeenCalledTimes(1);
+
+ const envoi = mailer.sendMail.mock.calls[0][0];
+ expect(envoi.to).toBe(ctx.client.email);
+ expect(envoi.subject).toMatch(/gros lot/i);
+ expect(envoi.html).toContain("Chloe");
+ expect(envoi.html).toContain("360");
+
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
+ });
+
+ test("un SMTP en panne n invalide pas le tirage", async () => {
+ mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
+
+ const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
+
+ expect(res.status).toBe(201);
+ expect(res.body.emailSent).toBe(false);
+ expect(res.body.message).toMatch(/contacter directement/);
+
+ // Le tirage reste enregistre : c est un acte irreversible.
+ const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } });
+ expect(enBase).not.toBeNull();
+
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
+ });
+
+ test("aucun email n est envoye si le tirage est refuse", async () => {
+ mailer.sendMail.mockResolvedValue(true);
+ await jourJ(1);
+
+ const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
+
+ expect(res.status).toBe(409);
+ expect(mailer.sendMail).not.toHaveBeenCalled();
+
+ await jourJ(35);
+ });
+});
diff --git a/backend/tests/gainNotification.test.js b/backend/tests/gainNotification.test.js
new file mode 100644
index 0000000..5e3bf99
--- /dev/null
+++ b/backend/tests/gainNotification.test.js
@@ -0,0 +1,113 @@
+const request = require("supertest");
+const app = require("../src/app");
+const prisma = require("../src/config/prisma");
+const jwt = require("jsonwebtoken");
+const bcrypt = require("bcrypt");
+const mailer = require("../src/config/mailer");
+
+jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
+
+const DAY = 86400000;
+const PREFIX = "gain-";
+const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"];
+let ctx = {};
+
+async function cleanup() {
+ const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
+ const ids = us.map((u) => u.id);
+ const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
+ await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
+ await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
+ await prisma.user.deleteMany({ where: { id: { in: ids } } });
+}
+
+const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
+const jourJ = (d) =>
+ prisma.gameSettings.update({
+ where: { id: ctx.settingsId },
+ data: { startDate: new Date(Date.now() - d * DAY) },
+ });
+
+beforeAll(async () => {
+ await cleanup();
+ const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
+ ctx.settingsId = row.id;
+ ctx.startDateOriginale = row.startDate;
+
+ ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
+ for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } });
+
+ const h = await bcrypt.hash("Password123", 10);
+ ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
+ ctx.tClient = tk(ctx.client);
+
+ await jourJ(1);
+});
+
+afterAll(async () => {
+ await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
+ await cleanup();
+ await prisma.$disconnect();
+});
+
+describe("Notification du gain a chaque participation", () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ test("le joueur recoit un email decrivant son lot", async () => {
+ mailer.sendMail.mockResolvedValue(true);
+
+ const res = await request(app)
+ .post("/api/game/participate")
+ .set("Authorization", "Bearer " + ctx.tClient)
+ .send({ code: CODES[0] })
+ .expect(201);
+
+ expect(res.body.emailSent).toBe(true);
+ expect(mailer.sendMail).toHaveBeenCalledTimes(1);
+
+ const envoi = mailer.sendMail.mock.calls[0][0];
+ expect(envoi.to).toBe(ctx.client.email);
+ expect(envoi.subject).toContain(ctx.lot.name);
+ expect(envoi.html).toContain("Chloe");
+ expect(envoi.html).toContain(CODES[0]);
+ expect(envoi.html).toContain(ctx.lot.name);
+ });
+
+ test("un SMTP en panne n annule pas la participation", async () => {
+ mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
+
+ const res = await request(app)
+ .post("/api/game/participate")
+ .set("Authorization", "Bearer " + ctx.tClient)
+ .send({ code: CODES[1] });
+
+ expect(res.status).toBe(201);
+ expect(res.body.emailSent).toBe(false);
+
+ // Le gain reste acquis et consultable.
+ const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
+ expect(t.isUsed).toBe(true);
+
+ const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
+ expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true);
+ });
+
+ test("aucun email si le code est refuse", async () => {
+ mailer.sendMail.mockResolvedValue(true);
+
+ const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
+ expect(rejoue.status).toBe(409);
+
+ const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" });
+ expect(inconnu.status).toBe(404);
+
+ await jourJ(45);
+ const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
+ expect(horsDelai.status).toBe(410);
+ await jourJ(1);
+
+ expect(mailer.sendMail).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/tests/gameFlow.test.js b/backend/tests/gameFlow.test.js
new file mode 100644
index 0000000..040b603
--- /dev/null
+++ b/backend/tests/gameFlow.test.js
@@ -0,0 +1,163 @@
+const request = require("supertest");
+const app = require("../src/app");
+const prisma = require("../src/config/prisma");
+const jwt = require("jsonwebtoken");
+const bcrypt = require("bcrypt");
+
+jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
+
+const emails = { adm: "v-adm@test.local", emp: "v-emp@test.local", a: "v-a@test.local", b: "v-b@test.local" };
+const CODES = ["VERIFTICK1", "VERIFTICK2", "VERIFTICK3", "VERIFTICK4", "VERIFTICK5"];
+const DAY = 86400000;
+let ctx = {};
+const log = (...a) => console.log(" ", ...a);
+
+async function cleanup() {
+ const us = await prisma.user.findMany({ where: { email: { in: [...Object.values(emails), "v-r1@test.local", "v-r2@test.local"] } } });
+ const ids = us.map((u) => u.id);
+ const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
+ await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
+ await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
+ await prisma.user.deleteMany({ where: { id: { in: ids } } });
+}
+
+const setStart = (d) =>
+ prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
+
+beforeAll(async () => {
+ await cleanup();
+ ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
+ ctx.orig = ctx.settings.startDate;
+ const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
+ for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
+ const h = await bcrypt.hash("Password123", 10);
+ const mk = (e, r) => prisma.user.create({ data: { firstname: "V", lastname: r, email: e, password: h, role: r, isEmailVerified: true } });
+ ctx.adm = await mk(emails.adm, "ADMIN");
+ ctx.emp = await mk(emails.emp, "EMPLOYEE");
+ ctx.a = await mk(emails.a, "CLIENT");
+ ctx.b = await mk(emails.b, "CLIENT");
+ const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
+ ctx.tadm = tk(ctx.adm);
+ ctx.temp = tk(ctx.emp);
+ ctx.ta = tk(ctx.a);
+ ctx.tb = tk(ctx.b);
+});
+
+afterAll(async () => {
+ await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
+ await cleanup();
+ await prisma.$disconnect();
+});
+
+describe("VERIFICATION DES 7 CORRECTIFS", () => {
+ test("#1 ADMIN peut lancer le jeu", async () => {
+ const futur = new Date(Date.now() + 5 * DAY).toISOString();
+ const r = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: futur });
+ log("PUT game-settings ->", r.status, r.body.message);
+ expect(r.status).toBe(200);
+
+ const g = await request(app).get("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).expect(200);
+ log("GET game-settings -> participation ouverte :", g.body.gameWindow.isParticipationOpen, "(jeu pas encore commence)");
+
+ const bad = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: "pas-une-date" });
+ log("date invalide ->", bad.status, bad.body.message);
+ expect(bad.status).toBe(400);
+
+ const nonAdmin = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.ta}`).send({ startDate: futur });
+ log("client tente de lancer le jeu ->", nonAdmin.status);
+ expect(nonAdmin.status).toBe(403);
+ });
+
+ test("#2 participation refusee apres 30 jours", async () => {
+ await setStart(45);
+ const r = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
+ log("jour 45 ->", r.status, r.body.message);
+ expect(r.status).toBe(410);
+
+ await setStart(1);
+ const ok = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
+ log("jour 1 ->", ok.status, ok.body.message || "accepte");
+ expect(ok.status).toBe(201);
+ ctx.claimId = ok.body.claim.id;
+ });
+
+ test("#3 expiration derivee et reversible", async () => {
+ await setStart(70);
+ const g1 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
+ log("jour 70, statut affiche :", g1.body.gains.map((g) => g.claimStatus).join(","));
+ expect(g1.body.gains[0].claimStatus).toBe("CANCELLED");
+
+ const enBase = await prisma.claim.findUnique({ where: { id: ctx.claimId } });
+ log("statut REEL en base :", enBase.status, "(non ecrase)");
+ expect(enBase.status).toBe("PENDING");
+
+ await setStart(1);
+ const g2 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
+ log("retour jour 1, statut :", g2.body.gains.map((g) => g.claimStatus).join(","), "(reversible)");
+ expect(g2.body.gains[0].claimStatus).toBe("PENDING");
+ });
+
+ test("#4 tirage refuse pendant le jeu", async () => {
+ await setStart(1);
+ const r = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
+ log("tirage pendant le jeu ->", r.status, r.body.message);
+ expect(r.status).toBe(409);
+
+ await setStart(35);
+ const r2 = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
+ log("tirage apres cloture ->", r2.status, r2.body.message, r2.body.totalEligibleParticipants ? `(${r2.body.totalEligibleParticipants} eligibles)` : "");
+ expect(r2.status).toBe(201);
+
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: [ctx.a.id, ctx.b.id] } } });
+ await setStart(1);
+ });
+
+ test("#5 admin et employe ne peuvent plus jouer", async () => {
+ const r1 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tadm}`).send({ code: CODES[1] });
+ const r2 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.temp}`).send({ code: CODES[1] });
+ log("ADMIN ->", r1.status, r1.body.message);
+ log("EMPLOYE ->", r2.status, r2.body.message);
+ expect(r1.status).toBe(403);
+ expect(r2.status).toBe(403);
+
+ const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
+ log("le ticket reste vierge :", t.isUsed === false);
+ expect(t.isUsed).toBe(false);
+ });
+
+ test("#6 suppression de compte ne remet pas le ticket en jeu", async () => {
+ await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tb}`).send({ code: CODES[2] }).expect(201);
+ await request(app).delete("/api/auth/account").set("Authorization", `Bearer ${ctx.tb}`).expect(200);
+
+ const t = await prisma.ticket.findUnique({ where: { code: CODES[2] } });
+ log("apres suppression : isUsed =", t.isUsed, "| userId =", t.userId);
+ expect(t.isUsed).toBe(true);
+
+ const rejoue = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[2] });
+ log("tentative de rejouer le code ->", rejoue.status, rejoue.body.message);
+ expect(rejoue.status).toBe(409);
+ });
+
+ test("#7 course sur un meme code", async () => {
+ const h = await bcrypt.hash("Password123", 10);
+ const c1 = await prisma.user.create({ data: { firstname: "R", lastname: "1", email: "v-r1@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
+ const c2 = await prisma.user.create({ data: { firstname: "R", lastname: "2", email: "v-r2@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
+ const t1 = jwt.sign({ id: c1.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
+ const t2 = jwt.sign({ id: c2.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
+
+ const [r1, r2] = await Promise.all([
+ request(app).post("/api/game/participate").set("Authorization", `Bearer ${t1}`).send({ code: CODES[3] }),
+ request(app).post("/api/game/participate").set("Authorization", `Bearer ${t2}`).send({ code: CODES[3] }),
+ ]);
+
+ const codes = [r1.status, r2.status].sort();
+ log("statuts obtenus :", codes.join(" + "), "(201 + 409 attendu, aucun 500)");
+ expect(codes).toEqual([201, 409]);
+
+ const n = await prisma.participation.count({ where: { ticket: { code: CODES[3] } } });
+ log("participations pour ce ticket :", n);
+ expect(n).toBe(1);
+ });
+});
diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js
new file mode 100644
index 0000000..8f87ea0
--- /dev/null
+++ b/backend/tests/gamePhases.test.js
@@ -0,0 +1,145 @@
+const request = require("supertest");
+const app = require("../src/app");
+const prisma = require("../src/config/prisma");
+const jwt = require("jsonwebtoken");
+const bcrypt = require("bcrypt");
+
+jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
+
+const DAY = 86400000;
+const PREFIX = "r2-";
+const CODES = ["R2TICKET01", "R2TICKET02", "R2TICKET03", "R2TICKET04"];
+let ctx = {};
+const log = (...a) => console.log(" ", ...a);
+const titre = (t) => console.log("\n===== " + t + " =====");
+
+async function cleanup() {
+ const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
+ const ids = us.map((u) => u.id);
+ const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
+ await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
+ await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
+ await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
+ await prisma.user.deleteMany({ where: { id: { in: ids } } });
+}
+
+async function jourJ(offsetJours) {
+ const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
+ const debut = new Date(Date.now() - offsetJours * DAY);
+ if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: debut } });
+ else await prisma.gameSettings.create({ data: { startDate: debut } });
+}
+
+const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
+
+beforeAll(async () => {
+ await cleanup();
+ const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
+ ctx.startDateOriginale = row ? row.startDate : null;
+
+ const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
+ for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
+
+ const h = await bcrypt.hash("Password123", 10);
+ ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
+ ctx.client = await prisma.user.create({ data: { firstname: "Zoe", lastname: "Cliente", email: PREFIX + "zoe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
+ ctx.tAdmin = tk(ctx.admin);
+ ctx.tClient = tk(ctx.client);
+});
+
+afterAll(async () => {
+ const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
+ if (ctx.startDateOriginale) {
+ if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
+ else await prisma.gameSettings.create({ data: { startDate: ctx.startDateOriginale } });
+ }
+ await cleanup();
+ await prisma.$disconnect();
+});
+
+describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
+
+ test("A. rien nest jouable avant tout lancement", async () => {
+ titre("A : aucune GameSettings en base");
+ await prisma.gameSettings.deleteMany({});
+
+ const vue = await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
+ log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
+ expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
+
+ const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
+ log("un client tente de jouer ->", essai.status, essai.body.message);
+ expect(essai.status).toBe(410);
+
+ const t = await prisma.ticket.findUnique({ where: { code: CODES[0] } });
+ log("le ticket reste vierge :", t.isUsed === false);
+ expect(t.isUsed).toBe(false);
+
+ const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
+ log("admin tente le tirage ->", tirage.status, tirage.body.message);
+ expect(tirage.status).toBe(409);
+ expect(await prisma.grandPrizeWinner.count()).toBe(0);
+ });
+
+ test("B. jeu programme : message juste et tirage bloque", async () => {
+ titre("B : lancement programme a J+7");
+ const futur = new Date(Date.now() + 7 * DAY).toISOString();
+ const r = await request(app).put("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).send({ startDate: futur });
+ log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
+ expect(r.body.gameWindow.phase).toBe("SCHEDULED");
+
+ const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
+ log("client joue avant ouverture ->", anticipe.status);
+ log("message :", JSON.stringify(anticipe.body.message));
+ expect(anticipe.body.message).toMatch(/ouvrira le/);
+ expect(anticipe.body.message).not.toMatch(/terminée/);
+
+ const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
+ log("tirage avant ouverture ->", tirage.status, tirage.body.message);
+ expect(tirage.status).toBe(409);
+ expect(tirage.body.message).toMatch(/pas encore commencé/);
+ });
+
+ test("C. les quatre phases dun jeu configure", async () => {
+ titre("C : parcours des phases");
+
+ await jourJ(1);
+ let w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
+ const ok = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
+ log("jour 1 | phase", w.phase, "| participate ->", ok.status);
+ expect(w.phase).toBe("OPEN");
+ expect(ok.status).toBe(201);
+ ctx.claimId = ok.body.claim.id;
+
+ await jourJ(35);
+ w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
+ const tard = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[1] });
+ log("jour 35 | phase", w.phase, "| participate ->", tard.status, JSON.stringify(tard.body.message));
+ expect(w.phase).toBe("CLAIM_ONLY");
+ expect(tard.body.message).toMatch(/participation est terminée/);
+
+ const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
+ log("tirage apres cloture ->", tirage.status, tirage.body.message);
+ expect(tirage.status).toBe(201);
+
+ await jourJ(70);
+ w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
+ const fini = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
+ log("jour 70 | phase", w.phase, "| participate ->", fini.status, JSON.stringify(fini.body.message));
+ expect(w.phase).toBe("CLOSED");
+ expect(fini.body.message).toMatch(/jeu-concours est terminé/);
+ });
+
+ test("D. non-regression : le parcours nominal fonctionne toujours", async () => {
+ titre("D : verification du parcours de base");
+ await jourJ(2);
+ const g = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
+ log("gains du client :", JSON.stringify(g.body.gains.map((x) => x.claimStatus)));
+ expect(g.body.gains[0].claimStatus).toBe("PENDING");
+
+ const stats = await request(app).get("/api/admin/stats").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
+ log("stats : phase =", stats.body.gameWindow.phase, "| participations =", stats.body.participations);
+ expect(stats.body.gameWindow.phase).toBe("OPEN");
+ });
+});
diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js
new file mode 100644
index 0000000..463821c
--- /dev/null
+++ b/backend/tests/register.mail.test.js
@@ -0,0 +1,66 @@
+const request = require("supertest");
+const app = require("../src/app");
+const prisma = require("../src/config/prisma");
+const mailer = require("../src/config/mailer");
+
+jest.mock("../src/config/mailer", () => ({
+ sendMail: jest.fn(),
+}));
+
+// Comme le reste de la suite, ce fichier tourne sur la base de dev : il ne
+// touche qu'au compte qu'il crée lui-même, identifié par cet email.
+const testEmail = "rollback-mail-test@test.com";
+
+const payload = {
+ firstname: "Camille",
+ lastname: "Martin",
+ email: testEmail,
+ password: "Password123",
+ birthdate: "1990-05-14",
+ gender: "Femme",
+};
+
+beforeEach(async () => {
+ jest.clearAllMocks();
+ await prisma.user.deleteMany({ where: { email: testEmail } });
+});
+
+afterAll(async () => {
+ await prisma.user.deleteMany({ where: { email: testEmail } });
+ await prisma.$disconnect();
+});
+
+describe("Inscription - echec d'envoi du code de validation", () => {
+ test("repond 502 et ne laisse aucun compte orphelin", async () => {
+ mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
+
+ const res = await request(app).post("/api/auth/register").send(payload);
+
+ expect(res.statusCode).toBe(502);
+
+ const user = await prisma.user.findUnique({ where: { email: testEmail } });
+ expect(user).toBeNull();
+ });
+
+ test("l'email reste reutilisable apres un echec d'envoi", async () => {
+ mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible"));
+ const echec = await request(app).post("/api/auth/register").send(payload);
+ expect(echec.statusCode).toBe(502);
+
+ mailer.sendMail.mockResolvedValue(true);
+ const succes = await request(app).post("/api/auth/register").send(payload);
+
+ expect(succes.statusCode).toBe(201);
+ });
+
+ test("enregistre la date de naissance et le genre", async () => {
+ mailer.sendMail.mockResolvedValue(true);
+
+ const res = await request(app).post("/api/auth/register").send(payload);
+ expect(res.statusCode).toBe(201);
+
+ const user = await prisma.user.findUnique({ where: { email: testEmail } });
+ expect(user.gender).toBe("Femme");
+ expect(user.birthdate.toISOString()).toBe("1990-05-14T00:00:00.000Z");
+ });
+});
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
index 4272e81..521c00a 100644
--- a/frontend/src/pages/Admin.jsx
+++ b/frontend/src/pages/Admin.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
+import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
import { searchTicketApi, deliverClaimApi } from '../services/employee'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
@@ -13,6 +13,32 @@ function StatCard({ label, value, sub }) {
)
}
+// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
+// ne doit pas être confondu avec « retraits uniquement ».
+const PHASE_BADGES = {
+ NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' },
+ SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' },
+ OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' },
+ CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' },
+ CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' },
+}
+
+const PHASE_PANELS = {
+ NOT_CONFIGURED: 'bg-amber-50 border border-amber-200',
+ SCHEDULED: 'bg-indigo-50 border border-indigo-200',
+ OPEN: 'bg-white',
+ CLAIM_ONLY: 'bg-white',
+ CLOSED: 'bg-red-50 border border-red-200',
+}
+
+const PHASE_TITLES = {
+ NOT_CONFIGURED: 'Jeu-concours non lancé',
+ SCHEDULED: 'Jeu-concours programmé',
+ OPEN: 'Jeu-concours en cours',
+ CLAIM_ONLY: 'Participations closes, retraits en cours',
+ CLOSED: 'Jeu-concours terminé',
+}
+
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
@@ -31,6 +57,12 @@ export default function Admin() {
const [error, setError] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
+ // Lancement du jeu-concours
+ const [startDateInput, setStartDateInput] = useState('')
+ const [settingsSaving, setSettingsSaving] = useState(false)
+ const [settingsError, setSettingsError] = useState('')
+ const [settingsOk, setSettingsOk] = useState('')
+
// Remise de lot
const [ticketCode, setTicketCode] = useState('')
const [ticketResult, setTicketResult] = useState(null)
@@ -58,6 +90,25 @@ export default function Admin() {
.finally(() => setLoading(false))
}, [])
+ const handleSaveGameSettings = async (e) => {
+ e.preventDefault()
+ setSettingsError('')
+ setSettingsOk('')
+ setSettingsSaving(true)
+ try {
+ // L'input datetime-local renvoie une heure locale sans fuseau : on la
+ // convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
+ const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
+ setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
+ setSettingsOk(data.message)
+ setStartDateInput('')
+ } catch (err) {
+ if (!handleAuthError(err)) setSettingsError(err.message)
+ } finally {
+ setSettingsSaving(false)
+ }
+ }
+
const handleDraw = async () => {
setShowConfirm(false)
setDrawing(true)
@@ -164,21 +215,61 @@ export default function Admin() {
Tableau de bord Admin
{/* Fenêtre du jeu-concours */}
- {stats?.gameWindow?.configured && (
-
- Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
+ {PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
+ {stats.gameWindow.configured ? (
+
+ Début : {formatDate(stats.gameWindow.startDate)}
+
+ Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
+
+ Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
+
+ ) : (
+
+ Choisissez une date de début pour ouvrir le jeu-concours.
+
+ Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
+
+
+ {settingsError &&
{settingsError}
}
+ {settingsOk &&
{settingsOk}
}
)}
@@ -440,6 +531,13 @@ export default function Admin() {
Tirage effectué le {formatDate(winner.winner.createdAt)}
)}
+ {/* Signalé uniquement au retour du tirage : le GET du gagnant ne
+ renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */}
+ {winner?.emailSent === false && (
+
+ L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
+
+ )}
) : (
/* Pas encore de tirage */
diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx
index e931393..502b20d 100644
--- a/frontend/src/pages/Connexion.jsx
+++ b/frontend/src/pages/Connexion.jsx
@@ -132,9 +132,12 @@ export default function Connexion() {
onClick={handleGoogleLogin}
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
-
- G
-
+
Google
diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx
index 0edfa67..86f0861 100644
--- a/frontend/src/pages/Inscription.jsx
+++ b/frontend/src/pages/Inscription.jsx
@@ -3,6 +3,13 @@ import { Link, useNavigate } from 'react-router-dom'
import { registerApi, verifyCodeApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
+// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
+const AUJOURD_HUI = new Date().toISOString().split('T')[0]
+
+// Valeurs alignées sur le tableau de bord admin, qui compte exactement
+// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
+const GENRES = ['Femme', 'Homme', 'Autre']
+
export default function Inscription() {
const navigate = useNavigate()
const { login } = useAuth()
@@ -10,8 +17,18 @@ export default function Inscription() {
// Étape : 'form' | 'verify'
const [step, setStep] = useState('form')
const [email, setEmail] = useState('')
- const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' })
+ const [form, setForm] = useState({
+ email: '',
+ lastName: '',
+ firstName: '',
+ password: '',
+ birthdate: '',
+ gender: '',
+ })
const [acceptedTerms, setAcceptedTerms] = useState(false)
+ // Consentement marketing : distinct des CGU et décoché par défaut, il doit
+ // rester un choix libre et non une condition d'inscription.
+ const [marketingOptIn, setMarketingOptIn] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -40,6 +57,9 @@ export default function Inscription() {
firstname: form.firstName,
lastname: form.lastName,
password: form.password,
+ birthdate: form.birthdate,
+ gender: form.gender,
+ marketingOptIn,
})
setEmail(form.email)
setStep('verify')
@@ -181,6 +201,23 @@ export default function Inscription() {
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
+