espace-paie-odentas/app/api/cddu-contracts/route.ts

657 lines
No EOL
26 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { createClient, SupabaseClient, PostgrestError } from '@supabase/supabase-js';
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { v4 as uuidv4 } from 'uuid';
import { sendContractNotifications } from '@/lib/emailService';
import { resolveActiveOrg } from '@/lib/resolveActiveOrg';
type QueryResult<T> = { data: T | null; error: PostgrestError | null };
type EmployeeRow = {
id: string;
code_salarie: string;
nom: string;
prenom: string;
adresse_mail: string | null;
employer_id?: string | null;
};
type OrganizationRow = {
name: string;
organization_details?: {
email_notifs?: string | null;
email_notifs_cc?: string | null;
prenom_contact?: string | null;
code_employeur?: string | null;
} | null;
};
type ProductionRow = {
id: string;
name: string;
reference: string | null;
org_id?: string | null;
};
type NoteInsert = {
contract_id: string;
organization_id: string;
content: string;
source: string;
};
export async function POST(request: NextRequest) {
const supabase = createRouteHandlerClient({ cookies });
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
try {
const body = await request.json();
console.log("Body reçu pour création CDDU:", body);
// Générer un identifiant unique pour le contrat
const contractId = uuidv4();
const providedReference = typeof body.reference === "string" ? body.reference.trim().toUpperCase() : "";
const contractNumber = providedReference || generateContractReference();
// Détecter si l'utilisateur est staff et préparer un client service_role si disponible
let isStaff = false;
console.log('🔍 [DEBUG] Début détection staff pour user:', user.id);
try {
const { data: staffRow } = await supabase
.from('staff_users')
.select('is_staff')
.eq('user_id', user.id)
.maybeSingle();
console.log('🔍 [DEBUG] Résultat query staff_users:', staffRow);
isStaff = !!staffRow?.is_staff;
console.log('🔍 [DEBUG] isStaff depuis DB:', isStaff);
} catch (err) {
console.log('🔍 [DEBUG] Erreur query staff_users, fallback metadata:', err);
const userMeta = user.user_metadata || {};
const appMeta = user.app_metadata || {};
console.log('🔍 [DEBUG] user_metadata:', userMeta);
console.log('🔍 [DEBUG] app_metadata:', appMeta);
isStaff = Boolean(
userMeta.is_staff === true ||
userMeta.role === 'staff' ||
(Array.isArray(appMeta?.roles) && appMeta.roles.includes('staff'))
);
console.log('🔍 [DEBUG] isStaff depuis metadata:', isStaff);
}
const serviceSupabase: SupabaseClient | null =
process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY
? createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
})
: null;
if (isStaff && !serviceSupabase) {
console.error('Service role non configuré : impossible de traiter une création staff.');
return NextResponse.json(
{ error: 'Configuration Supabase incomplète pour le mode staff' },
{ status: 500 }
);
}
const clients: SupabaseClient[] = [];
if (isStaff && serviceSupabase) {
clients.push(serviceSupabase);
}
clients.push(supabase);
if (!isStaff && serviceSupabase) {
clients.push(serviceSupabase);
}
const runOnClients = async <T>(fn: (client: SupabaseClient, index: number) => Promise<QueryResult<T>>): Promise<QueryResult<T>> => {
let lastResult: QueryResult<T> = { data: null, error: null };
console.log(`🔍 [DEBUG] runOnClients démarré avec ${clients.length} clients`);
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
console.log(`🔍 [DEBUG] Essai avec client ${i + 1}/${clients.length}`);
const result = await fn(client, i);
lastResult = result;
console.log(`🔍 [DEBUG] Client ${i + 1} résultat:`, { hasData: !!result.data, error: result.error });
if (result.data) {
console.log(`✅ [DEBUG] Données trouvées avec client ${i + 1}`);
return result;
}
if (result.error) {
const code = result.error.code;
console.log(`🔍 [DEBUG] Client ${i + 1} erreur code:`, code);
if (code && code !== 'PGRST116' && code !== 'PGRST108') {
console.log(`❌ [DEBUG] Erreur bloquante avec client ${i + 1}, arrêt`);
return result;
}
}
}
console.log(`❌ [DEBUG] Aucun client n'a trouvé de données`);
return lastResult;
};
// ✅ SÉCURITÉ : Récupérer l'organisation de l'utilisateur connecté (TOUJOURS depuis la session)
// Pour les clients : on force leur organisation (pas de confiance au body.org_id)
// Pour les staffs : on accepte l'org_id du body si fourni, sinon on résout via resolveActiveOrg
let orgId: string | null = null;
let orgName: string | null = null;
console.log('🔍 [DEBUG] isStaff:', isStaff);
console.log('🔍 [DEBUG] org_id depuis body:', body.org_id);
if (isStaff) {
// Staff : peut spécifier une organisation dans le body
const requestedOrgId = typeof body.org_id === 'string' && body.org_id.trim().length > 0 ? body.org_id.trim() : null;
if (requestedOrgId) {
console.log('🔍 [DEBUG] Staff - org_id fourni dans body:', requestedOrgId);
orgId = requestedOrgId;
} else {
console.log('🔍 [DEBUG] Staff - pas d\'org_id, utilisation de resolveActiveOrg...');
orgId = await resolveActiveOrg(supabase);
console.log('🔍 [DEBUG] Staff - Organisation résolue:', orgId);
}
} else {
// ✅ CLIENT : Ignorer body.org_id et forcer l'organisation de l'utilisateur
console.log('🔍 [DEBUG] Client - résolution automatique de l\'organisation (ignorer body.org_id)');
// Résoudre via resolveActiveOrg (qui lit organization_members)
orgId = await resolveActiveOrg(supabase);
if (!orgId) {
// Fallback : essayer via organization_members directement
const { data: member } = await supabase
.from('organization_members')
.select('org_id')
.eq('user_id', user.id)
.single();
orgId = member?.org_id || null;
}
console.log('🔍 [DEBUG] Client - Organisation finale:', orgId);
// ⚠️ Si un client essaie de forcer un org_id différent, on l'ignore et on log
if (body.org_id && body.org_id !== orgId) {
console.warn('⚠️ [SÉCURITÉ] Tentative de forcer org_id par un client:', {
userId: user.id,
userEmail: user.email,
requestedOrgId: body.org_id,
actualOrgId: orgId
});
}
}
if (!orgId) {
console.error("❌ [DEBUG] Aucune organisation trouvée pour l'utilisateur:", user.id);
return NextResponse.json({ error: 'Organisation non trouvée' }, { status: 400 });
}
console.log('✅ [DEBUG] Organisation finale:', orgId);
const salarieMatricule = (body.salarie_matricule ?? '').toString().trim();
console.log('🔍 [DEBUG] Body original salarie_matricule:', body.salarie_matricule);
console.log('🔍 [DEBUG] Matricule après trim:', salarieMatricule);
console.log('🔍 [DEBUG] Type de matricule:', typeof salarieMatricule);
console.log('🔍 [DEBUG] Longueur matricule:', salarieMatricule.length);
console.log('🔍 [DEBUG] Staff user:', isStaff);
console.log('🔍 [DEBUG] OrgId à utiliser:', orgId);
console.log('🔍 [DEBUG] Nombre de clients Supabase:', clients.length);
console.log('🔍 [DEBUG] Service role disponible:', !!serviceSupabase);
if (!salarieMatricule) {
console.error('❌ [DEBUG] Matricule vide après trim');
return NextResponse.json({ error: 'Matricule salarié manquant' }, { status: 400 });
}
console.log('🔍 [DEBUG] Début recherche employé...');
const { data: employee, error: empError } = await runOnClients<EmployeeRow>(async (client, index) => {
console.log(`🔍 [DEBUG] Essai client ${index + 1}/${clients.length}`);
const isServiceRole = client === serviceSupabase;
console.log(`🔍 [DEBUG] Client service role: ${isServiceRole}`);
const { data, error } = await client
.from('salaries')
.select('id, code_salarie, nom, prenom, adresse_mail, employer_id')
.eq('code_salarie', salarieMatricule)
.eq('employer_id', orgId)
.maybeSingle();
console.log(`🔍 [DEBUG] Client ${index + 1} - Data:`, data);
console.log(`🔍 [DEBUG] Client ${index + 1} - Error:`, error);
return { data: (data as EmployeeRow | null), error };
});
console.log('🔍 [DEBUG] Résultat final recherche employé:', { employee, empError });
if (empError || !employee) {
console.log('❌ [DEBUG] Employé non trouvé, recherche d\'exemples...');
// Essayer avec chaque client pour voir ce qui est disponible
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
const isServiceRole = client === serviceSupabase;
console.log(`🔍 [DEBUG] Recherche exemples avec client ${i + 1} (service role: ${isServiceRole})`);
// Tous les employés de cette org
const { data: allEmployeesInOrg, error: allEmpError } = await client
.from('salaries')
.select('code_salarie, nom, prenom, employer_id')
.eq('employer_id', orgId)
.limit(10);
console.log(`🔍 [DEBUG] Client ${i + 1} - Employés dans org ${orgId}:`, allEmployeesInOrg);
console.log(`🔍 [DEBUG] Client ${i + 1} - Erreur:`, allEmpError);
// Recherche par matricule sans filtrer par org
const { data: globalSearch, error: globalError } = await client
.from('salaries')
.select('code_salarie, nom, prenom, employer_id')
.eq('code_salarie', salarieMatricule)
.limit(5);
console.log(`🔍 [DEBUG] Client ${i + 1} - Recherche globale matricule ${salarieMatricule}:`, globalSearch);
console.log(`🔍 [DEBUG] Client ${i + 1} - Erreur recherche globale:`, globalError);
// Premier employé de n'importe quelle org pour voir la structure
const { data: firstEmployee, error: firstError } = await client
.from('salaries')
.select('code_salarie, nom, prenom, employer_id')
.limit(1)
.maybeSingle();
console.log(`🔍 [DEBUG] Client ${i + 1} - Premier employé (structure):`, firstEmployee);
console.log(`🔍 [DEBUG] Client ${i + 1} - Erreur premier employé:`, firstError);
}
return NextResponse.json({ error: 'Employé non trouvé' }, { status: 404 });
}
const { data: organization, error: orgError } = await runOnClients<OrganizationRow>(async (client) => {
const { data, error } = await client
.from('organizations')
.select(`
name,
organization_details!inner(
email_notifs,
email_notifs_cc,
prenom_contact,
code_employeur
)
`)
.eq('id', orgId)
.maybeSingle();
return { data: (data as OrganizationRow | null), error };
});
if (orgError || !organization) {
console.error('Erreur récupération organisation:', orgError);
return NextResponse.json({ error: 'Informations organisation non trouvées' }, { status: 404 });
}
orgName = organization.name;
// Récupérer les informations de la production
let production: ProductionRow | null = null;
let prodError: PostgrestError | null = null;
if (body.production_id) {
// Si un ID de production est fourni, récupérer par ID (production existante)
console.log('Recherche production par ID:', body.production_id);
const result = await runOnClients<ProductionRow>(async (client) => {
const { data, error } = await client
.from('productions')
.select('id, name, reference, org_id')
.eq('id', body.production_id)
.eq('org_id', orgId)
.maybeSingle();
return { data: (data as ProductionRow | null), error };
});
production = result.data;
prodError = result.error;
console.log('Résultat recherche production par ID:', { production, prodError });
} else if (body.spectacle) {
// Sinon, rechercher par nom (pour créer une nouvelle production)
console.log('Recherche production par nom:', body.spectacle);
const result = await runOnClients<ProductionRow>(async (client) => {
const { data, error } = await client
.from('productions')
.select('id, name, reference, org_id')
.eq('org_id', orgId)
.eq('name', body.spectacle)
.maybeSingle();
return { data: (data as ProductionRow | null), error };
});
production = result.data;
prodError = result.error;
console.log('Résultat recherche production par nom:', { production, prodError });
}
// Si un ID de production était fourni mais que la production n'existe pas, retourner une erreur
if (body.production_id && (prodError || !production)) {
console.error('Production non trouvée avec ID:', body.production_id, prodError);
return NextResponse.json({ error: 'Production introuvable' }, { status: 404 });
}
if (!body.production_id && (!production || prodError)) {
if (!body.spectacle) {
return NextResponse.json({ error: 'Nom du spectacle requis' }, { status: 400 });
}
console.log('Production non trouvée, création automatique...');
const newProductionData = {
id: uuidv4(),
org_id: orgId, // Utiliser l'organisation du contrat
name: body.spectacle,
reference: body.numero_objet || null, // Ne pas générer de faux numéro
declaration_date: null, // À remplir manuellement après déclaration à France Travail
sent_date: null,
declared_fts: false, // Par défaut non déclarée
prod_type: 'Spectacle vivant', // Valeur par défaut
director: null,
created_at: new Date().toISOString()
};
console.log('Données de la nouvelle production:', newProductionData);
const writeClient: SupabaseClient = serviceSupabase ?? supabase;
let creationResult = await writeClient
.from('productions')
.insert(newProductionData)
.select('id, name, reference, org_id')
.single();
if (creationResult.error && serviceSupabase && writeClient !== serviceSupabase) {
console.error('Erreur création nouvelle production:', creationResult.error);
creationResult = await serviceSupabase
.from('productions')
.insert(newProductionData)
.select('id, name, reference, org_id')
.single();
}
if (creationResult.error || !creationResult.data) {
console.error('Erreur création production avec service_role:', creationResult.error);
return NextResponse.json({ error: 'Impossible de créer la production' }, { status: 500 });
}
production = creationResult.data as ProductionRow;
console.log('Production créée avec succès:', production);
}
if (!production) {
console.error('Production introuvable après résolution/creation.');
return NextResponse.json({ error: 'Production introuvable' }, { status: 500 });
}
// Préparer les données du contrat selon la structure réelle de cddu_contracts
const isTechnicienCategorie = typeof body.categorie === 'string' && body.categorie.toLowerCase().includes('tech');
const contractData = {
id: contractId,
org_id: orgId,
employee_id: employee.id,
production_id: production.id,
contract_number: contractNumber,
employee_matricule: employee.code_salarie,
employee_name: `${employee.prenom} ${employee.nom}`.trim(),
production_name: production.name,
role: body.profession_label || body.profession,
start_date: body.date_debut,
end_date: body.date_fin,
hours: body.heures_total ? parseFloat(body.heures_total.toString()) : null,
gross_pay: body.montant ? parseFloat(body.montant.toString()) : null,
net_pay: null,
contract_pdf_s3_key: null,
payslips_count: 0,
notes: body.notes || null,
created_at: new Date().toISOString(),
reference: contractNumber,
// Champs obligatoires basés sur la structure existante
structure: orgName, // Utiliser le nom de l'organisation récupéré
etat_de_la_demande: "Reçue",
contrat_signe: "Non",
dpae: "À faire",
etat_de_la_paie: "À traiter",
aem: "À traiter",
matricule: employee.code_salarie,
salaries: `${employee.prenom} ${employee.nom}`.trim(),
nom_du_spectacle: production.name,
profession: body.profession_label || body.profession,
categorie_pro: body.categorie || "Artiste",
debut_contrat: body.date_debut,
fin_contrat: body.date_fin,
salaire: body.montant ? parseFloat(body.montant.toString()) : null,
type_salaire: body.type_salaire || "Brut",
type_de_contrat: "CDD d'usage",
type_d_embauche: "Intermittent du spectacle",
date_de_reception_de_la_demande: new Date().toLocaleDateString('fr-FR'),
brut: body.montant ? parseFloat(body.montant.toString()) : null,
fin_reelle: body.date_fin,
motif_fin_contrat: "Fin de CDD",
contrat_signe_par_employeur: "Non",
multi_mois: body.multi_mois ? "Oui" : "Non",
virement_effectue: "Non",
analytique: production.name,
paniers_repas: body.panier_repas || "Non",
indemnite_hebergement: "Non",
mineur_entre_16_et_18: "Non",
heure_traitement_demande: new Date().toLocaleTimeString('fr-FR', { hour12: false }),
// Champs numériques avec valeurs par défaut
cachets_representations: body.nb_representations ? body.nb_representations.toString() : "0",
services_repetitions: body.nb_services_repetition ? body.nb_services_repetition.toString() : "0",
nombre_d_heures: body.heures_total ? parseFloat(body.heures_total.toString()) : null,
nombre_paniers_repas: body.panier_repas === "Oui" ? "1.0" : "0.0",
// Champs texte optionnels
jours_representations: body.dates_representations || null,
jours_repetitions: body.dates_repetitions || null,
jours_travail: body.jours_travail || null,
// Pour les techniciens, dupliquer aussi dans jours_travail_non_artiste
jours_travail_non_artiste: isTechnicienCategorie ? (body.jours_travail || null) : null,
n_objet: production.reference || body.numero_objet || null,
objet_spectacle: production.reference || body.numero_objet || null,
heures_annexe_8: body.nb_heures_annexes ? parseFloat(body.nb_heures_annexes.toString()) : null
};
console.log("Données du contrat préparées:", contractData);
// Essayer d'abord la fonction RPC pour contourner les politiques RLS
const { data: contract, error } = await supabase
.rpc('create_cddu_contract', { contract_data: contractData });
let finalContract = contract;
if (error) {
console.error('Erreur insertion contrat via RPC:', error);
// Si la RPC échoue à cause des politiques RLS, essayer avec le client service_role
console.log('Tentative avec le client service_role...');
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { data: serviceContract, error: serviceError } = await serviceSupabase
.from('cddu_contracts')
.insert(contractData)
.select()
.single();
if (serviceError) {
console.error('Erreur avec service_role également:', serviceError);
return NextResponse.json({ error: serviceError.message }, { status: 500 });
}
finalContract = serviceContract;
}
// Créer une note système automatique pour tracer la création du contrat
try {
const now = new Date();
const dateStr = now.toLocaleDateString('fr-FR');
const timeStr = now.toLocaleTimeString('fr-FR');
// Récupérer le prénom et le rôle de l'utilisateur
const userFirstName = user.user_metadata?.first_name || user.user_metadata?.display_name?.split(' ')[0] || 'Utilisateur';
let userRole = null;
if (!isStaff) {
// Pour les clients, récupérer leur rôle dans l'organisation
try {
const { data: memberData } = await supabase
.from('organization_members')
.select('role')
.eq('org_id', orgId)
.eq('user_id', user.id)
.eq('revoked', false)
.maybeSingle();
userRole = memberData?.role || null;
} catch (err) {
console.warn('Erreur récupération rôle membre:', err);
}
}
const systemNoteContent = isStaff
? `Demande créée par le Staff Odentas le ${dateStr} à ${timeStr}`
: `Demande créée via l'Espace Paie par ${userFirstName}${userRole ? ` (${userRole})` : ''} le ${dateStr} à ${timeStr}`;
const systemNotePayload: NoteInsert = {
contract_id: contractId,
organization_id: orgId,
content: systemNoteContent,
source: 'Système',
};
const { error: systemNoteError } = await supabase
.from('notes')
.insert([systemNotePayload]);
if (systemNoteError) {
console.warn('Erreur insertion note système avec client standard, tentative service_role:', systemNoteError);
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error: srSystemNoteError } = await serviceSupabase
.from('notes')
.insert([systemNotePayload]);
if (srSystemNoteError) {
console.error('Échec insertion note système même avec service_role:', srSystemNoteError);
}
}
} catch (systemNoteCatchErr) {
console.error('Exception lors de la création de la note système:', systemNoteCatchErr);
}
// Si une note a été fournie lors de la création, créer également une entrée dans la table `notes`
try {
const rawNote = typeof body.notes === 'string' ? body.notes.trim() : '';
if (rawNote) {
const notePayload: NoteInsert = {
contract_id: contractId,
organization_id: orgId,
content: rawNote,
source: 'Espace Paie',
};
const { error: noteError } = await supabase
.from('notes')
.insert([notePayload]);
if (noteError) {
console.warn('Erreur insertion note avec client standard, tentative service_role:', noteError);
const serviceSupabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error: srNoteError } = await serviceSupabase
.from('notes')
.insert([notePayload]);
if (srNoteError) {
console.error('Échec insertion note même avec service_role:', srNoteError);
}
}
}
} catch (noteCatchErr) {
console.error('Exception lors de la création de la note liée au contrat:', noteCatchErr);
}
// Correction de persistance: certains champs optionnels peuvent ne pas être renseignés par la RPC
// Assurer que jours_travail (jours de travail technicien) est bien enregistré si fourni
try {
const providedJoursTravail = typeof body.jours_travail === 'string' && body.jours_travail.trim().length > 0 ? body.jours_travail.trim() : null;
const finalHasJoursTravail = (finalContract as any)?.jours_travail ?? null;
if (providedJoursTravail && !finalHasJoursTravail) {
const serviceClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
const { error: fixErr } = await serviceClient
.from('cddu_contracts')
.update({ jours_travail: providedJoursTravail })
.eq('id', contractId);
if (fixErr) {
console.warn('⚠️ Échec mise à jour jours_travail post-insert:', fixErr);
} else {
// Mettre à jour la valeur locale pour la réponse
if (finalContract) {
(finalContract as any).jours_travail = providedJoursTravail;
}
}
}
} catch (fixCatch) {
console.warn('⚠️ Exception lors de la correction jours_travail:', fixCatch);
}
// Envoyer les notifications par email après la création réussie du contrat
try {
const shouldSendEmail = body.send_email_confirmation !== false; // envoi par défaut, sauf si explicitement à false
if (shouldSendEmail) {
await sendContractNotifications(contractData, organization);
} else {
console.log('Email confirmation disabled by user choice, skipping notifications.');
}
} catch (emailError) {
console.error('Erreur envoi notifications email:', emailError);
// Ne pas faire échouer la création du contrat si l'envoi d'email échoue
}
return NextResponse.json({
success: true,
contract: finalContract,
message: 'Contrat créé avec succès'
});
} catch (error) {
console.error('Erreur lors de la création du contrat:', error);
return NextResponse.json({ error: 'Erreur interne du serveur' }, { status: 500 });
}
}
function generateContractReference(): string {
const letters = 'ABCDEFGHIKLMNPQRSTUVWXYZ'; // sans O
const digits = '123456789'; // sans 0
const pool = letters + digits;
const pick = (source: string) => source[Math.floor(Math.random() * source.length)];
while (true) {
let ref = '';
for (let i = 0; i < 8; i += 1) {
ref += pick(pool);
}
if (ref.startsWith('RG')) continue;
if (!/[A-Z]/.test(ref)) continue;
if (!/[1-9]/.test(ref)) continue;
return ref;
}
}