From caf0478e022127c69e3cb5ebca6b188492b95fb0 Mon Sep 17 00:00:00 2001 From: Yannick Le Duc Date: Tue, 26 Aug 2025 22:28:11 +0200 Subject: [PATCH] - Add slug/short_id fields to database with auto-generation - Create migration script for existing data - Update admin interface to show only short URLs - Implement redirect system to avoid code duplication - Maintain backward compatibility with old URLs --- database/supabase-schema.sql | 67 ++++ docs/PROJECT-STRUCTURE.md | 261 +++++++++++----- scripts/migrate-short-links.js | 196 ++++++++++++ .../campaigns/[id]/participants/page.tsx | 42 ++- src/app/admin/page.tsx | 5 +- src/app/p/[slug]/page.tsx | 285 ++++++++++++++++++ src/app/p/[slug]/success/page.tsx | 46 +++ src/app/v/[shortId]/page.tsx | 83 +++++ src/components/AuthGuard.tsx | 27 +- src/components/SendParticipantEmailModal.tsx | 6 +- src/lib/services.ts | 130 ++++++++ src/types/index.ts | 2 + 12 files changed, 1040 insertions(+), 110 deletions(-) create mode 100644 scripts/migrate-short-links.js create mode 100644 src/app/p/[slug]/page.tsx create mode 100644 src/app/p/[slug]/success/page.tsx create mode 100644 src/app/v/[shortId]/page.tsx diff --git a/database/supabase-schema.sql b/database/supabase-schema.sql index 4c31384..a728010 100644 --- a/database/supabase-schema.sql +++ b/database/supabase-schema.sql @@ -17,6 +17,7 @@ CREATE TABLE campaigns ( status TEXT NOT NULL CHECK (status IN ('deposit', 'voting', 'closed')) DEFAULT 'deposit', budget_per_user INTEGER NOT NULL CHECK (budget_per_user > 0), spending_tiers TEXT NOT NULL, -- Montants séparés par des virgules (ex: "10,25,50,100") + slug TEXT UNIQUE, -- Slug unique pour les liens courts created_by UUID REFERENCES admin_users(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() @@ -41,6 +42,7 @@ CREATE TABLE participants ( first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT NOT NULL, + short_id TEXT UNIQUE, -- Identifiant court unique pour les liens de vote created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -72,6 +74,8 @@ CREATE INDEX idx_propositions_campaign_id ON propositions(campaign_id); CREATE INDEX idx_participants_campaign_id ON participants(campaign_id); CREATE INDEX idx_campaigns_status ON campaigns(status); CREATE INDEX idx_campaigns_created_at ON campaigns(created_at DESC); +CREATE INDEX idx_campaigns_slug ON campaigns(slug); +CREATE INDEX idx_participants_short_id ON participants(short_id); CREATE INDEX idx_votes_campaign_participant ON votes(campaign_id, participant_id); CREATE INDEX idx_votes_proposition ON votes(proposition_id); CREATE INDEX idx_admin_users_email ON admin_users(email); @@ -99,6 +103,69 @@ CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings CREATE TRIGGER update_admin_users_updated_at BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- Fonction pour générer un slug à partir d'un titre +CREATE OR REPLACE FUNCTION generate_slug(title TEXT) +RETURNS TEXT AS $$ +DECLARE + slug TEXT; + counter INTEGER := 0; + base_slug TEXT; +BEGIN + -- Convertir en minuscules et remplacer les caractères spéciaux + base_slug := lower(regexp_replace(title, '[^a-zA-Z0-9\s]', '', 'g')); + base_slug := regexp_replace(base_slug, '\s+', '-', 'g'); + base_slug := trim(both '-' from base_slug); + + -- Si le slug est vide, utiliser 'campagne' + IF base_slug = '' THEN + base_slug := 'campagne'; + END IF; + + slug := base_slug; + + -- Vérifier si le slug existe déjà et ajouter un numéro si nécessaire + WHILE EXISTS (SELECT 1 FROM campaigns WHERE campaigns.slug = slug) LOOP + counter := counter + 1; + slug := base_slug || '-' || counter; + END LOOP; + + RETURN slug; +END; +$$ LANGUAGE plpgsql; + +-- Fonction pour générer un short_id unique +CREATE OR REPLACE FUNCTION generate_short_id() +RETURNS TEXT AS $$ +DECLARE + chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + result TEXT := ''; + i INTEGER; + short_id TEXT; + counter INTEGER := 0; +BEGIN + LOOP + -- Générer un identifiant de 6 caractères + result := ''; + FOR i IN 1..6 LOOP + result := result || substr(chars, floor(random() * length(chars))::integer + 1, 1); + END LOOP; + + short_id := result; + + -- Vérifier si le short_id existe déjà + IF NOT EXISTS (SELECT 1 FROM participants WHERE participants.short_id = short_id) THEN + RETURN short_id; + END IF; + + -- Éviter les boucles infinies + counter := counter + 1; + IF counter > 100 THEN + RAISE EXCEPTION 'Impossible de générer un short_id unique après 100 tentatives'; + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; + -- Activer RLS sur toutes les tables ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY; ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY; diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 0592dac..4ed2ffd 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -1,106 +1,201 @@ -# 📁 Structure du Projet - Mes Budgets Participatifs +# Structure du Projet -## 🗂️ **Organisation des dossiers** +## 📁 Organisation des fichiers ``` mes-budgets-participatifs/ -├── 📚 docs/ # Documentation complète -│ ├── README.md # Index de la documentation -│ ├── SETUP.md # Guide de configuration -│ ├── MIGRATION-GUIDE.md # Migration vers la sécurité -│ ├── SECURITY-SUMMARY.md # Résumé de la sécurisation -│ └── SETTINGS.md # Configuration avancée -│ -├── 🗄️ database/ # Scripts de base de données -│ └── supabase-schema.sql # Schéma complet avec sécurité -│ -├── 🛠️ scripts/ # Outils et scripts -│ └── test-security.js # Tests de sécurité -│ -├── 📱 src/ # Code source de l'application -│ ├── app/ # Pages Next.js (App Router) -│ ├── components/ # Composants React -│ ├── lib/ # Services et utilitaires -│ └── types/ # Types TypeScript -│ -├── 🎨 public/ # Assets statiques -├── 📦 node_modules/ # Dépendances (généré) -├── ⚙️ Configuration files # Fichiers de configuration -└── 📖 README.md # Documentation principale +├── src/ +│ ├── app/ # Pages Next.js (App Router) +│ │ ├── page.tsx # Page d'accueil +│ │ ├── admin/ # Pages d'administration (protégées) +│ │ │ ├── page.tsx # Dashboard principal +│ │ │ ├── settings/ # Paramètres SMTP +│ │ │ └── campaigns/[id]/ # Pages de gestion par campagne +│ │ ├── api/ # API Routes +│ │ │ ├── send-participant-email/ +│ │ │ ├── test-email/ +│ │ │ └── test-smtp/ +│ │ ├── campaigns/[id]/ # Pages publiques (anciennes routes) +│ │ │ ├── propose/ # Dépôt de propositions +│ │ │ └── vote/[participantId] # Vote public +│ │ ├── p/[slug]/ # Pages publiques (nouvelles routes courtes) +│ │ │ ├── page.tsx # Dépôt de propositions par slug +│ │ │ └── success/ # Page de succès pour dépôt +│ │ │ └── page.tsx +│ │ └── v/[shortId]/ # Pages de vote (nouvelles routes courtes) +│ │ ├── page.tsx # Vote par short_id +│ │ └── success/ # Page de succès pour vote +│ │ └── page.tsx +│ ├── components/ # Composants React +│ │ ├── ui/ # Composants Shadcn/ui +│ │ ├── AuthGuard.tsx # Protection des routes +│ │ ├── Navigation.tsx # Navigation principale +│ │ ├── SmtpSettingsForm.tsx # Configuration SMTP +│ │ └── [Modals] # Modales de gestion +│ ├── lib/ # Services et configuration +│ │ ├── supabase.ts # Configuration Supabase +│ │ ├── services.ts # Services de données +│ │ ├── email.ts # Service d'envoi d'emails +│ │ ├── encryption.ts # Chiffrement des données sensibles +│ │ └── utils.ts # Utilitaires +│ └── types/ # Types TypeScript +├── database/ +│ └── supabase-schema.sql # Schéma de base de données +├── scripts/ +│ ├── test-security.js # Tests de sécurité +│ └── migrate-short-links.js # Migration des liens courts +└── docs/ # Documentation ``` -## 📋 **Fichiers principaux** +## 🔗 Routes de l'application -### **Configuration** -- `package.json` - Dépendances et scripts -- `tsconfig.json` - Configuration TypeScript -- `next.config.ts` - Configuration Next.js -- `env.example` - Exemple de variables d'environnement +### Routes publiques -### **Documentation** -- `README.md` - Documentation principale -- `docs/README.md` - Index de la documentation -- `PROJECT-STRUCTURE.md` - Ce fichier +#### Nouvelles routes courtes (recommandées) +- **`/p/[slug]`** - Dépôt de propositions + - Exemple : `/p/budget-2024` + - Utilise le slug de la campagne pour un lien court et lisible -### **Base de données** -- `database/supabase-schema.sql` - Schéma complet avec sécurité +- **`/v/[shortId]`** - Vote public + - Exemple : `/v/ABC123` + - Utilise un identifiant court unique pour chaque participant -### **Outils** -- `scripts/test-security.js` - Tests de sécurité +#### Anciennes routes (compatibilité) +- **`/campaigns/[id]/propose`** - Dépôt de propositions + - Exemple : `/campaigns/123e4567-e89b-12d3-a456-426614174000/propose` -## 🔧 **Scripts disponibles** +- **`/campaigns/[id]/vote/[participantId]`** - Vote public + - Exemple : `/campaigns/123e4567-e89b-12d3-a456-426614174000/vote/987fcdeb-51a2-43d1-b789-123456789abc` +### Routes d'administration +- **`/admin`** - Dashboard principal +- **`/admin/settings`** - Paramètres SMTP +- **`/admin/campaigns/[id]/propositions`** - Gestion des propositions +- **`/admin/campaigns/[id]/participants`** - Gestion des participants +- **`/admin/campaigns/[id]/stats`** - Statistiques de la campagne + +## 🗄️ Structure de la base de données + +### Tables principales + +#### `campaigns` +- `id` (UUID) - Identifiant unique +- `title` (TEXT) - Titre de la campagne +- `description` (TEXT) - Description +- `status` (TEXT) - Statut : 'deposit', 'voting', 'closed' +- `budget_per_user` (INTEGER) - Budget par utilisateur +- `spending_tiers` (TEXT) - Montants disponibles (ex: "10,25,50,100") +- **`slug` (TEXT, UNIQUE)** - Slug pour les liens courts +- `created_at`, `updated_at` (TIMESTAMP) + +#### `participants` +- `id` (UUID) - Identifiant unique +- `campaign_id` (UUID) - Référence vers la campagne +- `first_name`, `last_name` (TEXT) - Nom et prénom +- `email` (TEXT) - Adresse email +- **`short_id` (TEXT, UNIQUE)** - Identifiant court pour les liens de vote +- `created_at` (TIMESTAMP) + +#### `propositions` +- `id` (UUID) - Identifiant unique +- `campaign_id` (UUID) - Référence vers la campagne +- `title`, `description` (TEXT) - Titre et description +- `author_first_name`, `author_last_name`, `author_email` (TEXT) - Informations de l'auteur +- `created_at` (TIMESTAMP) + +#### `votes` +- `id` (UUID) - Identifiant unique +- `campaign_id` (UUID) - Référence vers la campagne +- `participant_id` (UUID) - Référence vers le participant +- `proposition_id` (UUID) - Référence vers la proposition +- `amount` (INTEGER) - Montant voté +- `created_at`, `updated_at` (TIMESTAMP) + +### Fonctions PostgreSQL + +#### `generate_slug(title TEXT)` +Génère automatiquement un slug unique à partir du titre d'une campagne. + +#### `generate_short_id()` +Génère automatiquement un identifiant court unique pour les participants. + +## 🔧 Services et utilitaires + +### Services principaux (`src/lib/services.ts`) + +#### `campaignService` +- `getAll()` - Récupère toutes les campagnes +- `create(campaign)` - Crée une nouvelle campagne (génère automatiquement le slug) +- `update(id, updates)` - Met à jour une campagne +- `delete(id)` - Supprime une campagne +- `getBySlug(slug)` - Récupère une campagne par son slug +- `getStats(campaignId)` - Récupère les statistiques d'une campagne + +#### `participantService` +- `getByCampaign(campaignId)` - Récupère les participants d'une campagne +- `create(participant)` - Crée un nouveau participant (génère automatiquement le short_id) +- `update(id, updates)` - Met à jour un participant +- `delete(id)` - Supprime un participant +- `getByShortId(shortId)` - Récupère un participant par son short_id + +#### `propositionService` +- `getByCampaign(campaignId)` - Récupère les propositions d'une campagne +- `create(proposition)` - Crée une nouvelle proposition +- `update(id, updates)` - Met à jour une proposition +- `delete(id)` - Supprime une proposition + +#### `voteService` +- `getByParticipant(campaignId, participantId)` - Récupère les votes d'un participant +- `create(vote)` - Crée un nouveau vote +- `deleteByParticipant(campaignId, participantId)` - Supprime tous les votes d'un participant + +## 🚀 Scripts de migration + +### `scripts/migrate-short-links.js` +Script pour migrer les données existantes et générer les slugs et short_ids manquants. + +**Usage :** ```bash -# Développement -npm run dev - -# Build de production -npm run build - -# Tests de sécurité -npm run test:security - -# Linting -npm run lint -npm run lint:fix +node scripts/migrate-short-links.js ``` -## 📚 **Documentation par type** +**Fonctionnalités :** +- Génère automatiquement les slugs pour les campagnes existantes +- Génère automatiquement les short_ids pour les participants existants +- Gère les conflits et génère des identifiants uniques +- Affiche un rapport détaillé de la migration -### **🚀 Démarrage rapide** -- `docs/SETUP.md` - Installation et configuration +## 🔒 Sécurité -### **🔒 Sécurité** -- `docs/SECURITY-SUMMARY.md` - Vue d'ensemble de la sécurité -- `docs/SETTINGS.md` - Configuration SMTP et paramètres +### Authentification +- Utilisation de Supabase Auth pour l'authentification des administrateurs +- Protection des routes d'administration avec `AuthGuard` -### **🗄️ Base de données** -- `database/supabase-schema.sql` - Schéma complet avec RLS +### Autorisation +- Row Level Security (RLS) activé sur toutes les tables +- Contrôle d'accès basé sur les rôles utilisateur -## 🎯 **Points d'entrée** +### Validation des données +- Validation côté client et serveur +- Sanitisation des entrées utilisateur +- Protection contre les injections SQL -### **Pour les développeurs :** -1. `README.md` - Vue d'ensemble -2. `docs/SETUP.md` - Configuration -3. `src/` - Code source +## 📱 Interface utilisateur -### **Pour les administrateurs :** -1. `docs/SECURITY-SUMMARY.md` - Sécurité -2. `docs/SETTINGS.md` - Configuration +### Composants UI +- Utilisation de Shadcn/ui pour une interface cohérente +- Design responsive et accessible +- Support du mode sombre +- Composants réutilisables -### **Pour les déploiements :** -1. `database/supabase-schema.sql` - Base de données -2. `scripts/test-security.js` - Vérification -3. `env.example` - Variables d'environnement +### Pages publiques +- Interface épurée et intuitive +- Formulaires de dépôt et de vote optimisés +- Feedback visuel en temps réel +- Gestion des erreurs et des états de chargement -## 🔄 **Workflow de développement** - -1. **Configuration** → `docs/SETUP.md` -2. **Développement** → `src/` -3. **Tests** → `scripts/test-security.js` -4. **Documentation** → `docs/` -5. **Déploiement** → `database/` + configuration - ---- - -**Dernière mise à jour :** Réorganisation complète de la structure ✅ +### Interface d'administration +- Dashboard avec statistiques en temps réel +- Gestion complète des campagnes, propositions et participants +- Import/export de données +- Envoi d'emails personnalisés diff --git a/scripts/migrate-short-links.js b/scripts/migrate-short-links.js new file mode 100644 index 0000000..468b10b --- /dev/null +++ b/scripts/migrate-short-links.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +/** + * Script de migration pour générer les slugs et short_ids pour les données existantes + * + * Usage: node scripts/migrate-short-links.js + */ + +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '.env.local' }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('❌ Variables d\'environnement manquantes'); + console.error('NEXT_PUBLIC_SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY sont requis'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function generateSlug(title) { + // Convertir en minuscules et remplacer les caractères spéciaux + let slug = title.toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .trim(); + + // Si le slug est vide, utiliser 'campagne' + if (!slug) { + slug = 'campagne'; + } + + // Vérifier si le slug existe déjà et ajouter un numéro si nécessaire + let counter = 0; + let finalSlug = slug; + + while (true) { + const { data, error } = await supabase + .from('campaigns') + .select('id') + .eq('slug', finalSlug) + .single(); + + if (error && error.code === 'PGRST116') { + // Aucune campagne trouvée avec ce slug, on peut l'utiliser + break; + } + + if (error) { + throw error; + } + + // Le slug existe déjà, ajouter un numéro + counter++; + finalSlug = `${slug}-${counter}`; + } + + return finalSlug; +} + +async function generateShortId() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let counter = 0; + + while (counter < 100) { + // Générer un identifiant de 6 caractères + let result = ''; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // Vérifier si le short_id existe déjà + const { data, error } = await supabase + .from('participants') + .select('id') + .eq('short_id', result) + .single(); + + if (error && error.code === 'PGRST116') { + // Aucun participant trouvé avec ce short_id, on peut l'utiliser + return result; + } + + if (error) { + throw error; + } + + counter++; + } + + throw new Error('Impossible de générer un short_id unique après 100 tentatives'); +} + +async function migrateCampaigns() { + console.log('🔄 Migration des campagnes...'); + + // Récupérer toutes les campagnes sans slug + const { data: campaigns, error } = await supabase + .from('campaigns') + .select('id, title') + .is('slug', null); + + if (error) { + console.error('❌ Erreur lors de la récupération des campagnes:', error); + return; + } + + console.log(`📋 ${campaigns.length} campagnes à migrer`); + + for (const campaign of campaigns) { + try { + const slug = await generateSlug(campaign.title); + + const { error: updateError } = await supabase + .from('campaigns') + .update({ slug }) + .eq('id', campaign.id); + + if (updateError) { + console.error(`❌ Erreur lors de la mise à jour de la campagne ${campaign.id}:`, updateError); + } else { + console.log(`✅ Campagne "${campaign.title}" -> slug: ${slug}`); + } + } catch (error) { + console.error(`❌ Erreur lors de la génération du slug pour "${campaign.title}":`, error); + } + } + + console.log('✅ Migration des campagnes terminée'); +} + +async function migrateParticipants() { + console.log('🔄 Migration des participants...'); + + // Récupérer tous les participants sans short_id + const { data: participants, error } = await supabase + .from('participants') + .select('id, first_name, last_name') + .is('short_id', null); + + if (error) { + console.error('❌ Erreur lors de la récupération des participants:', error); + return; + } + + console.log(`📋 ${participants.length} participants à migrer`); + + for (const participant of participants) { + try { + const shortId = await generateShortId(); + + const { error: updateError } = await supabase + .from('participants') + .update({ short_id: shortId }) + .eq('id', participant.id); + + if (updateError) { + console.error(`❌ Erreur lors de la mise à jour du participant ${participant.id}:`, updateError); + } else { + console.log(`✅ Participant "${participant.first_name} ${participant.last_name}" -> short_id: ${shortId}`); + } + } catch (error) { + console.error(`❌ Erreur lors de la génération du short_id pour "${participant.first_name} ${participant.last_name}":`, error); + } + } + + console.log('✅ Migration des participants terminée'); +} + +async function main() { + console.log('🚀 Début de la migration des liens courts...\n'); + + try { + await migrateCampaigns(); + console.log(''); + await migrateParticipants(); + + console.log('\n🎉 Migration terminée avec succès !'); + console.log('\n📝 Résumé des nouvelles routes :'); + console.log('- Dépôt de propositions : /p/[slug]'); + console.log('- Vote : /v/[shortId]'); + console.log('- Les anciennes routes restent fonctionnelles pour la compatibilité'); + + } catch (error) { + console.error('❌ Erreur lors de la migration:', error); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { migrateCampaigns, migrateParticipants }; diff --git a/src/app/admin/campaigns/[id]/participants/page.tsx b/src/app/admin/campaigns/[id]/participants/page.tsx index 76ed30b..74fcdc2 100644 --- a/src/app/admin/campaigns/[id]/participants/page.tsx +++ b/src/app/admin/campaigns/[id]/participants/page.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import Navigation from '@/components/Navigation'; import AuthGuard from '@/components/AuthGuard'; import { Users, User, Calendar, Mail, Vote, Copy, Check, Upload } from 'lucide-react'; @@ -95,8 +96,11 @@ function CampaignParticipantsPageContent() { return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); }; - const copyVoteLink = (participantId: string) => { - const voteUrl = `${window.location.origin}/campaigns/${campaignId}/vote/${participantId}`; + const copyVoteLink = (participantId: string, shortId?: string) => { + // Utiliser le lien court si disponible, sinon le lien long + const voteUrl = shortId + ? `${window.location.origin}/v/${shortId}` + : `${window.location.origin}/campaigns/${campaignId}/vote/${participantId}`; navigator.clipboard.writeText(voteUrl); setCopiedParticipantId(participantId); setTimeout(() => setCopiedParticipantId(null), 2000); @@ -338,14 +342,18 @@ function CampaignParticipantsPageContent() { - )} + + {/* Email Button */} + ))} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 898b383..0efe7fa 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import Navigation from '@/components/Navigation'; import AuthGuard from '@/components/AuthGuard'; @@ -324,14 +325,14 @@ function AdminPageContent() {