merge: integrate Google OAuth and latest application fixes

This commit is contained in:
2026-08-20 23:01:39 +00:00
50 changed files with 2817 additions and 931 deletions

View File

@@ -1,5 +1,7 @@
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
require("dotenv").config();
const authRoutes = require("./routes/auth.routes");
@@ -9,11 +11,37 @@ const adminRoutes = require("./routes/admin.routes");
const swaggerUi = require("swagger-ui-express");
const swaggerSpec = require("./docs/swagger");
const { passport } = require("./config/passport");
const app = express();
app.use(cors());
const allowedOrigins = (process.env.FRONTEND_URL || "http://localhost:5173").split(",");
// La CSP par défaut de helmet bloque les scripts inline de Swagger UI :
// on l'applique partout sauf sur /api-docs, où une CSP dédiée (désactivée)
// est posée juste avant de monter Swagger, plus bas.
app.use((req, res, next) => {
if (req.path.startsWith("/api-docs")) return next();
return helmet()(req, res, next);
});
app.use(cors({ origin: allowedOrigins }));
app.use(express.json());
// OAuth stateless : initialize() suffit, aucune session n'est ouverte.
app.use(passport.initialize());
// Anti-bruteforce sur les routes d'authentification uniquement : 20
// tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API
// (participation au jeu, consultation des gains, etc.).
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: true,
legacyHeaders: false,
message: { message: "Trop de tentatives, réessayez dans quelques minutes." },
});
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
app.use("/api/auth/forgot-password", authLimiter);
// routes
app.use("/api/auth", authRoutes);
@@ -22,7 +50,7 @@ app.use("/api/employee", employeeRoutes);
app.use("/api/admin", adminRoutes);
// swagger
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// test route
app.get("/", (req, res) => {

View File

@@ -0,0 +1,136 @@
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const crypto = require("crypto");
const prisma = require("./prisma");
const transporter = require("./mailer");
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
// propose un dès l'inscription, via le même mécanisme de jeton que la
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de
// Google pour se connecter, et le formulaire de changement de son profil lui
// reste inaccessible.
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
async function sendPasswordSetupEmail(user) {
const token = crypto.randomBytes(32).toString("hex");
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken: token,
resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS),
},
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Définissez votre mot de passe Thé Tip Top",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
connecter de cette façon, ou définir un mot de passe pour accéder
également à votre compte sans passer par Google.</p>
<p style="text-align:center;padding:20px 0">
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Définir mon mot de passe
</a>
</p>
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
connexion Google continuera de fonctionner.</p>
</div>
`,
});
}
// Authentification stateless : le callback OAuth émet directement notre JWT,
// donc pas de session Express ni de serialize/deserialize à brancher.
const googleEnabled =
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
scope: ["profile", "email"],
},
async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) {
return done(null, false, {
message: "Aucune adresse email n'est associée à ce compte Google.",
});
}
// 1. Compte déjà lié à ce compte Google : connexion directe.
const byProvider = await prisma.user.findUnique({
where: {
provider_providerId: { provider: "GOOGLE", providerId: profile.id },
},
});
if (byProvider) return done(null, byProvider);
// 2. Un compte existe avec le même email : on le rattache à Google.
// L'email étant déjà validé par Google, on en profite pour lever
// la vérification si elle était en attente.
const byEmail = await prisma.user.findUnique({ where: { email } });
if (byEmail) {
const linked = await prisma.user.update({
where: { id: byEmail.id },
data: {
provider: "GOOGLE",
providerId: profile.id,
isEmailVerified: true,
emailVerifyToken: null,
emailVerifyExpires: null,
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
},
});
return done(null, linked);
}
// 3. Première connexion : création du compte.
const created = await prisma.user.create({
data: {
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
lastname: profile.name?.familyName || "",
email,
password: null,
provider: "GOOGLE",
providerId: profile.id,
avatarUrl: profile.photos?.[0]?.value || null,
isEmailVerified: true,
},
});
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
// est valide, et « mot de passe oublié » permet de rattraper.
try {
await sendPasswordSetupEmail(created);
} catch (mailError) {
console.error("Erreur envoi email de definition du mot de passe:", mailError);
}
return done(null, created);
} catch (error) {
return done(error);
}
}
)
);
}
module.exports = { passport, googleEnabled };

View File

@@ -1,6 +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+"];
@@ -33,7 +35,6 @@ function computeAgeStats(users) {
const getStats = async (req, res) => {
try {
await expireOverdueClaims();
const gameWindow = await getGameWindow();
const [
@@ -79,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),
@@ -214,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: {
@@ -234,6 +262,10 @@ const drawWinner = async (req, res) => {
});
}
// Un utilisateur = une entrée dans le pool, quel que soit son nombre de
// participations. On sélectionne des CLIENT distincts (pas des
// participations), donc avoir utilisé 10 tickets ou 1 seul ne change
// strictement rien aux chances de gagner le gros lot.
const participants = await prisma.user.findMany({
where: {
role: "CLIENT",
@@ -255,7 +287,11 @@ const drawWinner = async (req, res) => {
});
}
const randomIndex = Math.floor(Math.random() * participants.length);
// Tirage purement aléatoire et équiprobable parmi les participants
// distincts. crypto.randomInt (générateur cryptographiquement sûr,
// sans biais) plutôt que Math.random(), pour un tirage défendable
// devant l'huissier qui le supervise.
const randomIndex = crypto.randomInt(participants.length);
const winner = participants[randomIndex];
const savedWinner = await prisma.grandPrizeWinner.create({
@@ -274,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) {
@@ -287,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({
@@ -302,14 +416,8 @@ const getGrandPrizeWinner = async (req, res) => {
},
});
if (!winner) {
return res.status(404).json({
message: "Aucun gagnant du gros lot n'a encore été tiré.",
});
}
return res.json({
winner,
winner: winner ?? null,
});
} catch (error) {
console.error("Erreur récupération gagnant:", error);
@@ -326,4 +434,6 @@ module.exports = {
createEmployee,
drawWinner,
getGrandPrizeWinner,
setGameSettings,
getGameSettings,
};

View File

@@ -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.",
@@ -156,6 +176,14 @@ const login = async (req, res) => {
});
}
// Compte créé via un provider externe : aucun mot de passe à comparer.
if (!user.password) {
const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google";
return res.status(401).json({
message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`,
});
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
@@ -224,7 +252,10 @@ const forgotPassword = async (req, res) => {
},
});
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
// un clic depuis un email est une requête GET, qui tombait en 404.
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
@@ -255,7 +286,7 @@ const forgotPassword = async (req, res) => {
const resetPassword = async (req, res) => {
try {
const { token } = req.params;
const { password } = req.body;
const { password, confirmPassword } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({
@@ -263,6 +294,14 @@ const resetPassword = async (req, res) => {
});
}
// La confirmation est vérifiée côté serveur aussi : le contrôle du
// navigateur ne protège pas un appel direct à l'API.
if (confirmPassword !== undefined && password !== confirmPassword) {
return res.status(400).json({
message: "Les deux mots de passe ne correspondent pas.",
});
}
const user = await prisma.user.findFirst({
where: {
resetPasswordToken: token,
@@ -354,6 +393,50 @@ const verifyCode = async (req, res) => {
}
};
// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet
// le même JWT que la connexion classique et on renvoie le navigateur vers le
// front, qui récupère le token dans l'URL.
const oauthCallback = (req, res) => {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
try {
const user = req.user;
if (!user) {
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Connexion externe refusée."
)}`
);
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
const payload = Buffer.from(
JSON.stringify({
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
})
).toString("base64");
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
} catch (error) {
console.error("Erreur oauthCallback:", error);
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Erreur serveur lors de la connexion externe."
)}`
);
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
@@ -383,10 +466,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
@@ -415,6 +501,13 @@ const changePassword = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user.password) {
return res.status(400).json({
message:
"Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.",
});
}
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
@@ -440,4 +533,5 @@ module.exports = {
forgotPassword,
changePassword,
deleteAccount,
oauthCallback,
};

View File

@@ -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.",
});

View File

@@ -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,
})),
});

