feat: add newsletter subscription feature

- Add newsletter database table migration
- Create newsletter controller with subscribe/unsubscribe endpoints
- Add newsletter routes and validation
- Implement newsletter service with email validation
- Add setup documentation and migration scripts
- Include test page for newsletter functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
soufiane 2025-11-24 00:07:44 +01:00
parent 3e08a647a5
commit 4759ce99e7
9 changed files with 628 additions and 0 deletions

126
NEWSLETTER_SETUP.md Normal file
View File

@ -0,0 +1,126 @@
# Newsletter Feature - Installation Guide
## Vue d'ensemble
La fonctionnalité newsletter a été ajoutée au backend et frontend. Elle permet aux utilisateurs de s'abonner à la newsletter depuis le footer du site.
## Backend
### Fichiers créés :
1. **Migration de base de données** : `database/migrations/add-newsletter-table.sql`
2. **Controller** : `src/controllers/newsletter.controller.js`
3. **Service** : `src/services/newsletter.service.js`
4. **Routes** : `src/routes/newsletter.routes.js`
5. **Validation** : `src/validations/newsletter.validation.js`
### Endpoints API :
- `POST /api/newsletter/subscribe` - S'abonner (public)
- `POST /api/newsletter/unsubscribe` - Se désabonner (public)
- `GET /api/newsletter/subscribers` - Liste des abonnés (Admin seulement)
- `GET /api/newsletter/count` - Nombre d'abonnés actifs (Admin seulement)
## Frontend
### Fichiers modifiés/créés :
1. **Service** : `services/newsletter.service.ts`
2. **Constants** : `utils/constants.ts` (ajout des endpoints newsletter)
3. **Footer** : `components/Footer.tsx` (ajout du formulaire d'inscription)
## Installation
### 1. Appliquer la migration de base de données
Exécutez le script SQL pour créer la table `newsletters` :
```bash
# Depuis le répertoire backend
psql -h 51.75.24.29 -U postgres -d thetiptop_dev -p 5433 -f database/migrations/add-newsletter-table.sql
```
Ou connectez-vous à votre base de données et exécutez manuellement le contenu du fichier `database/migrations/add-newsletter-table.sql`.
### 2. Redémarrer le backend
```bash
# Dans le répertoire backend
npm run dev
```
### 3. Redémarrer le frontend
```bash
# Dans le répertoire frontend
npm run dev
```
## Fonctionnalités
### Pour les utilisateurs :
- Formulaire d'inscription à la newsletter dans le footer
- Validation d'email en temps réel
- Messages de confirmation/erreur
- Email de confirmation d'abonnement automatique
- Possibilité de se désabonner
### Pour les administrateurs :
- Voir la liste complète des abonnés
- Voir le nombre d'abonnés actifs
- API endpoints protégés par authentification et rôle ADMIN
## Emails
Les emails de confirmation sont envoyés automatiquement lors de l'inscription. Configuration SMTP requise dans le fichier `.env` :
```env
SMTP_HOST=your-smtp-host
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password
EMAIL_FROM=noreply@thetiptop.fr
```
En mode développement sans configuration SMTP, les emails sont affichés dans la console.
## Test
### Test manuel :
1. Ouvrez le frontend (http://localhost:3000)
2. Scrollez jusqu'au footer
3. Entrez votre email dans le champ "Newsletter"
4. Cliquez sur "S'inscrire"
5. Vérifiez le message de confirmation
### Test API avec curl :
```bash
# S'abonner
curl -X POST http://localhost:4000/api/newsletter/subscribe \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# Nombre d'abonnés (nécessite un token admin)
curl -X GET http://localhost:4000/api/newsletter/count \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
```
## Sécurité
- Les endpoints publics (subscribe/unsubscribe) ne nécessitent pas d'authentification
- Les endpoints admin (subscribers/count) nécessitent un token JWT et le rôle ADMIN
- Validation Zod sur les entrées
- Protection contre les doublons d'email
- Sanitisation des données
## Prochaines étapes possibles
- Ajout d'une page de gestion de newsletter pour les admins
- Export de la liste des abonnés en CSV
- Système de campagnes email
- Segmentation des abonnés
- Statistiques d'engagement

View File

@ -0,0 +1,26 @@
-- ============================================
-- MIGRATION: Add Newsletter Table
-- ============================================
-- Create newsletters table
CREATE TABLE IF NOT EXISTS newsletters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
unsubscribed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX idx_newsletters_email ON newsletters(email);
CREATE INDEX idx_newsletters_is_active ON newsletters(is_active);
-- Add trigger for updated_at
CREATE TRIGGER update_newsletters_updated_at BEFORE UPDATE ON newsletters
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Add comment
COMMENT ON TABLE newsletters IS 'Table des abonnements à la newsletter';
COMMENT ON COLUMN newsletters.is_active IS 'TRUE si abonné, FALSE si désabonné';

View File

@ -14,6 +14,7 @@ import gameRoutes from "./src/routes/game.routes.js";
import employeeRoutes from "./src/routes/employee.routes.js";
import adminRoutes from "./src/routes/admin.routes.js";
import drawRoutes from "./src/routes/draw.routes.js";
import newsletterRoutes from "./src/routes/newsletter.routes.js";
const app = express();
@ -89,6 +90,7 @@ app.use("/api/game", gameRoutes);
app.use("/api/employee", employeeRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api/draw", drawRoutes);
app.use("/api/newsletter", newsletterRoutes);
// Error handler (doit être après les routes)
app.use(errorHandler);

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Newsletter</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
}
input, button {
padding: 10px;
margin: 10px 0;
width: 100%;
}
button {
background: #4CAF50;
color: white;
border: none;
cursor: pointer;
}
#result {
margin-top: 20px;
padding: 10px;
border-radius: 5px;
}
.success {
background: #d4edda;
color: #155724;
}
.error {
background: #f8d7da;
color: #721c24;
}
</style>
</head>
<body>
<h1>Test Newsletter API</h1>
<form id="newsletterForm">
<input type="email" id="email" placeholder="Votre email" required>
<button type="submit">S'inscrire</button>
</form>
<div id="result"></div>
<script>
document.getElementById('newsletterForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const resultDiv = document.getElementById('result');
try {
console.log('🔄 Envoi de la requête...');
const response = await fetch('http://localhost:4000/api/newsletter/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email })
});
const data = await response.json();
console.log('✅ Réponse:', data);
resultDiv.className = response.ok ? 'success' : 'error';
resultDiv.textContent = data.message || 'Réponse reçue';
} catch (error) {
console.error('❌ Erreur:', error);
resultDiv.className = 'error';
resultDiv.textContent = 'Erreur: ' + error.message;
}
});
console.log('📍 Test depuis:', window.location.origin);
console.log('🎯 API cible: http://localhost:4000/api/newsletter/subscribe');
</script>
</body>
</html>

