Convert backend to regular directory

This commit is contained in:
2026-06-04 20:16:41 +02:00
parent 9107c29304
commit be40cb4745
29 changed files with 4149 additions and 1 deletions

Submodule backend deleted from e766dcaa82

5
backend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
PORT=5000
DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="your_secret_key"

5
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.env
.prisma
coverage/
dist/

368
backend/README.md Normal file
View File

@@ -0,0 +1,368 @@
# Thé Tip Top Backend API
## Présentation
Ce projet correspond au backend du jeu-concours organisé par **Thé Tip Top** à l'occasion de l'ouverture de sa 10ᵉ boutique à Nice.
L'application permet aux clients de participer à un jeu-concours via un code présent sur leur ticket de caisse, de consulter leurs gains et de réclamer leurs lots en boutique.
Le backend expose une **API** **REST** sécurisée utilisée par :
- l'application client (participants)
- l'espace employé en boutique
- l'espace administrateur
- les futurs services externes de l'écosystème Thé Tip Top
---
## Fonctionnalités
### Authentification
- Inscription utilisateur
- Connexion sécurisée
- Authentification **JWT**
- Gestion des rôles (**CLIENT**, **EMPLOYEE**, **ADMIN**)
- Hachage des mots de passe avec Bcrypt
- Validation des données avec **Zod**
### Jeu-concours
- Génération automatique des lots
- Génération des tickets gagnants
- Participation via code ticket unique
- Attribution automatique des gains
- Historique des participations
- Historique des gains remportés
### Espace Employé
- Recherche d'un gain via un code ticket
- Vérification du statut du gain
- Validation de la remise d'un lot
- Mise à jour automatique du statut du gain
### Espace Administrateur
- Tableau de bord statistique
- Liste des participants
- Liste des contacts ayant accepté les communications marketing
- Création de comptes employés
- Tirage au sort du grand gagnant
- Consultation du gagnant du gros lot
### Documentation API
- Documentation interactive avec **Swagger UI**
- Génération automatique de la documentation via **Swagger JSDoc**
- Tests des endpoints directement depuis le navigateur
---
## Architecture Technique
### Backend
- Node.js
- Express.js
### Base de données
- PostgreSQL
- **Prisma **ORM** 6**
- Prisma Client 6
- Prisma Migrate
- Prisma Studio
### Validation des données
- **Zod**
- Middleware de validation personnalisé
### Sécurité
- **JWT** (**JSON** Web Token)
- Bcrypt
- Gestion des rôles et permissions
- Validation des entrées utilisateur
### Documentation
- Swagger UI
- Swagger JSDoc
- OpenAPI 3.0
---
## Installation
### Cloner le projet
```bash
git clone <url-du-repository>
cd the-tip-top-backend
```
### Installer les dépendances
```bash
npm install
```
### Variables d'environnement
Créer un fichier `.env` à la racine du projet :
```env **PORT**=**5000**```
```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*```
```JWT_SECRET=*your_secret_key" ```
---
## Base de données
### Générer les migrations
```bash
npx prisma migrate dev
```
### Générer Prisma Client
```bash
npx prisma generate
```
### Exécuter le seed de la base
```bash
npx prisma db seed
```
### Générer les tickets du jeu-concours
```bash
npm run generate:tickets
```
### Ouvrir Prisma Studio
```bash
npx prisma studio
```
---
## Technologies utilisées
| Technologie | Version |
| ----------- | ---------------- |
| Node.js | 20+ |
| Express.js | 5 |
| PostgreSQL | 16+ |
| Prisma ORM | 6 |
| JWT | Dernière version |
| Bcrypt | Dernière version |
| Zod | Dernière version |
| Swagger UI | Dernière version |
---
## Lancement du projet
### Développement
Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code.
```bash
npm run dev
```
Serveur :
```txt [http://localhost:**5000**](http://localhost:**5000**) ```
---
## Documentation API
La documentation Swagger est accessible à l'adresse :
```txt [http://localhost:**5000**/api-docs](http://localhost:**5000**/api-docs) ```
Fonctionnalités disponibles :
- Consultation de toutes les routes
- Visualisation des schémas de données
- Test des endpoints
- Authentification via **JWT** avec le bouton **Authorize**
---
## Gestion des rôles
### CLIENT
Peut :
- Participer au jeu-concours
- Consulter ses gains
- Consulter son profil
### EMPLOYEE
Peut :
- Rechercher un gain
- Vérifier les informations du gagnant
- Valider la remise d'un lot
### ADMIN
Peut :
- Accéder aux statistiques
- Créer des employés
- Gérer les campagnes emailing
- Effectuer le tirage au sort final
- Consulter le gagnant du gros lot
---
## Routes API
### Auth
| Méthode | Route |
| ------- | ------------------ |
| POST | /api/auth/register |
| POST | /api/auth/login |
| GET | /api/auth/me |
### Game
| Méthode | Route |
| ------- | --------------------- |
| POST | /api/game/participate |
| GET | /api/game/my-gains |
### Employee
| Méthode | Route |
| ------- | ---------------------------- |
| GET | /api/employee/ticket/ |
| PATCH | /api/employee/claim//deliver |
### Admin
| Méthode | Route |
| ------- | -------------------- |
| GET | /api/admin/stats |
| GET | /api/admin/users |
| GET | /api/admin/emailing |
| POST | /api/admin/employees |
| POST | /api/admin/draw |
| GET | /api/admin/draw |
---
## Validation avec Zod
Toutes les données reçues par l'**API** sont validées avant traitement grâce à **Zod**.
Exemples :
- Inscription utilisateur
- Connexion
- Création d'employés
- Participation au jeu-concours
- Validation des paramètres de requête
Cette approche garantit :
- Des données cohérentes
- Une meilleure sécurité
- Des messages d'erreur explicites
- Une maintenance facilitée
---
## Modèle de données principal
### User
- **CLIENT**
- **EMPLOYEE**
- **ADMIN**
### Ticket
- Code unique
- Gain associé
- Utilisé / Non utilisé
### Lot
- Nom
- Description
- Valeur
- Pourcentage de répartition
### Participation
- Utilisateur
- Ticket
- Lot gagné
### Claim
- **PENDING**
- **DELIVERED**
- **CANCELLED**
### GrandPrizeWinner
- Gagnant du gros lot
- Date du tirage
- Récompense
---
## Prisma ORM 6
Le projet utilise **Prisma **ORM** 6** pour :
- La modélisation des données
- Les migrations de base de données
- Les requêtes **SQL** typées
- La génération automatique du client Prisma
- L'administration de la base via Prisma Studio
Commandes utiles :
```bash
npx prisma migrate dev npx prisma generate npx prisma db seed npx prisma studio
```
---
## Règles métier
- Tous les tickets sont gagnants.
- Un ticket ne peut être utilisé qu'une seule fois.
- Les gains sont associés aux tickets dès leur génération.
- Plusieurs participations n'augmentent pas les chances de remporter le gros lot.
- Un seul gagnant du gros lot peut être enregistré.
- Les gains non récupérés dans les délais peuvent être annulés.
---
## Auteur
Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale**
Client fictif : **Thé Tip Top**.

