- Création de la page /staff/contrats/saisie-temps-reel avec tableau éditable - Ajout des colonnes jours_representations et jours_repetitions dans l'API - Construction intelligente du TT Contractuel (concaténation des sources) - Ajout de la colonne temps_reel_traite pour marquer les contrats traités - Interface avec filtres (année, mois, organisation, recherche) - Tri par date/salarié - Édition inline avec auto-save via API - Checkbox pour marquer comme traité (masque automatiquement la ligne) - Toggle pour afficher/masquer les contrats traités - Migration SQL pour la colonne temps_reel_traite - Ajout du menu 'Temps de travail réel' dans la sidebar - Logs de débogage pour le suivi des sauvegardes
851 lines
No EOL
40 KiB
TypeScript
851 lines
No EOL
40 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;
|
|
supabaseData.structure = requestBody.production;
|
|
}
|
|
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}`);
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
} |