fix problème possible de "logique delete + create pouvait créer des conditions de concurrence"
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
41
scripts/apply-vote-function.sql
Normal file
41
scripts/apply-vote-function.sql
Normal 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;
|
||||
@@ -241,7 +241,7 @@ function AdminPageContent() {
|
||||
) : (
|
||||
<div className="grid gap-6 group/campaigns">
|
||||
{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">
|
||||
|
||||
<CardHeader className="pb-4">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Campaign[]> {
|
||||
@@ -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<Vote[]> {
|
||||
@@ -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<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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user