2233
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "the-tip-top-backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "nodemon src/app.js",
"test": "echo \"Error: no test specified\" && exit 1",
"generate:tickets": "node src/scripts/generateTickets.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@prisma/client": "^6.19.3",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"nodemon": "^3.1.14"
},
"prisma": {
"seed": "node prisma/seed.js"
}
}

View File

@@ -0,0 +1,104 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('CLIENT', 'EMPLOYEE', 'ADMIN');
-- CreateEnum
CREATE TYPE "ClaimType" AS ENUM ('STORE', 'ONLINE');
-- CreateEnum
CREATE TYPE "ClaimStatus" AS ENUM ('PENDING', 'DELIVERED', 'CANCELLED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"firstname" TEXT NOT NULL,
"lastname" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'CLIENT',
"birthdate" TIMESTAMP(3),
"gender" TEXT,
"marketingOptIn" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lot" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"value" DOUBLE PRECISION,
"percentage" DOUBLE PRECISION NOT NULL,
CONSTRAINT "Lot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Ticket" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"isUsed" BOOLEAN NOT NULL DEFAULT false,
"usedAt" TIMESTAMP(3),
"lotId" INTEGER NOT NULL,
"userId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Participation" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"ticketId" INTEGER NOT NULL,
"lotId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Participation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Claim" (
"id" SERIAL NOT NULL,
"status" "ClaimStatus" NOT NULL DEFAULT 'PENDING',
"participationId" INTEGER NOT NULL,
"validatedByEmployeeId" INTEGER,
"claimedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Claim_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_code_key" ON "Ticket"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Participation_ticketId_key" ON "Participation"("ticketId");
-- CreateIndex
CREATE UNIQUE INDEX "Claim_participationId_key" ON "Claim"("participationId");
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "Lot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Participation" ADD CONSTRAINT "Participation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Participation" ADD CONSTRAINT "Participation_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Participation" ADD CONSTRAINT "Participation_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "Lot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Claim" ADD CONSTRAINT "Claim_participationId_fkey" FOREIGN KEY ("participationId") REFERENCES "Participation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Claim" ADD CONSTRAINT "Claim_validatedByEmployeeId_fkey" FOREIGN KEY ("validatedByEmployeeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "GrandPrizeWinner" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"prize" TEXT NOT NULL DEFAULT 'Un an de thé d''une valeur de 360€',
"drawnAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GrandPrizeWinner_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GrandPrizeWinner_userId_key" ON "GrandPrizeWinner"("userId");
-- AddForeignKey
ALTER TABLE "GrandPrizeWinner" ADD CONSTRAINT "GrandPrizeWinner_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,111 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
CLIENT
EMPLOYEE
ADMIN
}
enum ClaimType {
STORE
ONLINE
}
enum ClaimStatus {
PENDING
DELIVERED
CANCELLED
}
model User {
id Int @id @default(autoincrement())
firstname String
lastname String
email String @unique
password String
role Role @default(CLIENT)
birthdate DateTime?
gender String?
marketingOptIn Boolean @default(false)
createdAt DateTime @default(now())
tickets Ticket[]
participations Participation[]
grandPrizeWinner GrandPrizeWinner?
claimsValidated Claim[] @relation("EmployeeClaims")
}
model Lot {
id Int @id @default(autoincrement())
name String
description String?
value Float?
percentage Float
tickets Ticket[]
participations Participation[]
}
model Ticket {
id Int @id @default(autoincrement())
code String @unique
isUsed Boolean @default(false)
usedAt DateTime?
lotId Int
lot Lot @relation(fields: [lotId], references: [id])
userId Int?
user User? @relation(fields: [userId], references: [id])
participation Participation?
createdAt DateTime @default(now())
}
model Participation {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
ticketId Int @unique
ticket Ticket @relation(fields: [ticketId], references: [id])
lotId Int
lot Lot @relation(fields: [lotId], references: [id])
claim Claim?
createdAt DateTime @default(now())
}
model Claim {
id Int @id @default(autoincrement())
status ClaimStatus @default(PENDING)
participationId Int @unique
participation Participation @relation(fields: [participationId], references: [id])
validatedByEmployeeId Int?
validatedByEmployee User? @relation("EmployeeClaims", fields: [validatedByEmployeeId], references: [id])
claimedAt DateTime?
createdAt DateTime @default(now())
}
model GrandPrizeWinner {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id])
prize String @default("Un an de thé d'une valeur de 360€")
drawnAt DateTime @default(now())
}

