fix problème possible de "logique delete + create pouvait créer des conditions de concurrence"

This commit is contained in:
Yannick Le Duc
2025-08-27 00:25:32 +02:00
parent ba3a7c3ea1
commit 8cfa14a693
7 changed files with 231 additions and 27 deletions

View File

@@ -312,3 +312,42 @@ BEGIN
(SELECT COALESCE(SUM(amount), 0) FROM votes WHERE campaign_id = campaign_uuid) as total_budget_voted; (SELECT COALESCE(SUM(amount), 0) FROM votes WHERE campaign_id = campaign_uuid) as total_budget_voted;
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ 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;

View File

@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
experimental: {
allowedDevOrigins: ['localhost', '127.0.0.1', '192.168.1.20', '192.168.1.0/24']
}
}; };
export default nextConfig; export default nextConfig;

View File

@@ -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;

View File

@@ -241,7 +241,7 @@ function AdminPageContent() {
) : ( ) : (
<div className="grid gap-6 group/campaigns"> <div className="grid gap-6 group/campaigns">
{campaigns.map((campaign) => ( {campaigns.map((campaign) => (
<Card key={campaign.id} className="group hover:shadow-xl hover:shadow-slate-100 dark:hover:shadow-slate-900/20 transition-all duration-300 border-slate-200 dark:border-slate-700 overflow-hidden group-hover/campaigns:opacity-50 hover:!opacity-100"> <Card key={campaign.id} className="group hover:shadow-xl hover:shadow-slate-100 dark:hover:shadow-slate-900/20 transition-all duration-300 border-slate-200 dark:border-slate-700 overflow-hidden group-hover/campaigns:opacity-30 hover:!opacity-100">
<div className="relative"> <div className="relative">
<CardHeader className="pb-4"> <CardHeader className="pb-4">

View File

@@ -37,6 +37,31 @@ export default function PublicVotePage() {
} }
}, [campaignId, participantId]); }, [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 // Calculer le total voté à partir des votes locaux
useEffect(() => { useEffect(() => {
const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0); const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0);
@@ -91,6 +116,13 @@ export default function PublicVotePage() {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); 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([ const [campaigns, participants, propositionsData] = await Promise.all([
campaignService.getAll(), campaignService.getAll(),
participantService.getByCampaign(campaignId), participantService.getByCampaign(campaignId),
@@ -147,7 +179,23 @@ export default function PublicVotePage() {
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des données:', 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -178,28 +226,39 @@ export default function PublicVotePage() {
setError(''); setError('');
try { try {
// Supprimer tous les votes existants pour ce participant // Préparer les votes à sauvegarder (seulement ceux avec amount > 0)
const existingVotes = await voteService.getByParticipant(campaignId, participantId); const votesToSave = Object.entries(localVotes)
for (const vote of existingVotes) { .filter(([_, amount]) => amount > 0)
await voteService.delete(vote.id); .map(([propositionId, amount]) => ({
} proposition_id: propositionId,
amount
}));
// Créer les nouveaux votes // Utiliser la méthode atomique pour remplacer tous les votes
for (const [propositionId, amount] of Object.entries(localVotes)) { await voteService.replaceVotes(campaignId, participantId, votesToSave);
if (amount > 0) {
await voteService.create({
campaign_id: campaignId,
participant_id: participantId,
proposition_id: propositionId,
amount
});
}
}
setSuccess(true); setSuccess(true);
} catch (error) { } catch (error) {
console.error('Erreur lors de la validation:', 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 { } finally {
setSaving(false); setSaving(false);
} }

View File

@@ -36,6 +36,36 @@ function generateShortIdClient(): string {
return `${result}${timestamp}`; 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 // Services pour les campagnes
export const campaignService = { export const campaignService = {
async getAll(): Promise<Campaign[]> { async getAll(): Promise<Campaign[]> {
@@ -349,7 +379,7 @@ export const voteService = {
.eq('campaign_id', campaignId) .eq('campaign_id', campaignId)
.eq('participant_id', participantId); .eq('participant_id', participantId);
if (error) throw error; if (error) handleSupabaseError(error, 'récupération des votes par participant');
return data || []; return data || [];
}, },
@@ -359,7 +389,7 @@ export const voteService = {
.select('*') .select('*')
.eq('proposition_id', propositionId); .eq('proposition_id', propositionId);
if (error) throw error; if (error) handleSupabaseError(error, 'récupération des votes par proposition');
return data || []; return data || [];
}, },
@@ -371,7 +401,7 @@ export const voteService = {
.select() .select()
.single(); .single();
if (error) throw error; if (error) handleSupabaseError(error, 'création de vote');
return data; return data;
}, },
@@ -384,7 +414,7 @@ export const voteService = {
.select() .select()
.single(); .single();
if (error) throw error; if (error) handleSupabaseError(error, 'mise à jour de vote');
return data; return data;
}, },
@@ -395,7 +425,7 @@ export const voteService = {
.select() .select()
.single(); .single();
if (error) throw error; if (error) handleSupabaseError(error, 'upsert de vote');
return data; return data;
}, },
@@ -405,7 +435,7 @@ export const voteService = {
.delete() .delete()
.eq('id', id); .eq('id', id);
if (error) throw error; if (error) handleSupabaseError(error, 'suppression de vote');
}, },
async getByCampaign(campaignId: string): Promise<Vote[]> { async getByCampaign(campaignId: string): Promise<Vote[]> {
@@ -414,7 +444,7 @@ export const voteService = {
.select('*') .select('*')
.eq('campaign_id', campaignId); .eq('campaign_id', campaignId);
if (error) throw error; if (error) handleSupabaseError(error, 'récupération des votes par campagne');
return data || []; return data || [];
}, },
@@ -443,6 +473,22 @@ export const voteService = {
total_voted_amount: totalVotedAmount 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<void> {
// 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');
} }
}; };

View File

@@ -3,4 +3,20 @@ import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-key'; 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'
}
}
});