espace-paie-odentas/app/api/contrats/route.ts
odentas 78c43f0bfa feat: Implémentation complète du système de permissions
- Créer lib/permissions.ts avec toutes les fonctions de vérification
- Protéger routes API: facturation, cotisations, virements (bloquer AGENT)
- Protéger routes API: contrats (bloquer COMPTA)
- Protéger routes API: gestion utilisateurs (bloquer AGENT/COMPTA)
- Empêcher ADMIN de modifier/révoquer/créer SUPER_ADMIN
- Ajouter documentation complète dans PERMISSIONS_MATRIX.md

Système à 5 niveaux:
- STAFF (équipe Odentas)
- SUPER_ADMIN (admin principal, 1 par org, protégé)
- ADMIN (admins secondaires)
- AGENT (opérationnel: contrats/paies/salariés)
- COMPTA (financier lecture seule: cotisations/virements/factures)
2025-11-14 20:25:30 +01:00

425 lines
No EOL
20 KiB
TypeScript

// app/api/contrats/route.ts
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies, headers } from "next/headers";
import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
import { createClient } from '@supabase/supabase-js';
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
import { DEMO_CONTRACTS, DEMO_ORGANIZATION } from "@/lib/demo-data";
import { getUserPermissions, canAccessContrats, checkPermissionOrRespond } from "@/lib/permissions";
// 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 buildUpstreamUrl(req: Request) {
const url = new URL(req.url);
const upstream = new URL(UPSTREAM_BASE + `${PATH_PREFIX}/contrats`);
// Rejoue tous les query params vers l'amont
url.searchParams.forEach((v, k) => upstream.searchParams.set(k, v));
return upstream;
}
export async function GET(req: Request) {
// 🎭 Vérification du mode démo en premier
const h = headers();
const isDemoMode = detectDemoModeFromHeaders(h);
if (isDemoMode) {
console.log("🎭 [API CONTRATS] Mode démo détecté - renvoi de données fictives");
const url = new URL(req.url);
const regime = url.searchParams.get("regime");
const status = url.searchParams.get("status");
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "10");
// Filtrer les contrats selon les paramètres
let filteredContracts = DEMO_CONTRACTS;
if (regime === "CDDU") {
filteredContracts = filteredContracts.filter(c =>
c.regime === "CDDU_MONO" || c.regime === "CDDU_MULTI"
);
} else if (regime === "RG") {
filteredContracts = filteredContracts.filter(c => c.regime === "RG");
}
if (status === "en_cours") {
filteredContracts = filteredContracts.filter(c =>
c.etat === "en_cours" || c.etat === "signe"
);
} else if (status === "termines") {
filteredContracts = filteredContracts.filter(c =>
c.etat === "traitee"
);
}
// Pagination
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedContracts = filteredContracts.slice(startIndex, endIndex);
return NextResponse.json({
items: paginatedContracts,
total: filteredContracts.length,
page,
limit,
totalPages: Math.ceil(filteredContracts.length / limit),
hasMore: endIndex < filteredContracts.length
});
}
try {
const sb = createRouteHandlerClient({ cookies });
// 🔒 VÉRIFICATION DES PERMISSIONS (bloquer COMPTA)
const permissions = await getUserPermissions(sb);
const permissionCheck = canAccessContrats(permissions);
const errorResponse = checkPermissionOrRespond(permissionCheck);
if (errorResponse) return errorResponse;
const url = new URL(req.url);
const regime = url.searchParams.get("regime");
// Si CDDU, RG, ou absence de regime (= tous), lire depuis Supabase (table cddu_contracts)
if (regime === "CDDU" || regime === "RG" || !regime) {
const sb = createRouteHandlerClient({ cookies });
// Use centralized resolver which normalizes header/cookie values like 'unknown'
// and implements staff null-org semantics (returns null for global staff access).
const orgId = await resolveActiveOrg(sb);
// If orgId is not found here it means either the user is a staff with no active org
// or the resolution failed. For staff we want to allow global access (orgId === null).
// For non-staff users, absence of orgId will result in an empty result set later.
// Pagination
const page = parseInt(url.searchParams.get("page") || "1", 10);
const limit = parseInt(url.searchParams.get("limit") || "10", 10);
const offset = (page - 1) * limit;
// Recherche
const q = url.searchParams.get("q")?.trim();
// Tri
const sort = url.searchParams.get("sort") || "date_fin";
const order = url.searchParams.get("order") || "desc";
// Support explicit org filter via query param `org_id` (sent by client UI when staff selects a structure).
// Resolution priority:
// 1) If `org_id` query param is present, allow it only for staff or if it matches resolved orgId for non-staff.
// 2) Else if resolved orgId is present, scope to it.
// 3) Else resolved orgId === null -> staff global access: use admin client to read all rows.
const requestedOrg = url.searchParams.get("org_id") || undefined;
let query: any;
// Helper: determine if this is a staff user. Note: resolveActiveOrg may return an org id
// when the UI set a header/cookie; however the current session user may still be a staff
// account. We therefore explicitly check the session to detect staff privileges so that
// staff users who provided an explicit org can still be handled via the admin client.
let detectedIsStaff = false;
try {
const { data: { session } } = await sb.auth.getSession();
if (session) {
try {
const { data: staffRow } = await sb.from('staff_users').select('is_staff').eq('user_id', session.user.id).maybeSingle();
detectedIsStaff = !!(staffRow as any)?.is_staff;
} catch {
const userMeta = session.user?.user_metadata || {};
const appMeta = session.user?.app_metadata || {};
detectedIsStaff = Boolean((userMeta.is_staff === true || userMeta.role === 'staff') || (Array.isArray(appMeta.roles) && appMeta.roles.includes('staff')));
}
}
} catch {
detectedIsStaff = false;
}
// Consider the caller as staff if either resolveActiveOrg returned null (explicit staff global)
// or the session indicates staff privileges.
const isStaff = orgId === null || detectedIsStaff;
// Prepare admin client for staff reads (bypass RLS) if possible
let admin: any = null;
if (isStaff) {
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
console.error('Service role key not configured; cannot perform staff admin reads');
// we'll still try to use sb but results may be restricted
} else {
admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { autoRefreshToken: false, persistSession: false },
});
}
}
if (requestedOrg) {
// If non-staff provided a requestedOrg, ensure it matches their resolved orgId
if (!isStaff && orgId && requestedOrg !== orgId) {
return NextResponse.json({ items: [], page, limit, hasMore: false });
}
// Staff should use admin client to bypass RLS when filtering by a specific org
if (isStaff && admin) {
query = admin.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", requestedOrg);
} else {
query = sb.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", requestedOrg);
}
} else if (orgId) {
if (isStaff && admin) {
query = admin.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", orgId);
} else {
query = sb.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)").eq("org_id", orgId);
}
} else {
// orgId === null and no requestedOrg -> staff global read required
if (!isStaff) {
return NextResponse.json({ items: [], page, limit, hasMore: false });
}
if (!admin) {
console.error('Service role key not configured; cannot perform staff global read');
return NextResponse.json({ items: [], page, limit, hasMore: false });
}
query = admin.from("cddu_contracts").select("*, organizations!inner(name), payslips(id, processed)");
}
// We'll fetch rows then filter in JS to avoid brittle SQL patterns (accents/variants)
if (q) {
query = query.or(`employee_name.ilike.%${q}%,contract_number.ilike.%${q}%,production_name.ilike.%${q}%`);
}
// On ne filtre plus par statut SQL, mais par date de fin côté Node.js
// TODO: Filtres période/année/mois si besoin
query = query.order("created_at", { ascending: false });
const { data, error } = await query;
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
// If caller asked for debug resolution, return org resolution info + raw row count
const debugResolve = url.searchParams.get("debug_resolve");
if (debugResolve === "1") {
const resolvedSource = req.headers.get("x-active-org-id") ? "header" : cookies().get("active_org_id") ? "cookie" : "session-db";
return NextResponse.json({ resolved: { orgId, resolvedSource }, rows: (data || []).length, sample: (data || []).slice(0, 3) });
}
// Filtrage JS selon la date de fin, année, période, puis tri du plus récent au plus ancien
const status = url.searchParams.get("status");
const today = new Date();
today.setHours(0,0,0,0); // UTC minuit
const year = url.searchParams.get("year") ? parseInt(url.searchParams.get("year")!, 10) : undefined;
// Prise en compte des paramètres month, quarter, semester, period
const month = url.searchParams.get("month") ? parseInt(url.searchParams.get("month")!, 10) : undefined;
const quarter = url.searchParams.get("quarter") ? parseInt(url.searchParams.get("quarter")!, 10) : undefined;
const semester = url.searchParams.get("semester") ? parseInt(url.searchParams.get("semester")!, 10) : undefined;
const period = url.searchParams.get("period") || "Y";
// Helper pour filtrer par période
function isInPeriod(date: Date) {
if (!year) return true;
if (date.getFullYear() !== year) return false;
if (typeof month === "number") {
return date.getMonth() + 1 === month;
} else if (typeof quarter === "number") {
// Trimestres : Q1=janv-mars, Q2=avr-juin, Q3=juil-sept, Q4=oct-déc
if (quarter === 1) return date.getMonth() >= 0 && date.getMonth() <= 2;
if (quarter === 2) return date.getMonth() >= 3 && date.getMonth() <= 5;
if (quarter === 3) return date.getMonth() >= 6 && date.getMonth() <= 8;
if (quarter === 4) return date.getMonth() >= 9 && date.getMonth() <= 11;
} else if (typeof semester === "number") {
// Semestres : S1=janv-juin, S2=juil-déc
if (semester === 1) return date.getMonth() >= 0 && date.getMonth() <= 5;
if (semester === 2) return date.getMonth() >= 6 && date.getMonth() <= 11;
} else if (period.startsWith("M")) {
const m = parseInt(period.slice(1), 10);
return date.getMonth() + 1 === m;
} else if (period.startsWith("Q")) {
const q = parseInt(period.slice(1), 10);
if (q === 1) return date.getMonth() >= 0 && date.getMonth() <= 2;
if (q === 2) return date.getMonth() >= 3 && date.getMonth() <= 5;
if (q === 3) return date.getMonth() >= 6 && date.getMonth() <= 8;
if (q === 4) return date.getMonth() >= 9 && date.getMonth() <= 11;
} else if (period.startsWith("S")) {
const s = parseInt(period.slice(1), 10);
if (s === 1) return date.getMonth() >= 0 && date.getMonth() <= 5;
if (s === 2) return date.getMonth() >= 6 && date.getMonth() <= 11;
}
// "Y" ou autre : toute l'année
return true;
}
let filtered = (data || []).filter((row: any) => {
const end = row.end_date || row.date_fin;
if (!end) return false;
const endDate = new Date(end);
if (isNaN(endDate.getTime())) return false;
endDate.setHours(0,0,0,0);
if (status === "en_cours") {
// Un contrat est "en_cours" si sa date de fin est dans le futur ou aujourd'hui
// Cela inclut les contrats qui n'ont pas encore commencé (date_debut future)
return endDate >= today;
} else if (status === "termines") {
if (endDate < today && isInPeriod(endDate)) return true;
return false;
}
return true;
});
// Filter by requested regime: if caller asked for RG, keep only rows where type_d_embauche indicates RG
// If caller asked for CDDU, exclude RG rows so they don't appear in CDDU tab
// If no regime specified, keep all rows (CDDU + RG)
const normalize = (s: any) => String(s || '').toLowerCase();
if (regime === 'RG') {
filtered = filtered.filter((row: any) => {
const td = normalize(row.type_d_embauche);
return td.includes('régime général') || td.includes('regime general') || td === 'rg';
});
} else if (regime === 'CDDU') {
filtered = filtered.filter((row: any) => {
const td = normalize(row.type_d_embauche);
return !(td.includes('régime général') || td.includes('regime general') || td === 'rg');
});
}
// Si regime === null/undefined, on garde tous les contrats (pas de filtrage)
// Tri basé sur les paramètres sort/order
filtered.sort((a: any, b: any) => {
let aValue: any;
let bValue: any;
// Déterminer le champ à trier
if (sort === 'date_debut' || sort === 'start_date') {
aValue = new Date(a.start_date || a.date_debut || 0);
bValue = new Date(b.start_date || b.date_debut || 0);
} else {
// Défaut: date_fin
aValue = new Date(a.end_date || a.date_fin || 0);
bValue = new Date(b.end_date || b.date_fin || 0);
}
// Appliquer l'ordre
const comparison = aValue.getTime() - bValue.getTime();
return order === 'asc' ? comparison : -comparison;
});
// Pagination JS après filtrage/tri
const paged = filtered.slice(offset, offset + limit);
// Mapping format attendu par le front
const items = paged.map((row: any) => {
const isMulti = row.multi_mois === "Oui" || row.multi_mois === true;
const td = String(row.type_d_embauche || "").toLowerCase();
const isRG = td.includes("régime général") || td.includes("regime general") || td === "rg";
// Déterminer l'état à afficher
let displayEtat = (row.etat_de_la_demande || row.etat || "en_cours");
// Pour les contrats terminés (status === "termines")
if (status === "termines") {
if (isMulti) {
// CDDU multi-mois terminé : afficher "Terminé"
displayEtat = "traitee"; // ou créer un nouvel état "termine" si besoin
} else {
// CDDU mono-mois terminé : afficher l'état de traitement de la payslip
const payslips = row.payslips || [];
if (payslips.length > 0) {
// Prendre la première payslip (mono-mois = une seule paie)
const payslip = payslips[0];
if (payslip.processed === true) {
displayEtat = "traitee";
} else {
displayEtat = "en_cours";
}
} else {
// Pas de payslip créée
displayEtat = "en_cours";
}
}
}
return {
id: row.id,
reference: row.contract_number,
salarie_nom: row.employee_name,
production: row.production_name || row.organizations?.name || "",
// Expose organization info (if available) so UI can show "Structure" column for staff
org_id: row.org_id || null,
org_name: row.organizations?.name || null,
profession: row.profession || row.role || "",
date_debut: row.start_date,
date_fin: row.end_date,
etat: displayEtat,
is_multi_mois: isMulti,
regime: isRG ? "RG" : (isMulti ? "CDDU_MULTI" : "CDDU_MONO"),
};
});
return NextResponse.json({
items,
page,
limit,
hasMore: filtered.length > offset + limit,
total: filtered.length,
totalPages: Math.ceil(filtered.length / limit),
debug: {
orgId,
isStaff,
totalRowsFromDB: (data || []).length,
filteredRows: filtered.length,
returnedItems: items.length
}
});
}
// Sinon, proxy comme avant (RG ou autre)
const upstream = buildUpstreamUrl(req);
const debug = url.searchParams.get("debug");
if (debug === "1") {
const activeOrg = req.headers.get("x-active-org-id") || undefined;
const companyNameB64 = req.headers.get("x-company-name-b64") || undefined;
const companyKey = req.headers.get("x-company-key") || undefined;
const rawCompanyName = req.headers.get("x-company-name") || undefined;
const incomingAuth = req.headers.get("authorization") || undefined;
return NextResponse.json({
upstream: upstream.toString(),
forwardedHeaders: {
...(activeOrg ? { "x-active-org-id": activeOrg } : {}),
...(companyNameB64 ? { "x-company-name-b64": companyNameB64 } : {}),
...(rawCompanyName ? { "x-company-name": rawCompanyName } : {}),
...(companyKey ? { "x-company-key": companyKey } : {}),
...(incomingAuth ? { authorization: incomingAuth } : {}),
...(process.env.STRUCTURE_API_TOKEN && !incomingAuth
? { authorization: `Bearer ${process.env.STRUCTURE_API_TOKEN?.slice(0, 8)}` }
: {}),
},
note: "Set STRUCTURE_API_BASE_URL and optionally STRUCTURE_API_PATH_PREFIX (default '/api').",
});
}
const activeOrg = req.headers.get("x-active-org-id") || undefined;
const companyNameB64 = req.headers.get("x-company-name-b64") || undefined;
const companyKey = req.headers.get("x-company-key") || undefined;
const rawCompanyName = req.headers.get("x-company-name") || undefined;
const incomingAuth = req.headers.get("authorization") || undefined;
const r = await fetch(upstream.toString(), {
method: "GET",
headers: {
Accept: "application/json",
...(activeOrg ? { "x-active-org-id": activeOrg } : {}),
...(companyNameB64 ? { "x-company-name-b64": companyNameB64 } : {}),
...(rawCompanyName ? { "x-company-name": rawCompanyName } : {}),
...(companyKey ? { "x-company-key": companyKey } : {}),
...(incomingAuth ? { Authorization: incomingAuth } : {}),
...(process.env.STRUCTURE_API_TOKEN && !incomingAuth
? { Authorization: `Bearer ${process.env.STRUCTURE_API_TOKEN}` }
: {}),
},
cache: "no-store",
});
const ct = r.headers.get("content-type") || "application/json";
const body = await r.text();
if (!r.ok) {
return new NextResponse(body || JSON.stringify({ error: "upstream_failed" }), {
status: r.status,
headers: { "content-type": ct },
});
}
return new NextResponse(body, {
status: 200,
headers: { "content-type": ct },
});
} catch (e: any) {
// En cas de panne réseau ou d'URL invalide, renvoyer une erreur JSON claire
return NextResponse.json(
{ error: "proxy_error", message: e?.message || String(e) },
{ status: 500 }
);
}
}