51
backend/prisma/seed.js Normal file
View File

@@ -0,0 +1,51 @@
const prisma = require("../src/config/prisma");
async function main() {
await prisma.lot.deleteMany();
await prisma.lot.createMany({
data: [
{
name: "Infuseur à thé",
description: "Infuseur à thé offert",
value: 5,
percentage: 60,
},
{
name: "Boîte de 100g thé détox ou infusion",
description: "Boîte de 100g dun thé détox ou dune infusion",
value: 12,
percentage: 20,
},
{
name: "Boîte de 100g thé signature",
description: "Boîte de 100g dun thé signature Thé Tip Top",
value: 18,
percentage: 10,
},
{
name: "Coffret découverte 39€",
description: "Coffret découverte dune valeur de 39€",
value: 39,
percentage: 6,
},
{
name: "Coffret découverte 69€",
description: "Coffret découverte dune valeur de 69€",
value: 69,
percentage: 4,
},
],
});
console.log("Lots créés avec succès");
}
main()
.catch((error) => {
console.error("Erreur seed :", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

38
backend/src/app.js Normal file
View File

@@ -0,0 +1,38 @@
const express = require("express");
const cors = require("cors");
require("dotenv").config();
const authRoutes = require("./routes/auth.routes");
const gameRoutes = require("./routes/game.routes");
const employeeRoutes = require("./routes/employee.routes");
const adminRoutes = require("./routes/admin.routes");
const swaggerUi = require("swagger-ui-express");
const swaggerSpec = require("./docs/swagger");
const app = express();
app.use(cors());
app.use(express.json());
app.use("/api/auth", authRoutes);
app.use("/api/game", gameRoutes);
app.use("/api/employee", employeeRoutes);
app.use("/api/admin", adminRoutes);
app.use(
"/api-docs",
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
app.get("/", (req, res) => {
res.json({
message: "API Thé Tip Top opérationnelle",
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Serveur lancé sur http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,5 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
module.exports = prisma;

View File

@@ -0,0 +1,292 @@
const bcrypt = require("bcrypt");
const prisma = require("../config/prisma");
const getStats = async (req, res) => {
try {
const [
ticketsTotal,
ticketsUsed,
participations,
claimsPending,
claimsDelivered,
claimsCancelled,
users,
men,
women,
] = await Promise.all([
prisma.ticket.count(),
prisma.ticket.count({ where: { isUsed: true } }),
prisma.participation.count(),
prisma.claim.count({ where: { status: "PENDING" } }),
prisma.claim.count({ where: { status: "DELIVERED" } }),
prisma.claim.count({ where: { status: "CANCELLED" } }),
prisma.user.count({ where: { role: "CLIENT" } }),
prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }),
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
]);
const lotsStats = await prisma.lot.findMany({
select: {
id: true,
name: true,
percentage: true,
_count: {
select: {
tickets: true,
participations: true,
},
},
},
});
return res.json({
ticketsTotal,
ticketsUsed,
ticketsRemaining: ticketsTotal - ticketsUsed,
participations,
claimsPending,
claimsDelivered,
claimsCancelled,
users,
genderStats: {
men,
women,
other: users - men - women,
},
lotsStats,
});
} catch (error) {
console.error("Erreur stats admin:", error);
return res.status(500).json({
message: "Erreur serveur lors de la récupération des statistiques.",
});
}
};
const getUsers = async (req, res) => {
try {
const users = await prisma.user.findMany({
where: { role: "CLIENT" },
select: {
id: true,
firstname: true,
lastname: true,
email: true,
gender: true,
birthdate: true,
marketingOptIn: true,
createdAt: true,
_count: {
select: {
participations: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return res.json({ users });
} catch (error) {
console.error("Erreur liste utilisateurs:", error);
return res.status(500).json({
message: "Erreur serveur lors de la récupération des utilisateurs.",
});
}
};
const getEmailingContacts = async (req, res) => {
try {
const contacts = await prisma.user.findMany({
where: {
role: "CLIENT",
marketingOptIn: true,
},
select: {
firstname: true,
lastname: true,
email: true,
createdAt: true,
},
orderBy: {
createdAt: "desc",
},
});
return res.json({
total: contacts.length,
contacts,
});
} catch (error) {
console.error("Erreur emailing:", error);
return res.status(500).json({
message: "Erreur serveur lors de la récupération des contacts emailing.",
});
}
};
const createEmployee = async (req, res) => {
try {
const { firstname, lastname, email, password, birthdate, gender } = req.body;
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return res.status(409).json({
message: "Un compte existe déjà avec cet email.",
});
}
const hashedPassword = await bcrypt.hash(password, 10);
const employee = await prisma.user.create({
data: {
firstname,
lastname,
email,
password: hashedPassword,
birthdate: birthdate ? new Date(birthdate) : null,
gender,
role: "EMPLOYEE",
marketingOptIn: false,
},
select: {
id: true,
firstname: true,
lastname: true,
email: true,
role: true,
createdAt: true,
},
});
return res.status(201).json({
message: "Employé créé avec succès.",
employee,
});
} catch (error) {
console.error("Erreur création employé:", error);
return res.status(500).json({
message: "Erreur serveur lors de la création de l'employé.",
});
}
};
const drawWinner = async (req, res) => {
try {
const existingWinner = await prisma.grandPrizeWinner.findFirst({
include: {
user: {
select: {
id: true,
firstname: true,
lastname: true,
email: true,
},
},
},
});
if (existingWinner) {
return res.status(409).json({
message: "Le tirage au sort a déjà été effectué.",
winner: existingWinner,
});
}
const participants = await prisma.user.findMany({
where: {
role: "CLIENT",
participations: {
some: {},
},
},
select: {
id: true,
firstname: true,
lastname: true,
email: true,
},
});
if (participants.length === 0) {
return res.status(400).json({
message: "Aucun participant disponible pour le tirage.",
});
}
const randomIndex = Math.floor(Math.random() * participants.length);
const winner = participants[randomIndex];
const savedWinner = await prisma.grandPrizeWinner.create({
data: {
userId: winner.id,
},
include: {
user: {
select: {
id: true,
firstname: true,
lastname: true,
email: true,
},
},
},
});
return res.status(201).json({
message: "Tirage au sort effectué avec succès.",
totalEligibleParticipants: participants.length,
winner: savedWinner,
});
} catch (error) {
console.error("Erreur tirage au sort:", error);
return res.status(500).json({
message: "Erreur serveur lors du tirage au sort.",
});
}
};
const getGrandPrizeWinner = async (req, res) => {
try {
const winner = await prisma.grandPrizeWinner.findFirst({
include: {
user: {
select: {
id: true,
firstname: true,
lastname: true,
email: true,
},
},
},
});
if (!winner) {
return res.status(404).json({
message: "Aucun gagnant du gros lot n'a encore été tiré.",
});
}
return res.json({
winner,
});
} catch (error) {
console.error("Erreur récupération gagnant:", error);
return res.status(500).json({
message: "Erreur serveur lors de la récupération du gagnant.",
});
}
};
module.exports = {
getStats,
getUsers,
getEmailingContacts,
createEmployee,
drawWinner,
getGrandPrizeWinner,
};

View File

@@ -0,0 +1,134 @@
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const prisma = require("../config/prisma");
const register = async (req, res) => {
try {
const {
firstname,
lastname,
email,
password,
birthdate,
gender,
marketingOptIn,
} = req.body;
if (!firstname || !lastname || !email || !password) {
return res.status(400).json({
message: "Tous les champs obligatoires doivent être renseignés.",
});
}
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return res.status(409).json({
message: "Un compte existe déjà avec cet email.",
});
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
firstname,
lastname,
email,
password: hashedPassword,
birthdate: birthdate ? new Date(birthdate) : null,
gender,
marketingOptIn: marketingOptIn || false,
},
select: {
id: true,
firstname: true,
lastname: true,
email: true,
role: true,
marketingOptIn: true,
createdAt: true,
},
});
return res.status(201).json({
message: "Inscription réussie.",
user,
});
} catch (error) {
console.error("Erreur register:", error);
return res.status(500).json({
message: "Erreur serveur lors de l'inscription.",
});
}
};
const login = async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
message: "Email et mot de passe requis.",
});
}
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
return res.status(401).json({
message: "Identifiants invalides.",
});
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
message: "Identifiants invalides.",
});
}
const token = jwt.sign(
{
id: user.id,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
return res.json({
message: "Connexion réussie.",
token,
user: {
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
},
});
} catch (error) {
console.error("Erreur login:", error);
return res.status(500).json({
message: "Erreur serveur lors de la connexion.",
});
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
});
};
module.exports = {
register,
login,
me,
};

