Files
mes-budgets-participatifs/src/lib/services.ts

666 lines
21 KiB
TypeScript

import { supabase } from './supabase';
import { Campaign, Proposition, Participant, Vote, ParticipantWithVoteStatus, Setting, SmtpSettings } from '@/types';
import { encryptionService } from './encryption';
import { emailService } from './email';
// Fonction utilitaire pour générer un slug côté client
function generateSlugClient(title: string): string {
// 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';
}
// Ajouter un timestamp pour éviter les conflits
const timestamp = Date.now().toString().slice(-6);
return `${slug}-${timestamp}`;
}
// Fonction utilitaire pour générer un short_id côté client
function generateShortIdClient(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
// Générer un identifiant de 6 caractères
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
// Ajouter un timestamp pour éviter les conflits
const timestamp = Date.now().toString().slice(-3);
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[]> {
const { data, error } = await supabase
.from('campaigns')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(campaign: any): Promise<Campaign> {
// Générer automatiquement le slug si non fourni
if (!campaign.slug) {
try {
// Essayer d'utiliser la fonction PostgreSQL
const { data: slugData, error: slugError } = await supabase
.rpc('generate_slug', { title: campaign.title });
if (slugError) {
// Si la fonction n'existe pas, générer le slug côté client
console.warn('Fonction generate_slug non disponible, génération côté client:', slugError);
campaign.slug = generateSlugClient(campaign.title);
} else {
campaign.slug = slugData;
}
} catch (error) {
// Fallback vers la génération côté client
console.warn('Erreur avec generate_slug, génération côté client:', error);
campaign.slug = generateSlugClient(campaign.title);
}
}
const { data, error } = await supabase
.from('campaigns')
.insert(campaign)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Campaign> {
// Générer automatiquement le slug si le titre a changé et qu'aucun slug n'est fourni
if (updates.title && !updates.slug) {
try {
// Essayer d'utiliser la fonction PostgreSQL
const { data: slugData, error: slugError } = await supabase
.rpc('generate_slug', { title: updates.title });
if (slugError) {
// Si la fonction n'existe pas, générer le slug côté client
console.warn('Fonction generate_slug non disponible, génération côté client:', slugError);
updates.slug = generateSlugClient(updates.title);
} else {
updates.slug = slugData;
}
} catch (error) {
// Fallback vers la génération côté client
console.warn('Erreur avec generate_slug, génération côté client:', error);
updates.slug = generateSlugClient(updates.title);
}
}
const { data, error } = await supabase
.from('campaigns')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data;
},
async delete(id: string): Promise<void> {
console.log('Tentative de suppression de la campagne:', id);
// La suppression en cascade est gérée par la base de données
// grâce à ON DELETE CASCADE dans les contraintes de clés étrangères
const { error } = await supabase
.from('campaigns')
.delete()
.eq('id', id);
if (error) {
console.error('Erreur lors de la suppression:', error);
throw error;
}
console.log('Campagne supprimée avec succès');
},
async getStats(campaignId: string): Promise<{ propositions: number; participants: number }> {
const [propositionsResult, participantsResult] = await Promise.all([
supabase
.from('propositions')
.select('id', { count: 'exact', head: true })
.eq('campaign_id', campaignId),
supabase
.from('participants')
.select('id', { count: 'exact', head: true })
.eq('campaign_id', campaignId)
]);
if (propositionsResult.error) throw propositionsResult.error;
if (participantsResult.error) throw participantsResult.error;
return {
propositions: propositionsResult.count || 0,
participants: participantsResult.count || 0
};
},
// Nouvelle méthode pour récupérer une campagne par slug
async getBySlug(slug: string): Promise<Campaign | null> {
const { data, error } = await supabase
.from('campaigns')
.select('*')
.eq('slug', slug)
.single();
if (error) {
if (error.code === 'PGRST116') {
return null; // Aucune campagne trouvée
}
throw error;
}
return data;
}
};
// Services pour les propositions
export const propositionService = {
async getByCampaign(campaignId: string): Promise<Proposition[]> {
const { data, error } = await supabase
.from('propositions')
.select('*')
.eq('campaign_id', campaignId)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(proposition: any): Promise<Proposition> {
const { data, error } = await supabase
.from('propositions')
.insert(proposition)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Proposition> {
try {
// Effectuer la mise à jour directement
const { data, error } = await supabase
.from('propositions')
.update(updates)
.eq('id', id)
.select('*')
.single();
if (error) {
console.error('Erreur Supabase lors de la mise à jour:', error);
if (error.code === 'PGRST116') {
throw new Error(`Proposition avec l'ID ${id} non trouvée`);
}
throw new Error(`Erreur lors de la mise à jour: ${error.message || 'Erreur inconnue'}`);
}
if (!data) {
throw new Error('Aucune donnée retournée après la mise à jour');
}
return data;
} catch (error: any) {
console.error('Erreur dans propositionService.update:', error);
throw error;
}
},
async delete(id: string): Promise<void> {
const { error } = await supabase
.from('propositions')
.delete()
.eq('id', id);
if (error) throw error;
}
};
// Services pour les participants
export const participantService = {
async getByCampaign(campaignId: string): Promise<Participant[]> {
const { data, error } = await supabase
.from('participants')
.select('*')
.eq('campaign_id', campaignId)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(participant: any): Promise<Participant> {
// Générer automatiquement le short_id si non fourni
if (!participant.short_id) {
try {
// Essayer d'utiliser la fonction PostgreSQL
const { data: shortIdData, error: shortIdError } = await supabase
.rpc('generate_short_id');
if (shortIdError) {
// Si la fonction n'existe pas, générer le short_id côté client
console.warn('Fonction generate_short_id non disponible, génération côté client:', shortIdError);
participant.short_id = generateShortIdClient();
} else {
participant.short_id = shortIdData;
}
} catch (error) {
// Fallback vers la génération côté client
console.warn('Erreur avec generate_short_id, génération côté client:', error);
participant.short_id = generateShortIdClient();
}
}
const { data, error } = await supabase
.from('participants')
.insert(participant)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Participant> {
try {
// Effectuer la mise à jour directement
const { data, error } = await supabase
.from('participants')
.update(updates)
.eq('id', id)
.select('*')
.single();
if (error) {
console.error('Erreur Supabase lors de la mise à jour du participant:', error);
if (error.code === 'PGRST116') {
throw new Error(`Participant avec l'ID ${id} non trouvé`);
}
throw new Error(`Erreur lors de la mise à jour du participant: ${error.message || 'Erreur inconnue'}`);
}
if (!data) {
throw new Error('Aucune donnée retournée après la mise à jour du participant');
}
return data;
} catch (error: any) {
console.error('Erreur dans participantService.update:', error);
throw error;
}
},
async delete(id: string): Promise<void> {
const { error } = await supabase
.from('participants')
.delete()
.eq('id', id);
if (error) throw error;
},
// Nouvelle méthode pour récupérer un participant par short_id
async getByShortId(shortId: string): Promise<Participant | null> {
const { data, error } = await supabase
.from('participants')
.select('*')
.eq('short_id', shortId)
.single();
if (error) {
if (error.code === 'PGRST116') {
return null; // Aucun participant trouvé
}
throw error;
}
return data;
}
};
// Services pour les votes
export const voteService = {
async getByParticipant(campaignId: string, participantId: string): Promise<Vote[]> {
const { data, error } = await supabase
.from('votes')
.select('*')
.eq('campaign_id', campaignId)
.eq('participant_id', participantId);
if (error) handleSupabaseError(error, 'récupération des votes par participant');
return data || [];
},
async getByProposition(propositionId: string): Promise<Vote[]> {
const { data, error } = await supabase
.from('votes')
.select('*')
.eq('proposition_id', propositionId);
if (error) handleSupabaseError(error, 'récupération des votes par proposition');
return data || [];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(vote: any): Promise<Vote> {
const { data, error } = await supabase
.from('votes')
.insert(vote)
.select()
.single();
if (error) handleSupabaseError(error, 'création de vote');
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(id: string, updates: any): Promise<Vote> {
const { data, error } = await supabase
.from('votes')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) handleSupabaseError(error, 'mise à jour de vote');
return data;
},
async upsert(vote: { campaign_id: string; participant_id: string; proposition_id: string; amount: number }): Promise<Vote> {
const { data, error } = await supabase
.from('votes')
.upsert(vote, { onConflict: 'participant_id,proposition_id' })
.select()
.single();
if (error) handleSupabaseError(error, 'upsert de vote');
return data;
},
async delete(id: string): Promise<void> {
const { error } = await supabase
.from('votes')
.delete()
.eq('id', id);
if (error) handleSupabaseError(error, 'suppression de vote');
},
async getByCampaign(campaignId: string): Promise<Vote[]> {
const { data, error } = await supabase
.from('votes')
.select('*')
.eq('campaign_id', campaignId);
if (error) handleSupabaseError(error, 'récupération des votes par campagne');
return data || [];
},
async getParticipantVoteStatus(campaignId: string): Promise<ParticipantWithVoteStatus[]> {
const { data: participants, error: participantsError } = await supabase
.from('participants')
.select('*')
.eq('campaign_id', campaignId);
if (participantsError) throw participantsError;
const { data: votes, error: votesError } = await supabase
.from('votes')
.select('*')
.eq('campaign_id', campaignId);
if (votesError) throw votesError;
return participants.map(participant => {
const participantVotes = votes.filter(vote => vote.participant_id === participant.id);
const totalVotedAmount = participantVotes.reduce((sum, vote) => sum + vote.amount, 0);
return {
...participant,
has_voted: participantVotes.length > 0,
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');
}
};
// Services pour les paramètres
export const settingsService = {
async getAll(): Promise<Setting[]> {
const { data, error } = await supabase
.from('settings')
.select('*')
.order('category', { ascending: true })
.order('key', { ascending: true });
if (error) throw error;
return data || [];
},
async getByCategory(category: string): Promise<Setting[]> {
const { data, error } = await supabase
.from('settings')
.select('*')
.eq('category', category)
.order('key', { ascending: true });
if (error) throw error;
return data || [];
},
async getByKey(key: string): Promise<Setting | null> {
const { data, error } = await supabase
.from('settings')
.select('*')
.eq('key', key)
.single();
if (error) {
if (error.code === 'PGRST116') return null; // No rows returned
throw error;
}
return data;
},
async getValue(key: string, defaultValue: string = ''): Promise<string> {
const setting = await this.getByKey(key);
return setting?.value || defaultValue;
},
async getBooleanValue(key: string, defaultValue: boolean = false): Promise<boolean> {
const value = await this.getValue(key, defaultValue.toString());
return value === 'true';
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(setting: any): Promise<Setting> {
const { data, error } = await supabase
.from('settings')
.insert(setting)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(key: string, updates: any): Promise<Setting> {
const { data, error } = await supabase
.from('settings')
.update(updates)
.eq('key', key)
.select()
.single();
if (error) throw error;
return data;
},
async setValue(key: string, value: string): Promise<Setting> {
const existing = await this.getByKey(key);
if (existing) {
return this.update(key, { value });
} else {
return this.create({ key, value, category: 'general' });
}
},
async setBooleanValue(key: string, value: boolean): Promise<Setting> {
return this.setValue(key, value.toString());
},
async delete(key: string): Promise<void> {
const { error } = await supabase
.from('settings')
.delete()
.eq('key', key);
if (error) throw error;
},
// Méthodes spécifiques pour les paramètres SMTP
async getSmtpSettings(): Promise<SmtpSettings> {
const smtpKeys = [
'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'smtp_secure', 'smtp_from_email', 'smtp_from_name'
];
const settings = await Promise.all(
smtpKeys.map(key => this.getByKey(key))
);
return {
host: settings[0]?.value || '',
port: parseInt(settings[1]?.value || '587'),
username: settings[2]?.value || '',
password: encryptionService.isEncrypted(settings[3]?.value || '')
? encryptionService.decrypt(settings[3]?.value || '')
: settings[3]?.value || '',
secure: settings[4]?.value === 'true',
from_email: settings[5]?.value || '',
from_name: settings[6]?.value || ''
};
},
async setSmtpSettings(smtpSettings: SmtpSettings): Promise<void> {
const settingsToUpdate = [
{ key: 'smtp_host', value: smtpSettings.host, category: 'email', description: 'Serveur SMTP' },
{ key: 'smtp_port', value: smtpSettings.port.toString(), category: 'email', description: 'Port SMTP' },
{ key: 'smtp_username', value: smtpSettings.username, category: 'email', description: 'Nom d\'utilisateur SMTP' },
{ key: 'smtp_password', value: encryptionService.encrypt(smtpSettings.password), category: 'email', description: 'Mot de passe SMTP (chiffré)' },
{ key: 'smtp_secure', value: smtpSettings.secure.toString(), category: 'email', description: 'Connexion sécurisée SSL/TLS' },
{ key: 'smtp_from_email', value: smtpSettings.from_email, category: 'email', description: 'Adresse email d\'expédition' },
{ key: 'smtp_from_name', value: smtpSettings.from_name, category: 'email', description: 'Nom d\'expédition' }
];
await Promise.all(
settingsToUpdate.map(setting => this.setValue(setting.key, setting.value))
);
},
async testSmtpConnection(smtpSettings: SmtpSettings): Promise<{ success: boolean; error?: string }> {
try {
// Validation basique des paramètres
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
return { success: false, error: 'Paramètres SMTP incomplets' };
}
if (smtpSettings.port < 1 || smtpSettings.port > 65535) {
return { success: false, error: 'Port SMTP invalide' };
}
if (!smtpSettings.from_email.includes('@')) {
return { success: false, error: 'Adresse email d\'expédition invalide' };
}
// Test de connexion via API route
return await emailService.testConnection(smtpSettings);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Erreur inconnue' };
}
},
async sendTestEmail(smtpSettings: SmtpSettings, toEmail: string): Promise<{ success: boolean; error?: string; messageId?: string }> {
try {
// Validation de l'email de destination
if (!emailService.validateEmail(toEmail)) {
return { success: false, error: 'Adresse email de destination invalide' };
}
// Envoi de l'email de test via API route
return await emailService.sendTestEmail(smtpSettings, toEmail);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Erreur lors de l\'envoi de l\'email de test' };
}
}
};