View File

@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
lastname: true,
email: true,
role: true,
provider: true,
password: true,
},
});
@@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => {
});
}
req.user = user;
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
// un simple booléen, qui suffit au profil pour proposer « définir » ou
// « modifier » son mot de passe.
const { password, ...rest } = user;
req.user = { ...rest, hasPassword: Boolean(password) };
next();
} catch (error) {
return res.status(401).json({

View File

@@ -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();
@@ -105,10 +107,48 @@ router.post("/draw", drawWinner);
* - bearerAuth: []
* responses:
* 200:
* description: Gagnant du gros lot
* 404:
* description: Aucun gagnant encore tiré
* description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
*/
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;

View File

@@ -1,8 +1,9 @@
const express = require("express");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
const { passport, googleEnabled } = require("../config/passport");
const router = express.Router();
@@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode);
router.patch("/change-password", authMiddleware, changePassword);
router.delete("/account", authMiddleware, deleteAccount);
/**
* @swagger
* /api/auth/google:
* get:
* summary: Démarre la connexion via Google (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Google.
* 503:
* description: Connexion Google non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/google/callback:
* get:
* summary: Retour de Google, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
if (googleEnabled) {
router.get(
"/google",
passport.authenticate("google", { scope: ["profile", "email"], session: false })
);
router.get(
"/google/callback",
passport.authenticate("google", {
session: false,
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
"Connexion Google refusée."
)}`,
}),
oauthCallback
);
} else {
// Sans identifiants configurés, on répond explicitement plutôt que de
// laisser passport planter au démarrage.
const notConfigured = (req, res) =>
res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." });
router.get("/google", notConfigured);
router.get("/google/callback", notConfigured);
}
module.exports = router;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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,
};