View File

@@ -0,0 +1,115 @@
const prisma = require("../config/prisma");
const getGainByTicketCode = async (req, res) => {
try {
const { code } = req.params;
const ticket = await prisma.ticket.findUnique({
where: { code: code.toUpperCase() },
include: {
lot: true,
user: {
select: {
id: true,
firstname: true,
lastname: true,
email: true,
},
},
participation: {
include: {
claim: true,
},
},
},
});
if (!ticket) {
return res.status(404).json({
message: "Ticket introuvable.",
});
}
if (!ticket.isUsed || !ticket.participation) {
return res.status(400).json({
message: "Ce ticket n'a pas encore été utilisé par un client.",
});
}
return res.json({
ticket: {
code: ticket.code,
usedAt: ticket.usedAt,
},
client: ticket.user,
lot: {
name: ticket.lot.name,
description: ticket.lot.description,
value: ticket.lot.value,
},
claim: {
id: ticket.participation.claim.id,
status: ticket.participation.claim.status,
claimedAt: ticket.participation.claim.claimedAt,
},
});
} catch (error) {
console.error("Erreur recherche gain employé:", error);
return res.status(500).json({
message: "Erreur serveur lors de la recherche du gain.",
});
}
};
const deliverClaim = async (req, res) => {
try {
const { id } = req.params;
const employeeId = req.user.id;
const claim = await prisma.claim.findUnique({
where: { id: Number(id) },
});
if (!claim) {
return res.status(404).json({
message: "Réclamation introuvable.",
});
}
if (claim.status === "DELIVERED") {
return res.status(409).json({
message: "Ce lot a déjà été remis.",
});
}
if (claim.status === "CANCELLED") {
return res.status(400).json({
message: "Ce gain est expiré et ne peut plus être remis.",
});
}
const updatedClaim = await prisma.claim.update({
where: { id: Number(id) },
data: {
status: "DELIVERED",
claimedAt: new Date(),
validatedByEmployeeId: employeeId,
},
});
return res.json({
message: "Lot marqué comme remis avec succès.",
claim: updatedClaim,
});
} catch (error) {
console.error("Erreur remise lot:", error);
return res.status(500).json({
message: "Erreur serveur lors de la remise du lot.",
});
}
};
module.exports = {
getGainByTicketCode,
deliverClaim,
};

