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;
|
(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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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">
|
<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">
|
||||||
|
|||||||
@@ -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]) => ({
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
proposition_id: propositionId,
|
||||||
amount
|
amount
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
}
|
// Utiliser la méthode atomique pour remplacer tous les votes
|
||||||
|
await voteService.replaceVotes(campaignId, participantId, votesToSave);
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user