From 8cfa14a6931bf4cb32b33726816119f578ddb209 Mon Sep 17 00:00:00 2001 From: Yannick Le Duc Date: Wed, 27 Aug 2025 00:25:32 +0200 Subject: [PATCH] =?UTF-8?q?fix=20probl=C3=A8me=20possible=20de=20"logique?= =?UTF-8?q?=20delete=20+=20create=20pouvait=20cr=C3=A9er=20des=20condition?= =?UTF-8?q?s=20de=20concurrence"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/supabase-schema.sql | 39 ++++++++ next.config.ts | 3 + scripts/apply-vote-function.sql | 41 ++++++++ src/app/admin/page.tsx | 2 +- .../[id]/vote/[participantId]/page.tsx | 95 +++++++++++++++---- src/lib/services.ts | 60 ++++++++++-- src/lib/supabase.ts | 18 +++- 7 files changed, 231 insertions(+), 27 deletions(-) create mode 100644 scripts/apply-vote-function.sql diff --git a/database/supabase-schema.sql b/database/supabase-schema.sql index a728010..ed1e33f 100644 --- a/database/supabase-schema.sql +++ b/database/supabase-schema.sql @@ -312,3 +312,42 @@ BEGIN (SELECT COALESCE(SUM(amount), 0) FROM votes WHERE campaign_id = campaign_uuid) as total_budget_voted; END; $$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Fonction pour remplacer tous les votes d'un participant de manière atomique +CREATE OR REPLACE FUNCTION replace_participant_votes( + p_campaign_id UUID, + p_participant_id UUID, + p_votes JSONB +) +RETURNS VOID AS $$ +DECLARE + vote_record RECORD; +BEGIN + -- Commencer une transaction + BEGIN + -- Supprimer tous les votes existants pour ce participant dans cette campagne + DELETE FROM votes + WHERE campaign_id = p_campaign_id + AND participant_id = p_participant_id; + + -- Insérer les nouveaux votes + FOR vote_record IN + SELECT * FROM jsonb_array_elements(p_votes) + LOOP + INSERT INTO votes (campaign_id, participant_id, proposition_id, amount) + VALUES ( + p_campaign_id, + p_participant_id, + (vote_record.value->>'proposition_id')::UUID, + (vote_record.value->>'amount')::INTEGER + ); + END LOOP; + + -- La transaction sera automatiquement commitée si tout va bien + EXCEPTION + WHEN OTHERS THEN + -- En cas d'erreur, la transaction sera automatiquement rollbackée + RAISE EXCEPTION 'Erreur lors du remplacement des votes: %', SQLERRM; + END; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/next.config.ts b/next.config.ts index e9ffa30..25b857f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + experimental: { + allowedDevOrigins: ['localhost', '127.0.0.1', '192.168.1.20', '192.168.1.0/24'] + } }; export default nextConfig; diff --git a/scripts/apply-vote-function.sql b/scripts/apply-vote-function.sql new file mode 100644 index 0000000..6b94f3c --- /dev/null +++ b/scripts/apply-vote-function.sql @@ -0,0 +1,41 @@ +-- Script pour appliquer la fonction replace_participant_votes +-- À exécuter dans votre base de données Supabase + +-- Fonction pour remplacer tous les votes d'un participant de manière atomique +CREATE OR REPLACE FUNCTION replace_participant_votes( + p_campaign_id UUID, + p_participant_id UUID, + p_votes JSONB +) +RETURNS VOID AS $$ +DECLARE + vote_record RECORD; +BEGIN + -- Commencer une transaction + BEGIN + -- Supprimer tous les votes existants pour ce participant dans cette campagne + DELETE FROM votes + WHERE campaign_id = p_campaign_id + AND participant_id = p_participant_id; + + -- Insérer les nouveaux votes + FOR vote_record IN + SELECT * FROM jsonb_array_elements(p_votes) + LOOP + INSERT INTO votes (campaign_id, participant_id, proposition_id, amount) + VALUES ( + p_campaign_id, + p_participant_id, + (vote_record.value->>'proposition_id')::UUID, + (vote_record.value->>'amount')::INTEGER + ); + END LOOP; + + -- La transaction sera automatiquement commitée si tout va bien + EXCEPTION + WHEN OTHERS THEN + -- En cas d'erreur, la transaction sera automatiquement rollbackée + RAISE EXCEPTION 'Erreur lors du remplacement des votes: %', SQLERRM; + END; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 11cb0ef..361d89d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -241,7 +241,7 @@ function AdminPageContent() { ) : (
{campaigns.map((campaign) => ( - +
diff --git a/src/app/campaigns/[id]/vote/[participantId]/page.tsx b/src/app/campaigns/[id]/vote/[participantId]/page.tsx index e0b0343..3768f43 100644 --- a/src/app/campaigns/[id]/vote/[participantId]/page.tsx +++ b/src/app/campaigns/[id]/vote/[participantId]/page.tsx @@ -37,6 +37,31 @@ export default function PublicVotePage() { } }, [campaignId, participantId]); + // Écouter les changements de connectivité réseau + useEffect(() => { + const handleOnline = () => { + console.log('Connexion réseau rétablie'); + setError(''); + }; + + const handleOffline = () => { + console.log('Connexion réseau perdue'); + setError('Connexion réseau perdue. Veuillez vérifier votre connexion internet.'); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + } + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + } + }; + }, []); + // Calculer le total voté à partir des votes locaux useEffect(() => { const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0); @@ -91,6 +116,13 @@ export default function PublicVotePage() { const loadData = async () => { try { setLoading(true); + setError(''); + + // Vérifier la connectivité réseau + if (typeof window !== 'undefined' && !navigator.onLine) { + throw new Error('Pas de connexion internet. Veuillez vérifier votre connexion réseau.'); + } + const [campaigns, participants, propositionsData] = await Promise.all([ campaignService.getAll(), participantService.getByCampaign(campaignId), @@ -147,7 +179,23 @@ export default function PublicVotePage() { } catch (error) { console.error('Erreur lors du chargement des données:', error); - setError('Erreur lors du chargement des données'); + let errorMessage = 'Erreur lors du chargement des données'; + + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'object' && error !== null) { + // Essayer d'extraire plus d'informations de l'erreur + const errorObj = error as any; + if (errorObj.message) { + errorMessage = errorObj.message; + } else if (errorObj.error) { + errorMessage = errorObj.error; + } else if (errorObj.details) { + errorMessage = errorObj.details; + } + } + + setError(errorMessage); } finally { setLoading(false); } @@ -178,28 +226,39 @@ export default function PublicVotePage() { setError(''); try { - // Supprimer tous les votes existants pour ce participant - const existingVotes = await voteService.getByParticipant(campaignId, participantId); - for (const vote of existingVotes) { - await voteService.delete(vote.id); - } + // Préparer les votes à sauvegarder (seulement ceux avec amount > 0) + const votesToSave = Object.entries(localVotes) + .filter(([_, amount]) => amount > 0) + .map(([propositionId, amount]) => ({ + proposition_id: propositionId, + amount + })); - // Créer les nouveaux votes - for (const [propositionId, amount] of Object.entries(localVotes)) { - if (amount > 0) { - await voteService.create({ - campaign_id: campaignId, - participant_id: participantId, - proposition_id: propositionId, - amount - }); - } - } + // Utiliser la méthode atomique pour remplacer tous les votes + await voteService.replaceVotes(campaignId, participantId, votesToSave); setSuccess(true); } catch (error) { console.error('Erreur lors de la validation:', error); - setError('Erreur lors de la validation des votes'); + + // Améliorer l'affichage de l'erreur + let errorMessage = 'Erreur lors de la validation des votes'; + + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'object' && error !== null) { + // Essayer d'extraire plus d'informations de l'erreur + const errorObj = error as any; + if (errorObj.message) { + errorMessage = errorObj.message; + } else if (errorObj.error) { + errorMessage = errorObj.error; + } else if (errorObj.details) { + errorMessage = errorObj.details; + } + } + + setError(errorMessage); } finally { setSaving(false); } diff --git a/src/lib/services.ts b/src/lib/services.ts index 511f376..5b62b64 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -36,6 +36,36 @@ function generateShortIdClient(): string { return `${result}${timestamp}`; } +// Fonction utilitaire pour gérer les erreurs Supabase +function handleSupabaseError(error: any, operation: string): never { + console.error(`Erreur Supabase lors de ${operation}:`, error); + + // Extraire les détails de l'erreur + let errorMessage = `Erreur lors de ${operation}`; + + if (error?.message) { + errorMessage = error.message; + } else if (error?.error_description) { + errorMessage = error.error_description; + } else if (error?.details) { + errorMessage = error.details; + } else if (typeof error === 'string') { + errorMessage = error; + } + + // Ajouter des informations de débogage + const debugInfo = { + operation, + error, + timestamp: new Date().toISOString(), + userAgent: typeof window !== 'undefined' ? window.navigator.userAgent : 'server' + }; + + console.error('Informations de débogage:', debugInfo); + + throw new Error(errorMessage); +} + // Services pour les campagnes export const campaignService = { async getAll(): Promise { @@ -349,7 +379,7 @@ export const voteService = { .eq('campaign_id', campaignId) .eq('participant_id', participantId); - if (error) throw error; + if (error) handleSupabaseError(error, 'récupération des votes par participant'); return data || []; }, @@ -359,7 +389,7 @@ export const voteService = { .select('*') .eq('proposition_id', propositionId); - if (error) throw error; + if (error) handleSupabaseError(error, 'récupération des votes par proposition'); return data || []; }, @@ -371,7 +401,7 @@ export const voteService = { .select() .single(); - if (error) throw error; + if (error) handleSupabaseError(error, 'création de vote'); return data; }, @@ -384,7 +414,7 @@ export const voteService = { .select() .single(); - if (error) throw error; + if (error) handleSupabaseError(error, 'mise à jour de vote'); return data; }, @@ -395,7 +425,7 @@ export const voteService = { .select() .single(); - if (error) throw error; + if (error) handleSupabaseError(error, 'upsert de vote'); return data; }, @@ -405,7 +435,7 @@ export const voteService = { .delete() .eq('id', id); - if (error) throw error; + if (error) handleSupabaseError(error, 'suppression de vote'); }, async getByCampaign(campaignId: string): Promise { @@ -414,7 +444,7 @@ export const voteService = { .select('*') .eq('campaign_id', campaignId); - if (error) throw error; + if (error) handleSupabaseError(error, 'récupération des votes par campagne'); return data || []; }, @@ -443,6 +473,22 @@ export const voteService = { total_voted_amount: totalVotedAmount }; }); + }, + + // Méthode pour remplacer tous les votes d'un participant de manière atomique + async replaceVotes( + campaignId: string, + participantId: string, + votes: Array<{ proposition_id: string; amount: number }> + ): Promise { + // Utiliser une transaction pour garantir l'atomicité + const { error } = await supabase.rpc('replace_participant_votes', { + p_campaign_id: campaignId, + p_participant_id: participantId, + p_votes: votes + }); + + if (error) handleSupabaseError(error, 'remplacement des votes du participant'); } }; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index d7f60c6..b8f16c6 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -3,4 +3,20 @@ import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-key'; -export const supabase = createClient(supabaseUrl, supabaseAnonKey); +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: true + }, + realtime: { + params: { + eventsPerSecond: 10 + } + }, + global: { + headers: { + 'X-Client-Info': 'mes-budgets-participatifs' + } + } +});