View File

@@ -0,0 +1,120 @@
const prisma = require("../config/prisma");
const participate = async (req, res) => {
try {
const { code } = req.body;
const userId = req.user.id;
const ticket = await prisma.ticket.findUnique({
where: { code },
include: {
lot: true,
},
});
if (!ticket) {
return res.status(404).json({
message: "Code invalide.",
});
}
if (ticket.isUsed) {
return res.status(409).json({
message: "Ce code a déjà été utilisé.",
});
}
const result = await prisma.$transaction(async (tx) => {
const updatedTicket = await tx.ticket.update({
where: { id: ticket.id },
data: {
isUsed: true,
usedAt: new Date(),
userId,
},
include: {
lot: true,
},
});
const participation = await tx.participation.create({
data: {
userId,
ticketId: updatedTicket.id,
lotId: updatedTicket.lotId,
},
});
const claim = await tx.claim.create({
data: {
participationId: participation.id,
status: "PENDING",
},
});
return {
ticket: updatedTicket,
participation,
claim,
};
});
return res.status(201).json({
message: "Participation enregistrée avec succès.",
gain: {
name: result.ticket.lot.name,
description: result.ticket.lot.description,
value: result.ticket.lot.value,
},
claim: {
id: result.claim.id,
status: result.claim.status,
},
});
} catch (error) {
console.error("Erreur participation:", error);
return res.status(500).json({
message: "Erreur serveur lors de la participation.",
});
}
};
const myGains = async (req, res) => {
try {
const userId = req.user.id;
const participations = await prisma.participation.findMany({
where: { userId },
include: {
ticket: true,
lot: true,
claim: true,
},
orderBy: {
createdAt: "desc",
},
});
return res.json({
gains: participations.map((participation) => ({
participationId: participation.id,
ticketCode: participation.ticket.code,
lot: participation.lot.name,
description: participation.lot.description,
value: participation.lot.value,
claimStatus: participation.claim?.status,
wonAt: participation.createdAt,
})),
});
} catch (error) {
console.error("Erreur historique gains:", error);
return res.status(500).json({
message: "Erreur serveur lors de la récupération des gains.",
});
}
};
module.exports = {
participate,
myGains,
};