View File

@ -0,0 +1,47 @@
/**
* Script pour appliquer la migration newsletter
*/
import { pool } from '../db.js';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function applyMigration() {
try {
console.log('📦 Application de la migration newsletter...');
// Lire le fichier SQL
const migrationPath = join(__dirname, '../database/migrations/add-newsletter-table.sql');
const sql = fs.readFileSync(migrationPath, 'utf8');
console.log('📄 Fichier SQL lu:', migrationPath);
// Exécuter la migration
await pool.query(sql);
console.log('✅ Migration appliquée avec succès!');
console.log('📊 Table newsletters créée');
// Vérifier que la table existe
const result = await pool.query(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'newsletters'"
);
if (result.rows.length > 0) {
console.log('✅ Vérification: Table newsletters trouvée dans la base de données');
} else {
console.log('❌ Erreur: Table newsletters non trouvée après migration');
}
process.exit(0);
} catch (error) {
console.error('❌ Erreur lors de l\'application de la migration:', error.message);
console.error(error);
process.exit(1);
}
}
applyMigration();

View File

@ -0,0 +1,154 @@
/**
* Controller Newsletter
*/
import { pool } from '../../db.js';
import { AppError, asyncHandler } from '../middleware/errorHandler.js';
import { sendNewsletterConfirmationEmail } from '../services/newsletter.service.js';
/**
* S'abonner à la newsletter
* POST /api/newsletter/subscribe
*/
export const subscribe = asyncHandler(async (req, res, next) => {
const { email } = req.body;
if (!email) {
return next(new AppError('Email requis', 400));
}
// Vérifier si l'email existe déjà
const existingSubscription = await pool.query(
'SELECT * FROM newsletters WHERE email = $1',
[email]
);
if (existingSubscription.rows.length > 0) {
const subscription = existingSubscription.rows[0];
// Si déjà abonné et actif
if (subscription.is_active) {
return res.json({
success: true,
message: 'Vous êtes déjà abonné à notre newsletter',
});
}
// Si désabonné, réactiver l'abonnement
await pool.query(
'UPDATE newsletters SET is_active = TRUE, unsubscribed_at = NULL, updated_at = NOW() WHERE email = $1',
[email]
);
// Envoyer l'email de confirmation
try {
await sendNewsletterConfirmationEmail(email);
} catch (error) {
console.error('Erreur envoi email confirmation:', error);
// On continue même si l'email échoue
}
return res.json({
success: true,
message: 'Votre abonnement à la newsletter a été réactivé avec succès',
});
}
// Créer un nouvel abonnement
await pool.query(
'INSERT INTO newsletters (email, is_active) VALUES ($1, TRUE)',
[email]
);
// Envoyer l'email de confirmation
try {
await sendNewsletterConfirmationEmail(email);
} catch (error) {
console.error('Erreur envoi email confirmation:', error);
// On continue même si l'email échoue
}
res.status(201).json({
success: true,
message: 'Merci ! Vous êtes maintenant abonné à notre newsletter',
});
});
/**
* Se désabonner de la newsletter
* POST /api/newsletter/unsubscribe
*/
export const unsubscribe = asyncHandler(async (req, res, next) => {
const { email } = req.body;
if (!email) {
return next(new AppError('Email requis', 400));
}
// Vérifier si l'abonnement existe
const subscription = await pool.query(
'SELECT * FROM newsletters WHERE email = $1',
[email]
);
if (subscription.rows.length === 0) {
return next(new AppError('Aucun abonnement trouvé pour cet email', 404));
}
// Désactiver l'abonnement
await pool.query(
'UPDATE newsletters SET is_active = FALSE, unsubscribed_at = NOW(), updated_at = NOW() WHERE email = $1',
[email]
);
res.json({
success: true,
message: 'Vous avez été désabonné de notre newsletter',
});
});
/**
* Obtenir tous les abonnés (Admin seulement)
* GET /api/newsletter/subscribers
*/
export const getSubscribers = asyncHandler(async (req, res) => {
const result = await pool.query(
`SELECT id, email, is_active, subscribed_at, unsubscribed_at, created_at
FROM newsletters
ORDER BY created_at DESC`
);
res.json({
success: true,
count: result.rows.length,
data: result.rows.map(row => ({
id: row.id,
email: row.email,
isActive: row.is_active,
subscribedAt: row.subscribed_at,
unsubscribedAt: row.unsubscribed_at,
createdAt: row.created_at,
})),
});
});
/**
* Obtenir le nombre d'abonnés actifs (Admin seulement)
* GET /api/newsletter/count
*/
export const getActiveCount = asyncHandler(async (req, res) => {
const result = await pool.query(
'SELECT COUNT(*) as count FROM newsletters WHERE is_active = TRUE'
);
res.json({
success: true,
count: parseInt(result.rows[0].count),
});
});
export default {
subscribe,
unsubscribe,
getSubscribers,
getActiveCount,
};

