# LeBonCoup - Spécifications Techniques ## Architecture Générale ### Stack recommandée pour MVP **Backend :** - Node.js + Express (ou NestJS pour plus de structure) - Alternative : Laravel (si préférence PHP) **Base de données :** - PostgreSQL 14+ avec extension PostGIS (géolocalisation) **Frontend :** - React ou Vue.js 3 - PWA (Progressive Web App) pour notifications - Alternative : WordPress headless (si expertise existante) **Paiement :** - Stripe (Checkout ou Elements) **Hébergement :** - Backend : Railway, Render, DigitalOcean, ou AWS - BDD : Managed PostgreSQL (même providers) - Stockage médias (v2) : AWS S3 ou Cloudflare R2 **CDN :** - Cloudflare (gratuit pour commencer) --- ## 1. Authentification et Identité ### 1.1 Système de hash d'email **Génération du user_id :** ```javascript // Côté serveur : salt secret const SERVER_SALT = process.env.SECRET_SALT; // stocké en env var // Génération const userId = SHA256(email.toLowerCase().trim() + SERVER_SALT); ``` **Pourquoi un salt ?** - Empêche les attaques par rainbow table - Même avec la BDD leakée, impossible de retrouver l'email sans le salt - Le salt reste secret côté serveur ### 1.2 Dérivation de clés pour le chiffrement **Côté client uniquement (JavaScript) :** ```javascript // À la connexion async function deriveKeys(email, password) { const emailLower = email.toLowerCase().trim(); // Pour l'authentification (envoyé au serveur) const userId = await sha256(emailLower + SERVER_SALT); // Pour le chiffrement E2E (reste dans le navigateur) const encryptionKey = await crypto.subtle.deriveKey( { name: "PBKDF2", salt: new TextEncoder().encode(emailLower), iterations: 100000, hash: "SHA-256" }, await crypto.subtle.importKey( "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"] ), { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); // Stockée en sessionStorage (volatile) sessionStorage.setItem('encryptionKey', encryptionKey); return { userId, encryptionKey }; } ``` **Clés dérivées :** - `userId` : identifiant pour le serveur (hash SHA256) - `encryptionKey` : clé AES-256 pour chiffrer/déchiffrer les messages - La `encryptionKey` **ne quitte JAMAIS** le navigateur ### 1.3 Authentification **Inscription :** ```javascript POST /api/auth/signup Body: { userId: "8f7a9b2c...", // SHA256(email + salt) passwordHash: "bcrypt..." // password hashé côté client } Serveur : 1. Vérifie que userId n'existe pas 2. Hash à nouveau le passwordHash (double hash) 3. Stocke dans users table 4. Renvoie JWT ``` **Login :** ```javascript POST /api/auth/login Body: { userId: "8f7a9b2c...", password: "plaintext" // ou passwordHash si préférence } Serveur : 1. Trouve user par userId 2. Vérifie password avec bcrypt.compare() 3. Génère JWT (expiré 7 jours) 4. Renvoie token ``` **JWT Payload :** ```json { "userId": "8f7a9b2c...", "type": "pro" | "demandeur", "iat": 1234567890, "exp": 1234999999 } ``` --- ## 2. Base de Données ### 2.1 Schéma PostgreSQL ```sql -- Users (pros et demandeurs) CREATE TABLE users ( id VARCHAR(64) PRIMARY KEY, -- SHA256(email + salt) password_hash VARCHAR(255) NOT NULL, -- bcrypt user_type VARCHAR(20) NOT NULL, -- 'pro' ou 'demandeur' token_balance INT DEFAULT 0, token_premium_balance INT DEFAULT 0, -- optionnel v2 created_at TIMESTAMP DEFAULT NOW(), banned BOOLEAN DEFAULT FALSE, ban_reason TEXT ); -- Demandes CREATE TABLE demandes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), author_id VARCHAR(64) REFERENCES users(id), title VARCHAR(100) NOT NULL, description TEXT NOT NULL, category VARCHAR(50) NOT NULL, location GEOGRAPHY(POINT, 4326), -- PostGIS pour géoloc location_display VARCHAR(100), -- "Paris 11e" (flouté) budget_min INT, budget_max INT, status VARCHAR(20) DEFAULT 'active', -- active/full/resolved/expired slots_used INT DEFAULT 0, -- nombre de pros ayant débloqué slots_premium_used INT DEFAULT 0, -- optionnel v2 created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP, -- created_at + 30 jours flagged BOOLEAN DEFAULT FALSE, flag_reason TEXT ); CREATE INDEX idx_demandes_status ON demandes(status); CREATE INDEX idx_demandes_location ON demandes USING GIST(location); CREATE INDEX idx_demandes_category ON demandes(category); -- Transactions (paiements externes) CREATE TABLE transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id VARCHAR(64) REFERENCES users(id), stripe_payment_id VARCHAR(255) NOT NULL, amount_eur DECIMAL(10, 2) NOT NULL, tokens_credited INT NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_transactions_user ON transactions(user_id); CREATE INDEX idx_transactions_stripe ON transactions(stripe_payment_id); -- Unlocks (qui a débloqué quoi) CREATE TABLE unlocks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), pro_id VARCHAR(64) REFERENCES users(id), demande_id UUID REFERENCES demandes(id), tokens_spent INT NOT NULL, is_premium BOOLEAN DEFAULT FALSE, unlocked_at TIMESTAMP DEFAULT NOW(), refunded BOOLEAN DEFAULT FALSE, refund_reason TEXT ); CREATE INDEX idx_unlocks_pro ON unlocks(pro_id); CREATE INDEX idx_unlocks_demande ON unlocks(demande_id); -- Conversations (blobs chiffrés) CREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_a_id VARCHAR(64) REFERENCES users(id), user_b_id VARCHAR(64) REFERENCES users(id), blob_for_a TEXT, -- JSON chiffré pour A blob_for_b TEXT, -- JSON chiffré pour B message_count INT DEFAULT 0, last_updated TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(), auto_delete_at TIMESTAMP -- created_at + 6 mois ); CREATE INDEX idx_conv_user_a ON conversations(user_a_id); CREATE INDEX idx_conv_user_b ON conversations(user_b_id); CREATE INDEX idx_conv_auto_delete ON conversations(auto_delete_at); -- Flags (modération) CREATE TABLE conversation_flags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID REFERENCES conversations(id), user_id VARCHAR(64) REFERENCES users(id), -- user qui a généré les flags flag_type VARCHAR(50) NOT NULL, -- contact_externe, paiement_externe, suspect count INT DEFAULT 1, last_flagged TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_flags_conv ON conversation_flags(conversation_id); CREATE INDEX idx_flags_user ON conversation_flags(user_id); -- Signalements (user reports) CREATE TABLE reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), reporter_id VARCHAR(64) REFERENCES users(id), reported_id VARCHAR(64) REFERENCES users(id), reason TEXT NOT NULL, conversation_id UUID REFERENCES conversations(id), status VARCHAR(20) DEFAULT 'pending', -- pending/reviewed/actioned created_at TIMESTAMP DEFAULT NOW() ); -- Médias (optionnel v2) CREATE TABLE media_files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID REFERENCES conversations(id), uploaded_by VARCHAR(64) REFERENCES users(id), file_encrypted BYTEA, -- ou URL S3 file_type VARCHAR(10), -- image/video file_size INT, uploaded_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP, -- uploaded_at + 14 jours deleted BOOLEAN DEFAULT FALSE ); CREATE INDEX idx_media_expires ON media_files(expires_at) WHERE deleted = FALSE; ``` ### 2.2 Tâches cron automatiques **Script à exécuter quotidiennement (3h du matin) :** ```javascript // Suppression conversations > 6 mois DELETE FROM conversations WHERE last_updated < NOW() - INTERVAL '6 months'; // Suppression demandes expirées DELETE FROM demandes WHERE expires_at < NOW(); // Suppression médias expirés DELETE FROM media_files WHERE expires_at < NOW() AND deleted = FALSE; // Warning 30 jours avant suppression conversation SELECT user_a_id, user_b_id, id FROM conversations WHERE last_updated < NOW() - INTERVAL '5 months' AND last_updated > NOW() - INTERVAL '5 months 1 day'; // → Envoyer notifications // Ban automatique si trop de flags UPDATE users SET banned = TRUE, ban_reason = 'Auto-ban: excessive flags' WHERE id IN ( SELECT user_id FROM conversation_flags GROUP BY user_id HAVING SUM(count) > 50 ); ``` --- ## 3. Paiements Stripe ### 3.1 Intégration Stripe Checkout (recommandé pour MVP) **Flow :** ```javascript // Frontend : User clique "Acheter 50 tokens" const response = await fetch('/api/tokens/create-checkout', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}` }, body: JSON.stringify({ pack: '50' }) }); const { sessionId } = await response.json(); // Redirige vers Stripe Checkout const stripe = Stripe(STRIPE_PUBLIC_KEY); await stripe.redirectToCheckout({ sessionId }); ``` **Backend :** ```javascript const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); app.post('/api/tokens/create-checkout', authenticateJWT, async (req, res) => { const { pack } = req.body; const userId = req.user.userId; // Définir les packs const packs = { '10': { price: 10, tokens: 10 }, '25': { price: 25, tokens: 30 }, '50': { price: 50, tokens: 70 }, '100': { price: 100, tokens: 150 } }; const selectedPack = packs[pack]; if (!selectedPack) return res.status(400).json({ error: 'Invalid pack' }); // Créer session Stripe const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [{ price_data: { currency: 'eur', product_data: { name: `${selectedPack.tokens} tokens LeBonCoup` }, unit_amount: selectedPack.price * 100 // en centimes }, quantity: 1 }], mode: 'payment', success_url: `${process.env.FRONTEND_URL}/tokens/success`, cancel_url: `${process.env.FRONTEND_URL}/tokens/cancel`, metadata: { userId, tokens: selectedPack.tokens } }); res.json({ sessionId: session.id }); }); ``` ### 3.2 Webhook Stripe (CRITIQUE) **Configuration Stripe :** - Dashboard Stripe → Developers → Webhooks - Ajouter endpoint : `https://tonsite.com/api/webhooks/stripe` - Événements à écouter : `checkout.session.completed` **Backend :** ```javascript app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['stripe-signature']; const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; let event; try { // VÉRIFICATION DE LA SIGNATURE (CRITIQUE) event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); } catch (err) { console.error('Webhook signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } // Traiter l'événement if (event.type === 'checkout.session.completed') { const session = event.data.object; const { userId, tokens } = session.metadata; // Créditer les tokens await db.query( 'UPDATE users SET token_balance = token_balance + $1 WHERE id = $2', [tokens, userId] ); // Enregistrer la transaction await db.query( 'INSERT INTO transactions (user_id, stripe_payment_id, amount_eur, tokens_credited) VALUES ($1, $2, $3, $4)', [userId, session.payment_intent, session.amount_total / 100, tokens] ); console.log(`✅ Credited ${tokens} tokens to user ${userId}`); } res.json({ received: true }); }); ``` **⚠️ SÉCURITÉ CRITIQUE :** - **TOUJOURS** vérifier la signature Stripe - Sans ça, n'importe qui peut envoyer un faux webhook et s'auto-créditer des tokens - Ne JAMAIS faire confiance à un webhook non signé --- ## 4. Messagerie Chiffrée (Système de Blobs) ### 4.1 Chiffrement côté client **Chiffrement d'un message :** ```javascript async function encryptBlob(plaintextJSON, encryptionKey) { const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, encryptionKey, new TextEncoder().encode(JSON.stringify(plaintextJSON)) ); // Retourner IV + ciphertext encodé en base64 return { iv: btoa(String.fromCharCode(...iv)), data: btoa(String.fromCharCode(...new Uint8Array(encrypted))) }; } async function decryptBlob(encryptedBlob, encryptionKey) { const iv = Uint8Array.from(atob(encryptedBlob.iv), c => c.charCodeAt(0)); const data = Uint8Array.from(atob(encryptedBlob.data), c => c.charCodeAt(0)); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, encryptionKey, data ); return JSON.parse(new TextDecoder().decode(decrypted)); } ``` ### 4.2 Structure du blob (déchiffré) ```json { "conversation_id": "uuid", "participants": { "user_a": "user_id_a", "user_b": "user_id_b" }, "messages": [ { "id": "msg_uuid", "from": "user_id_a", "type": "text", "content": "Bonjour, je suis intéressé...", "timestamp": 1234567890, "read": false }, { "id": "msg_uuid2", "from": "user_id_b", "type": "text", "content": "Parfait, voici mes disponibilités...", "timestamp": 1234567920, "read": true } ], "last_read": { "user_a": 1234567920, "user_b": 1234567890 } } ``` ### 4.3 Envoi d'un message **Frontend :** ```javascript async function sendMessage(conversationId, recipientId, content) { // 1. Récupérer les blobs existants const response = await fetch(`/api/conversations/${conversationId}`, { headers: { 'Authorization': `Bearer ${jwt}` } }); const { blob_for_a, blob_for_b, user_a_id, user_b_id } = await response.json(); // 2. Déterminer qui est qui const currentUserId = getCurrentUserId(); const myBlob = currentUserId === user_a_id ? blob_for_a : blob_for_b; const theirBlob = currentUserId === user_a_id ? blob_for_b : blob_for_a; // 3. Déchiffrer mon blob const myKey = sessionStorage.getItem('encryptionKey'); const myData = await decryptBlob(JSON.parse(myBlob), myKey); // 4. Ajouter le nouveau message const newMessage = { id: generateUUID(), from: currentUserId, type: 'text', content, timestamp: Date.now(), read: false }; myData.messages.push(newMessage); // 5. Re-chiffrer pour moi const newMyBlob = await encryptBlob(myData, myKey); // 6. Dériver la clé du destinataire et chiffrer pour lui // NOTE: Ici on suppose qu'on a la clé du destinataire // En réalité, chaque user chiffre avec SA clé, donc on doit : // - Soit récupérer leur clé publique (si on passe en asymétrique) // - Soit re-construire leur blob côté serveur (impossible car on a pas leur clé) // // SOLUTION RÉELLE : Chaque user chiffre son propre blob // Donc User A met à jour son blob, User B met à jour le sien // Version simplifiée : on envoie juste notre blob mis à jour await fetch(`/api/conversations/${conversationId}/message`, { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ myBlob: newMyBlob, recipientId, messagePreview: content.substring(0, 50) // pour notif }) }); } ``` **Backend :** ```javascript app.post('/api/conversations/:id/message', authenticateJWT, async (req, res) => { const { id } = req.params; const { myBlob, recipientId, messagePreview } = req.body; const senderId = req.user.userId; // 1. Mettre à jour le blob de l'expéditeur await db.query( `UPDATE conversations SET ${senderId === 'user_a_id' ? 'blob_for_a' : 'blob_for_b'} = $1, message_count = message_count + 1, last_updated = NOW() WHERE id = $2`, [JSON.stringify(myBlob), id] ); // 2. Envoyer notification push au destinataire await sendPushNotification(recipientId, { title: 'Nouveau message', body: messagePreview, conversationId: id }); res.json({ success: true }); }); ``` **⚠️ PROBLÈME IDENTIFIÉ :** Avec des clés symétriques dérivées, **chaque user ne peut chiffrer QUE pour lui-même**. **Solutions :** **Option A : Chiffrement asymétrique (RSA/ECDH)** - Chaque user a une paire clé publique/privée - Clé publique stockée sur le serveur - Messages chiffrés avec la clé publique du destinataire - Plus complexe **Option B : Blob personnel uniquement** - Chaque user gère uniquement SON blob - Quand User A envoie un message, il met à jour son blob - Quand User B se connecte, il récupère son blob (qui contient les messages que A lui a envoyés) - Le serveur reçoit deux blobs séparés et les synchronise **Recommandation pour MVP : Option B (plus simple)** --- ## 5. Système de Flags (Modération) ### 5.1 Scan côté client **Patterns de détection :** ```javascript const DETECTION_PATTERNS = { phone: /\b0[67]\d{8}\b|\b\+33\s?[67]\d{8}\b/, email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/i, social: /@(instagram|telegram|whatsapp|snapchat|facebook)/i, payment: /\b(paypal|venmo|cash|espèces|liquide|virement|western\s?union)\b/i, suspect: /\b(discret|sans facture|urgent|livraison rapide|entre nous)\b/i }; function scanMessage(content) { const flags = []; if (DETECTION_PATTERNS.phone.test(content)) { flags.push('contact_externe'); } if (DETECTION_PATTERNS.email.test(content)) { flags.push('contact_externe'); } if (DETECTION_PATTERNS.social.test(content)) { flags.push('contact_externe'); } if (DETECTION_PATTERNS.payment.test(content)) { flags.push('paiement_externe'); } if (DETECTION_PATTERNS.suspect.test(content)) { flags.push('activité_suspecte'); } return flags; } ``` ### 5.2 Envoi des flags au serveur ```javascript async function sendMessage(conversationId, content) { // 1. Scan du message const flags = scanMessage(content); // 2. Si flags détectés, envoyer au serveur (asynchrone) if (flags.length > 0) { fetch('/api/flags', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId, flags, timestamp: Date.now() }) }).catch(err => console.error('Failed to send flags:', err)); } // 3. Chiffrer et envoyer le message normalement // (ne pas bloquer l'envoi) await encryptAndSendMessage(conversationId, content); } ``` ### 5.3 Traitement serveur ```javascript app.post('/api/flags', authenticateJWT, async (req, res) => { const { conversationId, flags } = req.body; const userId = req.user.userId; // Incrémenter les compteurs for (const flagType of flags) { await db.query( `INSERT INTO conversation_flags (conversation_id, user_id, flag_type, count, last_flagged) VALUES ($1, $2, $3, 1, NOW()) ON CONFLICT (conversation_id, user_id, flag_type) DO UPDATE SET count = conversation_flags.count + 1, last_flagged = NOW()`, [conversationId, userId, flagType] ); } // Calculer le total de flags pour cet user const result = await db.query( 'SELECT SUM(count) as total FROM conversation_flags WHERE user_id = $1', [userId] ); const totalFlags = parseInt(result.rows[0].total); // Actions automatiques if (totalFlags >= 50) { await db.query('UPDATE users SET banned = TRUE, ban_reason = $1 WHERE id = $2', ['Auto-ban: 50+ flags', userId]); } else if (totalFlags >= 25) { // Restriction temporaire (TODO: implémenter système de restrictions) } else if (totalFlags >= 10) { // Warning (TODO: notification in-app) } res.json({ success: true }); }); ``` --- ## 6. Géolocalisation (PostGIS) ### 6.1 Stockage d'une demande avec localisation ```javascript app.post('/api/demandes', authenticateJWT, async (req, res) => { const { title, description, category, lat, lon, budget_min, budget_max } = req.body; const authorId = req.user.userId; // Créer un point géographique const result = await db.query( `INSERT INTO demandes (author_id, title, description, category, location, location_display, budget_min, budget_max, expires_at) VALUES ($1, $2, $3, $4, ST_SetSRID(ST_MakePoint($5, $6), 4326), $7, $8, $9, NOW() + INTERVAL '30 days') RETURNING *`, [authorId, title, description, category, lon, lat, getLocationDisplay(lat, lon), budget_min, budget_max] ); res.json(result.rows[0]); }); ``` ### 6.2 Filtrage par distance ```javascript app.get('/api/demandes/nearby', authenticateJWT, async (req, res) => { const { lat, lon, radius_km, category } = req.query; const result = await db.query( `SELECT *, ST_Distance(location, ST_SetSRID(ST_MakePoint($1, $2), 4326)) / 1000 as distance_km FROM demandes WHERE status = 'active' AND category = $3 AND ST_DWithin(location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $4 * 1000) ORDER BY distance_km ASC`, [lon, lat, category, radius_km] ); res.json(result.rows); }); ``` --- ## 7. Sécurité ### 7.1 Checklist sécurité **Backend :** - ✅ HTTPS obligatoire (certificat SSL) - ✅ Helmet.js (headers de sécurité) - ✅ Rate limiting (express-rate-limit) - ✅ CORS configuré strictement - ✅ SQL injection : utiliser parameterized queries - ✅ XSS : sanitize inputs (DOMPurify côté client) - ✅ CSRF : tokens CSRF ou SameSite cookies - ✅ JWT avec expiration courte (7 jours max) - ✅ Webhook Stripe : vérification signature **Frontend :** - ✅ CSP (Content Security Policy) - ✅ Pas de données sensibles en localStorage (sessionStorage OK) - ✅ Clés de chiffrement en mémoire uniquement - ✅ Logout = clear sessionStorage **BDD :** - ✅ Pas de port exposé publiquement - ✅ Credentials forts - ✅ Backups réguliers - ✅ Chiffrement at-rest si possible ### 7.2 Rate limiting ```javascript const rateLimit = require('express-rate-limit'); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // 100 requêtes max message: 'Too many requests, please try again later' }); app.use('/api/', apiLimiter); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, // 5 tentatives de login max message: 'Too many login attempts, please try again later' }); app.use('/api/auth/login', authLimiter); ``` --- ## 8. Déploiement ### 8.1 Variables d'environnement ```bash # .env NODE_ENV=production # Database DATABASE_URL=postgresql://user:password@host:5432/leboncoup # Secrets JWT_SECRET=random_string_very_long_and_secure SECRET_SALT=another_random_string_for_email_hashing # Stripe STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PUBLIC_KEY=pk_live_... # Frontend FRONTEND_URL=https://leboncoup.fr # Optionnel (v2) AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... S3_BUCKET_NAME=leboncoup-media ``` ### 8.2 Dockerfile (optionnel) ```dockerfile FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . EXPOSE 3000 CMD ["node", "server.js"] ``` ### 8.3 Procédure de déploiement **Option 1 : Railway (recommandé pour MVP)** 1. Connecter repo GitHub 2. Railway détecte Node.js automatiquement 3. Ajouter PostgreSQL addon 4. Configurer les env vars 5. Deploy automatique sur chaque push **Option 2 : DigitalOcean App Platform** - Similaire à Railway - Managed PostgreSQL inclus **Option 3 : AWS (plus complexe)** - EC2 pour backend - RDS PostgreSQL - S3 pour médias - CloudFront CDN - Route53 DNS --- ## 9. Monitoring et Logs ### 9.1 Logs essentiels ```javascript const winston = require('winston'); const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports: [ new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }) ] }); // Logs critiques logger.info('User login', { userId, timestamp }); logger.info('Token purchase', { userId, amount, tokens }); logger.warn('Flag threshold reached', { userId, totalFlags }); logger.error('Webhook verification failed', { error }); ``` ### 9.2 Alertes **Mettre en place des alertes pour :** - Webhook Stripe échoué - User atteint 50+ flags - Erreurs serveur (500) - Usage disque > 80% - CPU > 90% **Outils recommandés :** - Sentry (monitoring d'erreurs) - Better Uptime (uptime monitoring) - Logtail (logs centralisés) --- ## 10. Tests ### 10.1 Tests critiques **Backend :** ```javascript // Test webhook Stripe test('Webhook Stripe valide crédite les tokens', async () => { const event = createMockStripeEvent(); const response = await request(app) .post('/api/webhooks/stripe') .set('stripe-signature', generateValidSignature(event)) .send(event); expect(response.status).toBe(200); const user = await db.getUserById(userId); expect(user.token_balance).toBe(50); }); // Test flags auto-ban test('User avec 50 flags est automatiquement banni', async () => { await createFlagsForUser(userId, 50); const user = await db.getUserById(userId); expect(user.banned).toBe(true); }); ``` **Frontend :** ```javascript // Test chiffrement/déchiffrement test('Message chiffré puis déchiffré = message original', async () => { const key = await deriveEncryptionKey('test@example.com', 'password123'); const message = { content: 'Hello', timestamp: Date.now() }; const encrypted = await encryptBlob(message, key); const decrypted = await decryptBlob(encrypted, key); expect(decrypted.content).toBe('Hello'); }); ``` --- ## 11. Performance ### 11.1 Optimisations BDD **Index critiques :** ```sql CREATE INDEX CONCURRENTLY idx_demandes_active ON demandes(status) WHERE status = 'active'; CREATE INDEX CONCURRENTLY idx_conversations_recent ON conversations(last_updated DESC); CREATE INDEX CONCURRENTLY idx_unlocks_pro_recent ON unlocks(pro_id, unlocked_at DESC); ``` **Requêtes optimisées :** - Utiliser `LIMIT` partout - Pagination (offset/limit ou cursor-based) - Éviter les `SELECT *` (spécifier les colonnes) ### 11.2 Cache **Redis (optionnel pour scale) :** ```javascript // Cache du nombre de demandes actives const cachedCount = await redis.get('demandes:active:count'); if (cachedCount) return parseInt(cachedCount); const count = await db.query('SELECT COUNT(*) FROM demandes WHERE status = \'active\''); await redis.set('demandes:active:count', count, 'EX', 300); // 5 min ``` --- ## 12. Migration et Maintenance ### 12.1 Scripts de migration **Utiliser un outil comme `node-pg-migrate` :** ```javascript // migrations/001_initial_schema.js exports.up = (pgm) => { pgm.createTable('users', { id: { type: 'varchar(64)', primaryKey: true }, password_hash: { type: 'varchar(255)', notNull: true }, // ... }); }; exports.down = (pgm) => { pgm.dropTable('users'); }; ``` ### 12.2 Backups **PostgreSQL :** ```bash # Backup quotidien automatique pg_dump -U user -h host leboncoup > backup_$(date +%Y%m%d).sql # Restauration psql -U user -h host leboncoup < backup_20250116.sql ``` --- **Date de création :** 2025-01-16 **Version :** 1.0 **Statut :** Draft pour implémentation