View File

@@ -0,0 +1,29 @@
const swaggerJsdoc = require("swagger-jsdoc");
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "Thé Tip Top API",
version: "1.0.0",
description: "API du jeu concours Thé Tip Top",
},
servers: [
{
url: "http://localhost:5000",
},
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
},
apis: ["./src/routes/*.js"],
};
module.exports = swaggerJsdoc(options);

View File

@@ -0,0 +1,44 @@
const jwt = require("jsonwebtoken");
const prisma = require("../config/prisma");
const authMiddleware = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
message: "Token manquant ou invalide.",
});
}
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: {
id: true,
firstname: true,
lastname: true,
email: true,
role: true,
},
});
if (!user) {
return res.status(401).json({
message: "Utilisateur introuvable.",
});
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({
message: "Token invalide ou expiré.",
});
}
};
module.exports = authMiddleware;

View File

@@ -0,0 +1,13 @@
const roleMiddleware = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
return res.status(403).json({
message: "Accès refusé.",
});
}
next();
};
};
module.exports = roleMiddleware;

View File

@@ -0,0 +1,17 @@
const validate = (schema) => {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
message: "Données invalides",
errors: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
};
module.exports = validate;

View File

@@ -0,0 +1,114 @@
const express = require("express");
const {
getStats,
getUsers,
getEmailingContacts,
createEmployee,
drawWinner,
getGrandPrizeWinner,
} = 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 router = express.Router();
router.use(authMiddleware);
router.use(roleMiddleware("ADMIN"));
/**
* @swagger
* /api/admin/stats:
* get:
* summary: Tableau de bord administrateur
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Statistiques du jeu concours
*/
router.get("/stats", getStats);
/**
* @swagger
* /api/admin/users:
* get:
* summary: Liste des clients inscrits
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Liste des utilisateurs clients
*/
router.get("/users", getUsers);
/**
* @swagger
* /api/admin/emailing:
* get:
* summary: Liste des contacts ayant accepté l'emailing
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Contacts marketing opt-in
*/
router.get("/emailing", getEmailingContacts);
/**
* @swagger
* /api/admin/employees:
* post:
* summary: Création d'un employé
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 201:
* description: Employé créé
*/
router.post("/employees", validate(createEmployeeSchema), createEmployee);
/**
* @swagger
* /api/admin/draw:
* post:
* summary: Tirage au sort du grand gagnant
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Gagnant tiré au sort
*/
router.post("/draw", drawWinner);
/**
* @swagger
* /api/admin/draw:
* get:
* summary: Consulter le gagnant du gros lot
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Gagnant du gros lot
* 404:
* description: Aucun gagnant encore tiré
*/
router.get("/draw", getGrandPrizeWinner);
module.exports = router;

