espace-paie-odentas/app/api/contrats/[id]/route.ts
odentas 266eb3598a feat: Implémenter store global Zustand + calcul total quantités + fix structure field + montants personnalisés virements
- Créer hook useStaffOrgSelection avec persistence localStorage
- Ajouter badge StaffOrgBadge dans Sidebar
- Synchroniser filtres org dans toutes les pages (contrats, cotisations, facturation, etc.)
- Fix calcul cachets: utiliser totalQuantities au lieu de dates.length
- Fix structure field bug: ne plus écraser avec production_name
- Ajouter création note lors modification contrat
- Implémenter montants personnalisés pour virements salaires
- Migrations SQL: custom_amount + fix_structure_field
- Réorganiser boutons ContractEditor en carte flottante droite
2025-12-01 21:51:57 +01:00

880 lines
No EOL
41 KiB
TypeScript

import { NextResponse, NextRequest } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";
import { randomUUID } from "crypto";
import { getS3SignedUrlIfExists } from "@/lib/aws-s3";
import { sendContractUpdateNotifications, sendContractCancellationNotifications } from "@/lib/emailService";
// Force dynamic rendering and disable revalidation cache for this proxy
export const dynamic = "force-dynamic";
export const revalidate = 0;
export const runtime = "nodejs";
const UPSTREAM_BASE =
process.env.STRUCTURE_API_BASE_URL ||
"https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default";
const PATH_PREFIX = process.env.STRUCTURE_API_PATH_PREFIX ?? "";
function b64(s: string) {
return Buffer.from(s, "utf8").toString("base64");
}
/** Résout l'organisation active 100% server-side (sans cookies/localStorage) */
async function resolveOrganization(supabase: any, session: any) {
const userId = session?.user?.id;
const userMeta = session?.user?.user_metadata || {};
const appMeta = session?.user?.app_metadata || {};
// Priorité côté serveur: table staff_users (source de vérité)
let isStaff = false;
try {
const { data: staffRow } = await supabase.from('staff_users').select('is_staff').eq('user_id', userId).maybeSingle();
isStaff = !!staffRow?.is_staff;
} catch (e) {
// Si la requête échoue, fallback sur metadata de session
isStaff = userMeta.is_staff === true || userMeta.role === 'staff' || (Array.isArray(appMeta?.roles) && appMeta.roles.includes('staff'));
}
// Helper: retrouver le nom structure_api à partir d'un org_id
async function getOrgById(orgId: string) {
const { data: org, error } = await supabase
.from("organizations")
.select("id, structure_api")
.eq("id", orgId)
.single();
if (error || !org?.structure_api) throw new Error("Organisation introuvable");
return { id: org.id, name: org.structure_api };
}
if (isStaff) {
// Simplify staff handling: staff sees all data. Return a null id to indicate
// global staff access. Do not attempt to pick an active org.
return { id: null, name: "Staff Access", isStaff: true } as const;
}
// Utilisateur client : lire org depuis organization_members -> organizations (server-side)
const { data: member, error: mErr } = await supabase
.from("organization_members")
.select("org_id")
.eq("user_id", userId)
.single();
if (mErr || !member?.org_id) throw new Error("Aucune organisation associée à l'utilisateur");
const org = await getOrgById(member.org_id);
return { ...org, isStaff: false } as const;
}
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const requestId = randomUUID();
try {
const contractId = params?.id;
if (!contractId) return NextResponse.json({ error: "missing_id" }, { status: 400 });
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.json(
{ error: "unauthorized", message: "Vous devez être connecté pour accéder à cette ressource" },
{ status: 401 }
);
}
// Résoudre l'organisation (staff/client)
const org = await resolveOrganization(supabase, session);
// 1. Essayer de trouver le contrat dans Supabase (table cddu_contracts)
// If staff (org.isStaff === true), use the service-role admin client to bypass RLS
// for reads so staff can see all rows, regardless of active org selection.
let cddu: any = null;
let cdduError: any = null;
if (org.isStaff) {
// ADMIN client requires SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SUPABASE_URL
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const q = admin.from("cddu_contracts").select("*").eq("id", contractId);
const res = await q.maybeSingle();
cddu = res.data;
cdduError = res.error;
} else {
let cdduQuery = supabase.from("cddu_contracts").select("*").eq("id", contractId);
if (org.id) cdduQuery = cdduQuery.eq("org_id", org.id);
const res = await cdduQuery.maybeSingle();
cddu = res.data;
cdduError = res.error;
}
if (cdduError) {
return NextResponse.json({ error: cdduError.message }, { status: 500 });
}
if (cddu) {
// Helper pour slugifier le nom de l'organisation
const slugify = (s: string) =>
s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // Enlever les accents
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 200);
// Générer l'URL signée S3 si la clé est présente
let pdfUrl: string | undefined = undefined;
let s3Key = cddu.contract_pdf_s3_key;
// Si contract_pdf_s3_key est vide, essayer de construire le chemin à partir de structure + contract_number
if (!s3Key && cddu.structure && cddu.contract_number) {
const orgSlug = slugify(cddu.structure);
s3Key = `contracts/${orgSlug}/${cddu.contract_number}.pdf`;
}
if (s3Key) {
try {
const maybe = await getS3SignedUrlIfExists(s3Key);
pdfUrl = maybe ?? undefined;
} catch (e) {
pdfUrl = undefined;
}
}
// Mapping vers ContratDetail attendu par le front
const isMulti = cddu.multi_mois === "Oui" || cddu.multi_mois === true;
const td = String(cddu.type_d_embauche || "").toLowerCase();
const isRG = td.includes("régime général") || td.includes("regime general") || td === "rg";
const detail = {
id: cddu.id,
numero: cddu.contract_number,
regime: isRG ? "RG" : (isMulti ? "CDDU_MULTI" : "CDDU_MONO"),
is_multi_mois: isMulti,
salarie: { nom: cddu.employee_name },
salarie_matricule: cddu.employee_matricule,
production: cddu.production_name || cddu.structure || "",
objet: cddu.objet_spectacle || cddu.nom_du_spectacle || undefined,
profession: cddu.profession || cddu.role || "",
categorie_prof: cddu.categorie_pro || undefined,
type_salaire: cddu.type_salaire || undefined,
salaire_demande: cddu.salaire || cddu.gross_pay || undefined,
salaires_par_date: cddu.salaires_par_date || undefined,
date_debut: cddu.start_date,
date_fin: cddu.end_date,
panier_repas: cddu.paniers_repas,
pdf_contrat: pdfUrl ? { available: true, url: pdfUrl } : { available: false },
pdf_avenant: { available: false },
pdf_paie: cddu.bulletin_paie ? { available: true, url: cddu.bulletin_paie.split('(')[1]?.replace(')', '').trim() } : { available: false },
etat_traitement: cddu.etat_de_la_paie === "Traitée" ? "termine" : cddu.etat_de_la_paie === "En cours" ? "en_cours" : "a_traiter",
virement_effectue: cddu.virement_effectue,
salaire_net_avant_pas: cddu.net,
net_a_payer_rib: cddu.net_apres_pas,
salaire_brut: cddu.brut,
cout_employeur: cddu.cout_employeur,
precisions_salaire: cddu.precisions_salaire,
etat_demande: cddu.etat_de_la_demande,
contrat_signe_employeur: cddu.contrat_signe_par_employeur === "Oui" ? "oui" : "non",
contrat_signe_salarie: cddu.contrat_signe === "Oui" ? "oui" : "non",
etat_contrat: undefined,
dpae: cddu.dpae,
aem: cddu.aem,
jours_travailles: cddu.jours_travail_non_artiste ? Number(cddu.jours_travail_non_artiste) : undefined,
jours_travail: cddu.jours_travail || undefined,
jours_travail_non_artiste: cddu.jours_travail_non_artiste || undefined,
dates_representations: cddu.jours_representations || undefined,
dates_repetitions: cddu.jours_repetitions || undefined,
nb_representations: cddu.cachets_representations ? Number(cddu.cachets_representations) : undefined,
nb_services_repetitions: cddu.services_repetitions ? Number(cddu.services_repetitions) : undefined,
nb_heures_repetitions: cddu.heures_de_repet ? Number(cddu.heures_de_repet) : undefined,
nb_heures_annexes: cddu.heures_annexe_8 ? Number(cddu.heures_annexe_8) : undefined,
nb_cachets_aem: cddu.cachets ? Number(cddu.cachets) : undefined,
nb_heures_aem: cddu.nombre_d_heures ? Number(cddu.nombre_d_heures) : undefined,
// Temps de travail réel (nouveaux champs informatifs)
jours_travail_reel: cddu.jours_travail_reel || undefined,
jours_travail_non_artiste_reel: cddu.jours_travail_non_artiste_reel || undefined,
nb_representations_reel: cddu.nb_representations_reel ? Number(cddu.nb_representations_reel) : undefined,
dates_representations_reel: cddu.dates_representations_reel || undefined,
nb_services_repetitions_reel: cddu.nb_services_repetitions_reel ? Number(cddu.nb_services_repetitions_reel) : undefined,
nb_heures_repetitions_reel: cddu.nb_heures_repetitions_reel ? Number(cddu.nb_heures_repetitions_reel) : undefined,
dates_repetitions_reel: cddu.dates_repetitions_reel || undefined,
nb_heures_annexes_reel: cddu.nb_heures_annexes_reel ? Number(cddu.nb_heures_annexes_reel) : undefined,
nb_cachets_aem_reel: cddu.nb_cachets_aem_reel ? Number(cddu.nb_cachets_aem_reel) : undefined,
nb_heures_aem_reel: cddu.nb_heures_aem_reel ? Number(cddu.nb_heures_aem_reel) : undefined,
created_at: cddu.created_at,
updated_at: cddu.updated_at,
};
return NextResponse.json(detail, { status: 200 });
}
// 2. Sinon, fallback sur l'upstream (RG ou autre)
const upstream = `${UPSTREAM_BASE}${PATH_PREFIX}/contrats/${encodeURIComponent(contractId)}?_t=${Date.now()}`;
const headers = new Headers({
Accept: "application/json",
"x-forwarded-from": "odentas-espace-paie",
"x-request-id": requestId,
});
// Only include organization-identifying headers when a real organization is selected.
if (org.id && org.name) {
headers.set("x-active-org-id", org.id);
headers.set("x-company-name", org.name);
headers.set("x-company-name-b64", b64(org.name));
}
if (process.env.STRUCTURE_API_TOKEN) {
headers.set("authorization", `Bearer ${process.env.STRUCTURE_API_TOKEN}`);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("timeout")), 10_000);
const res = await fetch(upstream, {
method: "GET",
headers: Object.fromEntries(headers.entries()),
cache: "no-store",
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 404) {
const body = await res.text().catch(() => "");
return NextResponse.json({ error: "not_found", message: "Contrat non trouvé ou accès non autorisé" }, { status: 404 });
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return NextResponse.json({ error: "upstream_error", status: res.status, body: text, requestId }, { status: 502 });
}
const data = await res.json();
// Si l'upstream retourne un contract_pdf_s3_key, générer l'URL signée
if (data.contract_pdf_s3_key && typeof data.contract_pdf_s3_key === 'string') {
try {
const signedUrl = await getS3SignedUrlIfExists(data.contract_pdf_s3_key);
if (signedUrl) {
data.pdf_contrat = { available: true, url: signedUrl };
}
} catch (e) {
console.error('Error generating S3 URL for upstream contract:', e);
}
}
const response = NextResponse.json(data, { status: 200 });
response.headers.set("x-request-id", requestId);
return response;
} catch (err: any) {
const msg = String(err?.message || err || "");
if (msg.toLowerCase().includes("timeout") || err?.name === "AbortError") {
console.error("⏳ CONTRACT TIMEOUT:", { requestId, msg });
return NextResponse.json({ error: "gateway_timeout", message: "Upstream timeout", requestId }, { status: 504 });
}
console.error("💥 ERROR in contract route:", { requestId, msg });
return NextResponse.json(
{ error: "internal_server_error", message: "Une erreur est survenue lors de la récupération du contrat", requestId },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
const requestId = randomUUID();
try {
const contractId = params?.id;
if (!contractId) return NextResponse.json({ error: "missing_id" }, { status: 400 });
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
// Résoudre l'organisation (même logique que GET)
const org = await resolveOrganization(supabase, session);
// Corps tel quel (JSON string)
const body = await req.text();
// Debug: Log le contenu du body pour voir ce qui est envoyé
console.log("🔍 PATCH REQUEST DEBUG:", { contractId, body, requestId });
// 1. Vérifier si le contrat existe dans Supabase
let cddu: any = null;
let isSupabaseOnly = false;
if (org.isStaff) {
// Staff avec accès admin
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).maybeSingle();
cddu = data;
} else {
// Client avec accès limité à son organisation
const { data } = await supabase.from("cddu_contracts").select("*").eq("id", contractId).eq("org_id", org.id).maybeSingle();
cddu = data;
}
if (cddu) {
isSupabaseOnly = true;
console.log("📋 CONTRAT SUPABASE TROUVÉ:", { contractId, isSupabaseOnly, requestId });
}
// 2. Traitement selon le type de contrat
if (isSupabaseOnly) {
// CONTRAT SUPABASE-ONLY : Mise à jour directe dans Supabase
try {
const requestBody = JSON.parse(body);
console.log("🔍 PARSED REQUEST BODY (Supabase-only):", { requestBody, contractId, requestId });
// Mapper les champs appropriés pour la table cddu_contracts
const supabaseData: any = {};
// Mapper les champs du formulaire vers la table Supabase
if (requestBody.production !== undefined) {
supabaseData.production_name = requestBody.production;
// Ne PAS écraser structure avec la production - structure doit rester l'organisation
}
if (requestBody.numero_objet !== undefined) {
supabaseData.objet_spectacle = requestBody.numero_objet;
}
if (requestBody.salarie_matricule !== undefined) {
supabaseData.employee_matricule = requestBody.salarie_matricule;
}
if (requestBody.salarie_nom !== undefined) {
supabaseData.employee_name = requestBody.salarie_nom;
}
if (requestBody.categorie_prof !== undefined) {
supabaseData.categorie_pro = requestBody.categorie_prof;
}
if (requestBody.profession !== undefined) {
supabaseData.profession = requestBody.profession;
supabaseData.role = requestBody.profession;
}
if (requestBody.date_debut !== undefined) {
supabaseData.start_date = requestBody.date_debut;
}
if (requestBody.date_fin !== undefined) {
supabaseData.end_date = requestBody.date_fin;
}
if (requestBody.nb_representations !== undefined) {
supabaseData.cachets_representations = requestBody.nb_representations;
}
if (requestBody.nb_services_repetition !== undefined) {
supabaseData.services_repetitions = requestBody.nb_services_repetition;
}
if (requestBody.dates_representations !== undefined) {
supabaseData.jours_representations = requestBody.dates_representations;
}
if (requestBody.dates_repetitions !== undefined) {
supabaseData.jours_repetitions = requestBody.dates_repetitions;
}
// Convertir heures + minutes en nombre décimal pour nombre_d_heures
if (requestBody.heures_travail !== undefined || requestBody.minutes_travail !== undefined) {
const heures = requestBody.heures_travail || 0;
const minutes = requestBody.minutes_travail || 0;
const minutesDecimal = minutes === "30" || minutes === 30 ? 0.5 : 0;
supabaseData.nombre_d_heures = String(Number(heures) + minutesDecimal);
}
if (requestBody.jours_travail !== undefined) {
supabaseData.jours_travail = requestBody.jours_travail;
}
if (requestBody.jours_travail_non_artiste !== undefined) {
supabaseData.jours_travail_non_artiste = requestBody.jours_travail_non_artiste;
}
// Temps de travail réel (informatif pour le client)
if (requestBody.jours_travail_reel !== undefined) {
supabaseData.jours_travail_reel = requestBody.jours_travail_reel;
}
if (requestBody.nb_representations_reel !== undefined) {
supabaseData.nb_representations_reel = requestBody.nb_representations_reel;
}
if (requestBody.nb_services_repetitions_reel !== undefined) {
supabaseData.nb_services_repetitions_reel = requestBody.nb_services_repetitions_reel;
}
if (requestBody.nb_heures_repetitions_reel !== undefined) {
supabaseData.nb_heures_repetitions_reel = requestBody.nb_heures_repetitions_reel;
}
if (requestBody.nb_heures_annexes_reel !== undefined) {
supabaseData.nb_heures_annexes_reel = requestBody.nb_heures_annexes_reel;
}
if (requestBody.nb_cachets_aem_reel !== undefined) {
supabaseData.nb_cachets_aem_reel = requestBody.nb_cachets_aem_reel;
}
if (requestBody.nb_heures_aem_reel !== undefined) {
supabaseData.nb_heures_aem_reel = requestBody.nb_heures_aem_reel;
}
if (requestBody.type_salaire !== undefined) {
supabaseData.type_salaire = requestBody.type_salaire;
}
if (requestBody.montant !== undefined) {
supabaseData.gross_pay = requestBody.montant;
supabaseData.salaire = requestBody.montant;
}
if (requestBody.panier_repas !== undefined) {
supabaseData.paniers_repas = requestBody.panier_repas;
}
if (requestBody.reference !== undefined) {
supabaseData.contract_number = requestBody.reference;
supabaseData.reference = requestBody.reference;
}
if (requestBody.notes !== undefined) {
supabaseData.notes = requestBody.notes;
}
if (requestBody.multi_mois !== undefined) {
supabaseData.multi_mois = requestBody.multi_mois ? "Oui" : "Non";
}
console.log("🔄 SUPABASE DATA TO UPDATE (direct):", { supabaseData, contractId, requestId });
// Mise à jour directe dans Supabase
let updateResult;
if (org.isStaff) {
// Staff avec accès admin
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
updateResult = await admin.from("cddu_contracts").update(supabaseData).eq("id", contractId);
} else {
// Client avec accès limité à son organisation
updateResult = await supabase.from("cddu_contracts").update(supabaseData).eq("id", contractId).eq("org_id", org.id);
}
console.log("✅ SUPABASE UPDATE RESULT (direct):", { updateResult, contractId, syncedFields: Object.keys(supabaseData), requestId });
if (updateResult.error) {
throw new Error(`Supabase update error: ${updateResult.error.message}`);
}
// Si une note a été fournie lors de la modification, créer une entrée dans la table `notes`
try {
const rawNote = typeof requestBody.notes === 'string' ? requestBody.notes.trim() : '';
if (rawNote) {
const notePayload = {
contract_id: contractId,
organization_id: org.id,
content: rawNote,
source: 'Client',
};
let noteInsertResult;
if (org.isStaff) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
noteInsertResult = await admin.from('notes').insert([notePayload]);
} else {
noteInsertResult = await supabase.from('notes').insert([notePayload]);
}
if (noteInsertResult.error) {
console.warn('⚠️ Erreur insertion note lors de la modification:', noteInsertResult.error);
} else {
console.log('✅ Note créée avec succès lors de la modification:', { contractId, noteLength: rawNote.length, requestId });
}
}
} catch (noteCatchErr) {
console.error('Exception lors de la création de la note de modification:', noteCatchErr);
}
// Envoyer les notifications email après la mise à jour réussie (sauf si explicitement désactivé)
const shouldSendEmail = requestBody.send_email_confirmation !== false;
try {
if (!shouldSendEmail) {
console.log("📧 Email notifications disabled by user (send_email_confirmation=false):", { contractId, requestId });
}
// Récupérer les données du contrat mis à jour pour les emails
let contractData;
if (org.isStaff) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
contractData = data;
} else {
const { data } = await supabase.from("cddu_contracts").select("*").eq("id", contractId).eq("org_id", org.id).single();
contractData = data;
}
if (contractData && shouldSendEmail) {
// Récupérer les données d'organisation avec tous les détails
let organizationData;
if (org.isStaff) {
// Pour staff, récupérer l'organisation du contrat depuis la DB
if (contractData.org_id) {
const { data: orgDetails } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else {
const { data: orgDetails, error: orgError } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", org.id)
.single();
console.log("🔍 [ROUTE DEBUG] Organization data retrieved for non-staff user:", {
orgId: org.id,
orgName: org.name,
orgError,
hasOrgDetails: !!orgDetails,
orgDetailsStructure: orgDetails ? {
id: orgDetails.id,
name: orgDetails.name,
hasOrganizationDetails: !!orgDetails.organization_details,
organizationDetailsIsArray: Array.isArray(orgDetails.organization_details),
organizationDetailsKeys: orgDetails.organization_details ? Object.keys(orgDetails.organization_details) : null,
emailNotifs: orgDetails.organization_details?.email_notifs,
emailNotifsCC: orgDetails.organization_details?.email_notifs_cc
} : null
});
organizationData = orgDetails;
}
if (organizationData) {
console.log("🔍 [ROUTE DEBUG] About to send email notifications with organizationData:", {
orgId: organizationData.id,
orgName: organizationData.name,
hasOrgDetails: !!organizationData.organization_details,
emailNotifs: organizationData.organization_details?.email_notifs,
emailNotifsCC: organizationData.organization_details?.email_notifs_cc
});
await sendContractUpdateNotifications(contractData, organizationData);
}
}
} catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
}
// Retourner les données mises à jour
return NextResponse.json({ success: true, updated: Object.keys(supabaseData) }, { status: 200 });
} catch (supabaseError) {
console.error("❌ SUPABASE-ONLY UPDATE ERROR:", { contractId, error: supabaseError, requestId });
return NextResponse.json({ error: "supabase_update_error", message: String(supabaseError), requestId }, { status: 500 });
}
} else {
// CONTRAT UPSTREAM : Logique originale avec appel upstream + sync Supabase
const upstream = `${UPSTREAM_BASE}${PATH_PREFIX}/contrats/${encodeURIComponent(contractId)}?_t=${Date.now()}`;
const headers = new Headers({
Accept: "application/json",
"Content-Type": "application/json",
"x-active-org-id": org.id,
"x-company-name": org.name,
"x-company-name-b64": b64(org.name),
"x-forwarded-from": "odentas-espace-paie",
"x-request-id": requestId,
});
if (process.env.STRUCTURE_API_TOKEN) {
headers.set("authorization", `Bearer ${process.env.STRUCTURE_API_TOKEN}`);
}
// Timeout 10s
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("timeout")), 10_000);
console.log("✏️ CONTRACT API PATCH (upstream):", { contractId, orgId: org.id, orgName: org.name, requestId, resolution: "server-side" });
const res = await fetch(upstream, {
method: "PATCH",
headers: Object.fromEntries(headers.entries()),
cache: "no-store",
body,
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 404) {
const bodyText = await res.text().catch(() => "");
console.warn("⚠️ CONTRACT NOT FOUND (PATCH upstream):", { contractId, requestId, bodyText });
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("❌ UPSTREAM ERROR (PATCH contrat):", { status: res.status, requestId, text });
return NextResponse.json({ error: "upstream_error", status: res.status, body: text, requestId }, { status: 502 });
}
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) {
const json = await res.json();
// Sauvegarder également dans Supabase si la requête upstream a réussi
try {
const requestBody = JSON.parse(body);
console.log("🔍 PARSED REQUEST BODY (upstream):", { requestBody, contractId, requestId });
// Créer une copie des données pour Supabase en excluant les champs qui ne correspondent pas à la table
const supabaseData: any = {};
// Mapper les champs appropriés pour la table cddu_contracts
if (requestBody.date_signature !== undefined) {
supabaseData.date_signature = requestBody.date_signature;
console.log("📅 DATE_SIGNATURE FOUND:", requestBody.date_signature);
}
if (requestBody.reference !== undefined) {
supabaseData.reference = requestBody.reference;
}
if (requestBody.analytique !== undefined) {
supabaseData.analytique = requestBody.analytique;
}
// Ajouter d'autres champs selon les besoins
const fieldsToSync = [
'cachets_representations', 'services_repetitions', 'jours_representations',
'jours_repetitions', 'nombre_d_heures', 'jours_travail',
'jours_travail_non_artiste', 'notes',
'dates_travaillees', 'type_salaire', 'montant', 'panier_repas',
// Temps de travail réel
'jours_travail_reel',
'nb_representations_reel',
'nb_services_repetitions_reel', 'nb_heures_repetitions_reel',
'nb_heures_annexes_reel',
'nb_cachets_aem_reel', 'nb_heures_aem_reel'
];
fieldsToSync.forEach(field => {
if (requestBody[field] !== undefined) {
supabaseData[field] = requestBody[field];
}
});
// Mapper dates_representations → jours_representations
if (requestBody.dates_representations !== undefined) {
supabaseData.jours_representations = requestBody.dates_representations;
}
// Mapper dates_repetitions → jours_repetitions
if (requestBody.dates_repetitions !== undefined) {
supabaseData.jours_repetitions = requestBody.dates_repetitions;
}
// Convertir heures + minutes en nombre décimal pour nombre_d_heures (upstream)
if (requestBody.heures_travail !== undefined || requestBody.minutes_travail !== undefined) {
const heures = requestBody.heures_travail || 0;
const minutes = requestBody.minutes_travail || 0;
const minutesDecimal = minutes === "30" || minutes === 30 ? 0.5 : 0;
supabaseData.nombre_d_heures = String(Number(heures) + minutesDecimal);
}
console.log("🔄 SUPABASE DATA TO UPDATE (upstream):", { supabaseData, contractId, requestId });
// Ne faire l'update que s'il y a des données à sauvegarder
if (Object.keys(supabaseData).length > 0) {
let updateResult;
if (org.isStaff) {
// Staff avec accès admin
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
updateResult = await admin.from("cddu_contracts").update(supabaseData).eq("id", contractId);
} else {
// Client avec accès limité à son organisation
updateResult = await supabase.from("cddu_contracts").update(supabaseData).eq("id", contractId).eq("org_id", org.id);
}
console.log("✅ SUPABASE UPDATE RESULT (upstream):", { updateResult, contractId, syncedFields: Object.keys(supabaseData), requestId });
// Envoyer les notifications email après la mise à jour réussie (sauf si explicitement désactivé)
const shouldSendEmail = requestBody.send_email_confirmation !== false;
try {
if (!shouldSendEmail) {
console.log("📧 Email notifications disabled by user (send_email_confirmation=false):", { contractId, requestId });
}
// Récupérer les données du contrat mis à jour pour les emails
let contractData;
if (org.isStaff) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
contractData = data;
} else {
const { data } = await supabase.from("cddu_contracts").select("*").eq("id", contractId).eq("org_id", org.id).single();
contractData = data;
}
if (contractData && shouldSendEmail) {
// Récupérer les données d'organisation avec tous les détails
let organizationData;
if (org.isStaff) {
// Pour staff, récupérer l'organisation du contrat depuis la DB
if (contractData.org_id) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const { data: orgDetails } = await admin
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else {
const { data: orgDetails, error: orgError } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", org.id)
.single();
console.log("🔍 [ROUTE DEBUG] Organization data retrieved for non-staff user (upstream sync):", {
orgId: org.id,
orgName: org.name,
orgError,
hasOrgDetails: !!orgDetails,
orgDetailsStructure: orgDetails ? {
id: orgDetails.id,
name: orgDetails.name,
hasOrganizationDetails: !!orgDetails.organization_details,
organizationDetailsIsArray: Array.isArray(orgDetails.organization_details),
organizationDetailsKeys: orgDetails.organization_details ? Object.keys(orgDetails.organization_details) : null,
emailNotifs: orgDetails.organization_details?.email_notifs,
emailNotifsCC: orgDetails.organization_details?.email_notifs_cc
} : null
});
organizationData = orgDetails;
}
if (organizationData) {
console.log("🔍 [ROUTE DEBUG] About to send email notifications with organizationData (upstream sync):", {
orgId: organizationData.id,
orgName: organizationData.name,
hasOrgDetails: !!organizationData.organization_details,
emailNotifs: organizationData.organization_details?.email_notifs,
emailNotifsCC: organizationData.organization_details?.email_notifs_cc
});
await sendContractUpdateNotifications(contractData, organizationData);
}
}
} catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
}
} else {
console.log("⚠️ NO DATA TO SYNC TO SUPABASE (upstream)", { contractId, requestId });
}
} catch (syncError) {
console.error("⚠️ SUPABASE SYNC ERROR (non-blocking):", { contractId, error: syncError, requestId });
// Ne pas faire échouer la requête principale si la sync Supabase échoue
}
const response = NextResponse.json(json, { status: 200 });
response.headers.set("x-request-id", requestId);
return response;
} else {
const text = await res.text();
return new NextResponse(text, { status: 200, headers: { "content-type": ct, "x-request-id": requestId } });
}
} // Fin du bloc else (contrat upstream)
} catch (err: any) {
const msg = String(err?.message || err || "");
if (msg.toLowerCase().includes("timeout") || err?.name === "AbortError") {
console.error("⏳ CONTRACT PATCH TIMEOUT:", { requestId, msg });
return NextResponse.json({ error: "gateway_timeout", message: "Upstream timeout", requestId }, { status: 504 });
}
console.error("💥 ERROR in contract PATCH route:", { requestId, msg });
return NextResponse.json({ error: "internal_server_error", message: msg, requestId }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const requestId = randomUUID();
try {
const contractId = params?.id;
if (!contractId) return NextResponse.json({ error: "missing_id" }, { status: 400 });
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
// Résoudre l'organisation (même logique que GET/PATCH)
const org = await resolveOrganization(supabase, session);
console.log("🗑️ CONTRACT DELETE:", { contractId, orgId: org.id, orgName: org.name, requestId });
// 1. Récupérer les données du contrat avant suppression (pour les emails)
let contractData;
let organizationData;
if (org.isStaff) {
// Staff avec accès admin
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const { data } = await admin.from("cddu_contracts").select("*").eq("id", contractId).single();
contractData = data;
// Pour staff, récupérer l'organisation du contrat depuis la DB
if (contractData?.org_id) {
const { data: orgDetails } = await admin
.from("organizations")
.select("*, organization_details(*)")
.eq("id", contractData.org_id)
.single();
organizationData = orgDetails || { organization_details: {} };
} else {
// Pas d'organisation liée, skip les notifications
console.log("⚠️ No organization linked to contract, skipping email notifications");
organizationData = null;
}
} else {
// Client avec accès limité à son organisation
const { data } = await supabase.from("cddu_contracts").select("*").eq("id", contractId).eq("org_id", org.id).single();
contractData = data;
// Récupérer les détails d'organisation
const { data: orgDetails } = await supabase
.from("organizations")
.select("*, organization_details(*)")
.eq("id", org.id)
.single();
organizationData = orgDetails;
}
if (!contractData) {
console.warn("⚠️ CONTRACT NOT FOUND FOR DELETE:", { contractId, requestId });
return NextResponse.json({ error: "not_found", message: "Contrat non trouvé" }, { status: 404 });
}
// 2. Vérifier que le contrat est annulable (statut "Reçue")
const isRecue = contractData.etat_de_la_demande === "Reçue" ||
contractData.etat_de_la_demande === "reçue" ||
contractData.etat_de_la_demande === "recu";
if (!isRecue) {
console.warn("⚠️ CONTRACT NOT CANCELLABLE:", { contractId, status: contractData.etat_de_la_demande, requestId });
return NextResponse.json({
error: "not_cancellable",
message: "Ce contrat ne peut plus être annulé car son traitement a commencé"
}, { status: 400 });
}
// 3. Supprimer le contrat de Supabase
let deleteResult;
if (org.isStaff) {
// Staff avec accès admin
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
deleteResult = await admin.from("cddu_contracts").delete().eq("id", contractId);
} else {
// Client avec accès limité à son organisation
deleteResult = await supabase.from("cddu_contracts").delete().eq("id", contractId).eq("org_id", org.id);
}
if (deleteResult.error) {
console.error("❌ SUPABASE DELETE ERROR:", { contractId, error: deleteResult.error, requestId });
return NextResponse.json({
error: "delete_error",
message: `Erreur lors de la suppression: ${deleteResult.error.message}`
}, { status: 500 });
}
console.log("✅ CONTRACT DELETED:", { contractId, requestId });
// 4. Envoyer les notifications email d'annulation
try {
if (organizationData) {
await sendContractCancellationNotifications(contractData, organizationData);
} else {
console.log("⚠️ No organization data available, skipping cancellation email notifications");
}
} catch (emailError) {
console.error("⚠️ EMAIL NOTIFICATION ERROR (non-blocking):", { contractId, error: emailError, requestId });
}
// 5. Retourner le succès
return NextResponse.json({
success: true,
message: "Contrat annulé avec succès",
contractId,
requestId
}, { status: 200 });
} catch (err: any) {
const msg = String(err?.message || err || "");
console.error("💥 ERROR in contract DELETE route:", { requestId, msg });
return NextResponse.json({ error: "internal_server_error", message: msg, requestId }, { status: 500 });
}
}