View File

@ -0,0 +1,31 @@
/**
* Routes newsletter
*/
import { Router } from 'express';
import * as newsletterController from '../controllers/newsletter.controller.js';
import { authenticateToken, authorizeRoles } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { subscribeSchema, unsubscribeSchema } from '../validations/newsletter.validation.js';
const router = Router();
// Routes publiques
router.post('/subscribe', validate(subscribeSchema), newsletterController.subscribe);
router.post('/unsubscribe', validate(unsubscribeSchema), newsletterController.unsubscribe);
// Routes protégées (Admin seulement)
router.get(
'/subscribers',
authenticateToken,
authorizeRoles('ADMIN'),
newsletterController.getSubscribers
);
router.get(
'/count',
authenticateToken,
authorizeRoles('ADMIN'),
newsletterController.getActiveCount
);
export default router;

View File

@ -0,0 +1,131 @@
/**
* Service Newsletter
*/
import nodemailer from 'nodemailer';
import dotenv from 'dotenv';
dotenv.config();
// Configuration du transporteur email (réutilise la config du service email)
const createTransporter = () => {
if (process.env.NODE_ENV === 'development' && !process.env.SMTP_HOST) {
console.log('⚠️ Mode développement: Les emails seront affichés dans la console');
return null;
}
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_PORT === '465',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
};
const transporter = createTransporter();
/**
* Fonction générique pour envoyer un email
*/
const sendEmail = async ({ to, subject, html, text }) => {
try {
if (!transporter) {
console.log('📧 Email qui aurait été envoyé:');
console.log('To:', to);
console.log('Subject:', subject);
console.log('Content:', text || html);
return { success: true, mode: 'dev' };
}
const mailOptions = {
from: process.env.EMAIL_FROM || 'noreply@thetiptop.fr',
to,
subject,
text,
html,
};
const info = await transporter.sendMail(mailOptions);
console.log('✅ Email envoyé:', info.messageId);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('❌ Erreur envoi email:', error);
throw new Error('Erreur lors de l\'envoi de l\'email');
}
};
/**
* Envoie un email de confirmation d'abonnement à la newsletter
*/
export const sendNewsletterConfirmationEmail = async (email) => {
const unsubscribeUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/newsletter/unsubscribe?email=${encodeURIComponent(email)}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
.unsubscribe { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; }
.unsubscribe a { color: #999; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🍵 Bienvenue dans notre newsletter!</h1>
</div>
<div class="content">
<h2>Merci de votre abonnement</h2>
<p>Nous sommes ravis de vous compter parmi nos abonnés !</p>
<p>Vous recevrez désormais nos dernières actualités, offres exclusives et nouveautés directement dans votre boîte mail.</p>
<p>Restez connecté avec Thé Tip Top pour découvrir :</p>
<ul>
<li>🍵 Nos nouveaux thés et infusions</li>
<li>🎁 Des offres spéciales réservées aux abonnés</li>
<li>📰 Les actualités de nos boutiques</li>
<li>🎉 Les résultats de nos jeux-concours</li>
</ul>
<div class="unsubscribe">
<p style="font-size: 12px; color: #999;">
Vous ne souhaitez plus recevoir nos emails ? <a href="${unsubscribeUrl}">Se désabonner</a>
</p>
</div>
</div>
<div class="footer">
<p>© 2025 Thé Tip Top - Tous droits réservés</p>
</div>
</div>
</body>
</html>
`;
const text = `
Bienvenue dans notre newsletter!
Nous sommes ravis de vous compter parmi nos abonnés !
Vous recevrez désormais nos dernières actualités, offres exclusives et nouveautés directement dans votre boîte mail.
Pour vous désabonner: ${unsubscribeUrl}
© 2025 Thé Tip Top - Tous droits réservés
`;
return sendEmail({
to: email,
subject: '🍵 Bienvenue dans la newsletter Thé Tip Top',
html,
text,
});
};
export default {
sendNewsletterConfirmationEmail,
};

View File

@ -0,0 +1,31 @@
/**
* Schémas de validation avec Zod pour la newsletter
*/
import { z } from 'zod';
// Schéma pour l'abonnement à la newsletter
export const subscribeSchema = z.object({
body: z.object({
email: z
.string({
required_error: 'L\'email est requis',
})
.email('Format d\'email invalide'),
}),
});
// Schéma pour le désabonnement de la newsletter
export const unsubscribeSchema = z.object({
body: z.object({
email: z
.string({
required_error: 'L\'email est requis',
})
.email('Format d\'email invalide'),
}),
});
export default {
subscribeSchema,
unsubscribeSchema,
};