View File

@@ -0,0 +1,69 @@
const express = require("express");
const { register, login, me } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
const router = express.Router();
/**
* @swagger
* /api/auth/register:
* post:
* summary: Inscription d'un utilisateur
* tags:
* - Auth
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* firstname:
* type: string
* lastname:
* type: string
* email:
* type: string
* password:
* type: string
* responses:
* 201:
* description: Utilisateur créé
*/
router.post("/register", validate(registerSchema), register);
/**
* @swagger
* /api/auth/login:
* post:
* summary: Connexion utilisateur
* tags:
* - Auth
* requestBody:
* required: true
* responses:
* 200:
* description: Connexion réussie
*/
router.post("/login", validate(loginSchema), login);
/**
* @swagger
* /api/auth/me:
* get:
* summary: Informations de l'utilisateur connecté
* tags:
* - Auth
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Profil utilisateur
*/
router.get("/me", authMiddleware, me);
module.exports = router;

View File

@@ -0,0 +1,76 @@
const express = require("express");
const {
getGainByTicketCode,
deliverClaim,
} = require("../controllers/employee.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const roleMiddleware = require("../middlewares/role.middleware");
const router = express.Router();
/**
* @swagger
* /api/employee/ticket/{code}:
* get:
* summary: Rechercher le gain associé à un ticket
* tags:
* - Employee
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* description: Code du ticket à 10 caractères
* responses:
* 200:
* description: Gain trouvé
* 400:
* description: Ticket non encore utilisé par un client
* 404:
* description: Ticket introuvable
*/
router.get(
"/ticket/:code",
authMiddleware,
roleMiddleware("EMPLOYEE", "ADMIN"),
getGainByTicketCode
);
/**
* @swagger
* /api/employee/claim/{id}/deliver:
* patch:
* summary: Marquer un lot comme remis en boutique
* tags:
* - Employee
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: ID de la réclamation
* responses:
* 200:
* description: Lot remis avec succès
* 400:
* description: Gain expiré ou non valide
* 404:
* description: Réclamation introuvable
* 409:
* description: Lot déjà remis
*/
router.patch(
"/claim/:id/deliver",
authMiddleware,
roleMiddleware("EMPLOYEE", "ADMIN"),
deliverClaim
);
module.exports = router;

View File

@@ -0,0 +1,41 @@
const express = require("express");
const { participate, myGains } = require("../controllers/game.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { participateSchema } = require("../validations/game.validation");
const router = express.Router();
/**
* @swagger
* /api/game/participate:
* post:
* summary: Participer au jeu concours
* tags:
* - Game
* security:
* - bearerAuth: []
* responses:
* 201:
* description: Participation enregistrée
*/
router.post("/participate", authMiddleware, validate(participateSchema), participate);
/**
* @swagger
* /api/game/my-gains:
* get:
* summary: Historique des gains du participant
* tags:
* - Game
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Liste des gains
*/
router.get("/my-gains", authMiddleware, myGains);
module.exports = router;

View File

@@ -0,0 +1,67 @@
const prisma = require("../config/prisma");
function generateCode(length = 10) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let code = "";
for (let i = 0; i < length; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
async function generateTickets(total = 500000) {
const lots = await prisma.lot.findMany({
orderBy: { id: "asc" },
});
if (lots.length !== 5) {
throw new Error("Les 5 lots doivent être créés avant de générer les tickets.");
}
await prisma.ticket.deleteMany();
const repartition = [
{ lotId: lots[0].id, count: Math.floor(total * 0.6) },
{ lotId: lots[1].id, count: Math.floor(total * 0.2) },
{ lotId: lots[2].id, count: Math.floor(total * 0.1) },
{ lotId: lots[3].id, count: Math.floor(total * 0.06) },
{ lotId: lots[4].id, count: total - (Math.floor(total * 0.6) + Math.floor(total * 0.2) + Math.floor(total * 0.1) + Math.floor(total * 0.06)) },
];
const usedCodes = new Set();
const tickets = [];
for (const item of repartition) {
for (let i = 0; i < item.count; i++) {
let code;
do {
code = generateCode();
} while (usedCodes.has(code));
usedCodes.add(code);
tickets.push({
code,
lotId: item.lotId,
});
}
}
await prisma.ticket.createMany({
data: tickets,
});
console.log(`---- ${tickets.length} tickets générés avec succès.----`);
}
generateTickets()
.catch((error) => {
console.error("Erreur génération tickets :", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,14 @@
const { z } = require("zod");
const createEmployeeSchema = z.object({
firstname: z.string().min(2),
lastname: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
birthdate: z.string().optional(),
gender: z.string().optional(),
});
module.exports = {
createEmployeeSchema,
};

View File

@@ -0,0 +1,21 @@
const { z } = require("zod");
const registerSchema = z.object({
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
email: z.string().email("Email invalide"),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
birthdate: z.string().optional(),
gender: z.string().optional(),
marketingOptIn: z.boolean().optional(),
});
const loginSchema = z.object({
email: z.string().email("Email invalide"),
password: z.string().min(1, "Mot de passe requis"),
});
module.exports = {
registerSchema,
loginSchema,
};

View File

@@ -0,0 +1,13 @@
const { z } = require("zod");
const participateSchema = z.object({
code: z
.string()
.min(10, "Le code doit contenir 10 caractères")
.max(10, "Le code doit contenir 10 caractères")
.toUpperCase(),
});
module.exports = {
participateSchema,
};