espace-paie-odentas/app/api/salaries/route.ts
odentas 2760ea2b39 fix: Corriger création salarié en mode staff avec employer_id
- Réorganiser la logique de sécurité pour distinguer staff/client
- Staff: accepter employer_id fourni et vérifier existence de l'org
- Client: utiliser l'org associée et bloquer toute tentative cross-org
- Améliorer les logs pour identifier le type d'utilisateur
- Corriger le retour no_organization inapproprié pour les staff
2025-11-13 20:48:14 +01:00

675 lines
25 KiB
TypeScript

// (POST handler is defined later in this file)
export const dynamic = "force-dynamic";
import { NextResponse, NextRequest } from "next/server";
import { createSbServer, createSbServiceRole } from "@/lib/supabaseServer";
import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
import { sendUniversalEmailV2 } from "@/lib/emailTemplateService";
import { generateAutoDeclarationToken } from "@/lib/autoDeclarationTokenService";
type SalarieRow = {
matricule: string;
nom: string;
email?: string | null;
transat_connecte?: boolean;
dernier_emploi?: string | null;
code_salarie?: string | null;
prenom?: string | null;
civilite?: string | null;
tel?: string | null;
org_id?: string | null;
org_name?: string | null;
};
type SalariesResponse = {
items: SalarieRow[];
page: number;
limit: number;
total?: number | null;
totalPages?: number;
hasMore: boolean;
};
async function resolveActiveOrgId(sb: any, _isStaff: boolean) {
return await resolveActiveOrg(sb);
}
export async function GET(req: NextRequest) {
try {
// 🎭 Mode démo : retourner des données fictives
const isDemoMode = process.env.DEMO_MODE === 'true';
if (isDemoMode) {
console.log('🎭 [API /salaries] Mode démo - retour de données fictives');
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "10", 10) || 10));
const q = (searchParams.get("search") || searchParams.get("q") || "").trim();
// Données fictives de salariés
const DEMO_SALARIES: SalarieRow[] = [
{
matricule: "demo-sal-001",
nom: "MARTIN Alice",
email: "alice.martin@demo.fr",
transat_connecte: true,
dernier_emploi: "Comédien",
code_salarie: "demo-sal-001",
prenom: "Alice",
civilite: "Mme",
tel: "06 12 34 56 78",
org_id: "demo-org",
org_name: "Organisation Démo",
},
{
matricule: "demo-sal-002",
nom: "DUBOIS Pierre",
email: "pierre.dubois@demo.fr",
transat_connecte: false,
dernier_emploi: "Metteur en scène",
code_salarie: "demo-sal-002",
prenom: "Pierre",
civilite: "M.",
tel: "06 23 45 67 89",
org_id: "demo-org",
org_name: "Organisation Démo",
},
{
matricule: "demo-sal-003",
nom: "LEROY Sophie",
email: "sophie.leroy@demo.fr",
transat_connecte: true,
dernier_emploi: "Danseur",
code_salarie: "demo-sal-003",
prenom: "Sophie",
civilite: "Mme",
tel: "06 34 56 78 90",
org_id: "demo-org",
org_name: "Organisation Démo",
},
{
matricule: "demo-sal-004",
nom: "BERNARD Marc",
email: "marc.bernard@demo.fr",
transat_connecte: false,
dernier_emploi: "Technicien son",
code_salarie: "demo-sal-004",
prenom: "Marc",
civilite: "M.",
tel: "06 45 67 89 01",
org_id: "demo-org",
org_name: "Organisation Démo",
},
{
matricule: "demo-sal-005",
nom: "GARCIA Elena",
email: "elena.garcia@demo.fr",
transat_connecte: true,
dernier_emploi: "Costumière",
code_salarie: "demo-sal-005",
prenom: "Elena",
civilite: "Mme",
tel: "06 56 78 90 12",
org_id: "demo-org",
org_name: "Organisation Démo",
},
];
// Filtrer par recherche si nécessaire
let filteredSalaries = DEMO_SALARIES;
if (q) {
const searchTerm = q.toLowerCase();
filteredSalaries = DEMO_SALARIES.filter(salarie =>
salarie.nom.toLowerCase().includes(searchTerm) ||
salarie.prenom?.toLowerCase().includes(searchTerm) ||
salarie.matricule.toLowerCase().includes(searchTerm) ||
salarie.email?.toLowerCase().includes(searchTerm) ||
salarie.dernier_emploi?.toLowerCase().includes(searchTerm)
);
}
// Pagination
const offset = (page - 1) * limit;
const paginatedSalaries = filteredSalaries.slice(offset, offset + limit);
const total = filteredSalaries.length;
const hasMore = offset + paginatedSalaries.length < total;
const totalPages = Math.ceil(total / limit);
const payload: SalariesResponse = {
items: paginatedSalaries,
page,
limit,
total,
totalPages,
hasMore,
};
return NextResponse.json(payload);
}
const sb = createSbServer();
const {
data: { user },
error: authError,
} = await sb.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Determine if current user is staff
let isStaff = false;
try {
const { data } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
isStaff = !!data?.is_staff;
} catch {}
const orgId = await resolveActiveOrgId(sb, isStaff);
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "25", 10) || 25));
// Accept either `search` or `q` (some clients send `q`)
const q = (searchParams.get("search") || searchParams.get("q") || "").trim();
console.log("👥 API salaries search:", { q, page, limit, user_id: user.id });
const offset = (page - 1) * limit;
const to = offset + limit - 1;
// Build query
let query = sb
.from("salaries")
.select(
`id, code_salarie, num_salarie, salarie, nom, prenom, civilite, adresse_mail, compte_transat, derniere_profession, employer_id, infos_caisses_organismes, tel`,
{ count: "exact" }
);
// If an organization id was resolved, scope results to that employer.
if (orgId) {
query = query.eq("employer_id", orgId);
}
if (q) {
const like = `%${q}%`;
// Use OR with ilike across relevant columns
query = query.or(
[
`nom.ilike.${like}`,
`prenom.ilike.${like}`,
`salarie.ilike.${like}`,
`adresse_mail.ilike.${like}`,
`code_salarie.ilike.${like}`,
`num_salarie.ilike.${like}`,
].join(",")
);
}
// Order by last name then first name for stability
query = query.order("nom", { ascending: true }).order("prenom", { ascending: true, nullsFirst: true }).range(offset, to);
const { data, error, count } = await query;
console.log("👥 API salaries result:", {
data: data?.length,
error: error?.message,
count
});
if (error) {
console.error("💥 [API /salaries] Supabase error:", error.message);
return NextResponse.json({ error: "supabase_error", detail: error.message }, { status: 500 });
}
const items: SalarieRow[] = (data || []).map((r: any) => {
const fullName = r.salarie || [r.nom, r.prenom].filter(Boolean).join(" ").trim() || r.nom || "";
const comp = (r.compte_transat || "").toString().toLowerCase();
const transat = comp.includes("connect") && !comp.includes("non");
return {
matricule: r.code_salarie || (r.num_salarie ? String(r.num_salarie) : r.id),
nom: fullName,
email: r.adresse_mail ?? null,
transat_connecte: transat,
dernier_emploi: r.derniere_profession ?? null,
code_salarie: r.code_salarie ?? null,
prenom: r.prenom ?? null,
civilite: r.civilite ?? null,
tel: r.tel ?? null,
org_id: r.employer_id || null,
org_name: r.infos_caisses_organismes || null,
} as SalarieRow;
});
const total = count ?? null;
const hasMore = typeof total === "number" ? offset + items.length < total : items.length === limit;
const totalPages = typeof total === "number" ? Math.ceil(total / limit) : 0;
const payload: SalariesResponse = {
items,
page,
limit,
total,
totalPages,
hasMore,
};
return NextResponse.json(payload);
} catch (e: any) {
console.error("💥 [API /salaries] Unexpected error:", e?.message);
return NextResponse.json({ error: "server_error", message: e?.message || "unknown" }, { status: 500 });
}
}
// Rate limiting map: userId -> { count, windowStart }
const rateLimitMap = new Map<string, { count: number; windowStart: number }>();
const RATE_LIMIT_MAX = 50; // 50 salariés par heure
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 heure en millisecondes
// POST: create a new salary record (used by the nouveau salarié form)
export async function POST(req: NextRequest) {
try {
let body: any = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: 'invalid_json' }, { status: 400 });
}
if (!body || !body.nom || !body.prenom || !body.email_salarie) {
return NextResponse.json({ ok: false, error: 'missing_required_fields' }, { status: 400 });
}
// 🔒 SÉCURITÉ : Validation du format email salarié
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(body.email_salarie)) {
console.error('❌ [SÉCURITÉ] Format d\'email salarié invalide:', body.email_salarie);
return NextResponse.json(
{
ok: false,
error: 'invalid_email',
message: 'Le format de l\'email du salarié est invalide'
},
{ status: 400 }
);
}
const supabase = createSbServiceRole();
// 🔒 SÉCURITÉ : Authentification et récupération de l'utilisateur
const sbAuth = createSbServer();
const { data: { user }, error: authErr } = await sbAuth.auth.getUser();
if (authErr || !user) {
console.error('❌ [SÉCURITÉ] Utilisateur non authentifié');
return NextResponse.json(
{ ok: false, error: 'unauthorized', message: 'Authentification requise' },
{ status: 401 }
);
}
// 🔒 SÉCURITÉ : Vérifier si l'utilisateur est staff
let isStaff = false;
try {
const { data: staffRow } = await sbAuth.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
isStaff = !!staffRow?.is_staff;
} catch {
const userMeta = user?.user_metadata || {};
const appMeta = user?.app_metadata || {};
isStaff = Boolean((userMeta.is_staff === true || userMeta.role === 'staff') || (Array.isArray(appMeta.roles) && appMeta.roles.includes('staff')));
}
console.log('🔍 [SÉCURITÉ] Vérification utilisateur:', {
email: user.email,
isStaff,
employer_id_fourni: body.employer_id
});
// 🔒 SÉCURITÉ : Rate limiting (50 créations par heure par utilisateur)
const now = Date.now();
const userRateLimit = rateLimitMap.get(user.id);
if (userRateLimit) {
// Vérifier si on est dans la même fenêtre temporelle
if (now - userRateLimit.windowStart < RATE_LIMIT_WINDOW) {
if (userRateLimit.count >= RATE_LIMIT_MAX) {
const remainingTime = Math.ceil((RATE_LIMIT_WINDOW - (now - userRateLimit.windowStart)) / 60000);
console.warn('⚠️ [RATE LIMIT] Limite atteinte pour utilisateur:', user.id);
return NextResponse.json(
{
ok: false,
error: 'rate_limit_exceeded',
message: `Limite de ${RATE_LIMIT_MAX} créations par heure atteinte. Réessayez dans ${remainingTime} minutes.`
},
{ status: 429 }
);
}
// Incrémenter le compteur
userRateLimit.count++;
} else {
// Nouvelle fenêtre temporelle
rateLimitMap.set(user.id, { count: 1, windowStart: now });
}
} else {
// Première création pour cet utilisateur
rateLimitMap.set(user.id, { count: 1, windowStart: now });
}
// Nettoyer les entrées expirées du rate limit map (éviter la fuite mémoire)
for (const [userId, data] of rateLimitMap.entries()) {
if (now - data.windowStart > RATE_LIMIT_WINDOW) {
rateLimitMap.delete(userId);
}
}
// 🔒 SÉCURITÉ : Logique différente pour staff et clients
let orgId: string | null = null;
if (isStaff) {
// Pour les utilisateurs staff : accepter l'employer_id fourni
orgId = body.employer_id || null;
if (!orgId) {
console.error('❌ [STAFF] employer_id manquant dans la requête');
return NextResponse.json(
{
ok: false,
error: 'no_organization',
message: 'Vous devez sélectionner une organisation'
},
{ status: 400 }
);
}
// Vérifier que l'organisation existe
const { data: orgExists, error: orgError } = await sbAuth
.from('organizations')
.select('id')
.eq('id', orgId)
.maybeSingle();
if (orgError || !orgExists) {
console.error('❌ [STAFF] Organisation introuvable:', orgId);
return NextResponse.json(
{
ok: false,
error: 'invalid_organization',
message: 'Organisation introuvable'
},
{ status: 404 }
);
}
console.log('✅ [STAFF] Organisation validée:', orgId);
} else {
// Pour les utilisateurs clients : utiliser leur organisation associée
const userOrgId = await resolveActiveOrg(sbAuth);
if (!userOrgId) {
console.error('❌ [CLIENT] Aucune organisation trouvée pour l\'utilisateur:', user.id);
return NextResponse.json(
{
ok: false,
error: 'no_organization',
message: 'Aucune organisation associée à votre compte'
},
{ status: 403 }
);
}
// 🔒 SÉCURITÉ CRITIQUE : Vérifier que l'employer_id fourni (si présent) correspond à l'organisation de l'utilisateur
if (body.employer_id && body.employer_id !== userOrgId) {
console.error('❌ [SÉCURITÉ CRITIQUE] Tentative de création salarié dans une autre organisation!');
console.error(' - employer_id fourni:', body.employer_id);
console.error(' - organisation utilisateur:', userOrgId);
console.error(' - utilisateur:', user.email);
return NextResponse.json(
{
ok: false,
error: 'unauthorized_organization',
message: 'Vous ne pouvez pas créer un salarié dans cette organisation'
},
{ status: 403 }
);
}
// Utiliser l'organisation de l'utilisateur authentifié
orgId = userOrgId;
console.log('✅ [CLIENT] Organisation validée:', orgId);
}
console.log('✅ [SÉCURITÉ] Vérifications réussies');
console.log(' - Utilisateur:', user.email);
console.log(' - Type:', isStaff ? 'STAFF' : 'CLIENT');
console.log(' - Organisation:', orgId);
console.log(' - Email salarié validé:', body.email_salarie);
console.log(' - Rate limit:', `${rateLimitMap.get(user.id)?.count || 0}/${RATE_LIMIT_MAX}`)
// Compute next matricule/code_salarie for this org if possible
let computedCode: string | null = null;
let computedNum: number | null = null;
if (orgId) {
try {
// Récupérer le code employeur depuis organization_details
const { data: orgDetailsData, error: orgDetailsError } = await supabase
.from('organization_details')
.select('code_employeur')
.eq('org_id', orgId)
.single();
console.log('🔍 [MATRICULE] Requête organization_details:', {
orgId,
orgDetailsData,
orgDetailsError,
hasCodeEmployeur: !!orgDetailsData?.code_employeur
});
const codeEmployeur = orgDetailsData?.code_employeur || '';
console.log('🔍 [MATRICULE] code_employeur final:', codeEmployeur);
const { data: rows, error: qerr } = await supabase
.from('salaries')
.select('code_salarie')
.eq('employer_id', orgId)
.not('code_salarie', 'is', null)
.limit(1000);
if (!qerr && Array.isArray(rows)) {
console.log('🔍 [MATRICULE] Nombre de salariés existants:', rows.length);
let maxNum = -Infinity;
let detectedPrefix = '';
let maxPad = 0;
for (const r of rows) {
const s = (r?.code_salarie || '').toString();
const m = s.match(/(\d+)$/);
if (m) {
const numStr = m[1];
const num = parseInt(numStr, 10);
if (!Number.isNaN(num) && num > maxNum) {
maxNum = num;
detectedPrefix = s.slice(0, s.length - numStr.length);
maxPad = numStr.length;
}
}
}
if (maxNum !== -Infinity) {
// On a trouvé des matricules existants
const nextNum = maxNum + 1;
const padded = String(nextNum).padStart(Math.max(maxPad, 3), '0');
// Utiliser le code employeur comme préfixe, même si les anciens n'en avaient pas
const prefix = codeEmployeur || detectedPrefix;
computedCode = `${prefix}${padded}`;
computedNum = nextNum;
console.log('✅ [MATRICULE] Incrémentation:', computedCode, '(prefix utilisé:', prefix, 'prefix détecté:', detectedPrefix, ')');
} else {
// No existing numeric matricules found: use code_employeur as prefix
computedNum = 1;
computedCode = codeEmployeur ? `${codeEmployeur}001` : `001`;
console.log('✅ [MATRICULE] Premier salarié, code généré:', computedCode, '(codeEmployeur:', codeEmployeur, ')');
}
}
} catch (e) {
console.error('❌ [MATRICULE] Erreur génération:', e);
// ignore computation errors and continue without code
}
}
const fullName = [body.nom, body.prenom].filter(Boolean).join(" ").trim();
// Only include columns that exist in the `salaries` table to avoid PGRST204
const payload: any = {
salarie: fullName || null,
nom: body.nom || null,
prenom: body.prenom || null,
nom_de_naissance: body.nomnaissance_salarie || body.nom_naissance || null,
// pseudonyme fallback to 'n/a' when empty
pseudonyme: (body.pseudonyme || body.pseudo || "").toString().trim() || "n/a",
civilite: body.civilite_salarie || body.civilite || null,
// map address text into the `adresse` column (table has `adresse`)
adresse: body.adresse_salarie || body.adresse || null,
// keep primary contact fields that exist in the table
adresse_mail: body.email_salarie || body.email || null,
tel: body.tel_salarie || body.telephone || null,
date_naissance: body.dob_salarie || body.dateNaissance || null,
lieu_de_naissance: body.cob_salarie || body.lieuNaissance || null,
nir: body.ss_salarie || body.nir || null,
conges_spectacles: body.cs_salarie || body.conges_spectacles || null,
iban: body.iban_salarie || body.iban || null,
bic: body.bic_salarie || body.bic || null,
// enforce requested defaults
compte_transat: "En cours",
topaze: "NC",
justificatifs_personnels: body.justificatifs_personnels || "En attente de réception",
rf_au_sens_fiscal: true,
intermittent_mineur_16: false,
abattement_2024: body.abattement_2024 || "Sans objet",
notif_nouveau_salarie: "Fait",
notif_employeur: "Fait",
infos_caisses_organismes: body.structure || null,
employer_id: orgId || body.employer_id || null,
// computed matricule fields
code_salarie: computedCode,
num_salarie: computedNum,
};
const { data, error } = await supabase.from('salaries').insert([payload]).select().single();
if (error) {
console.error('❌ [API /salaries POST] supabase error', error);
return NextResponse.json({ ok: false, error: 'db_error', detail: error }, { status: 500 });
}
// Envoyer les e-mails de notification après la création réussie
try {
const sbAuth = createSbServer();
const { data: { user } } = await sbAuth.auth.getUser();
// Récupérer les infos de l'organisation depuis les deux tables
const orgData = orgId ? await supabase.from('organizations').select('name').eq('id', orgId).single() : { data: null };
const orgDetailsResult = orgId ? await supabase.from('organization_details').select('code_employeur, prenom_contact, email_notifs, email_notifs_cc').eq('org_id', orgId).single() : { data: null, error: null };
console.log('🔍 [EMAIL] Données récupérées:', {
orgId,
orgData: orgData?.data,
orgDetailsData: 'data' in orgDetailsResult ? orgDetailsResult.data : null,
orgDetailsError: 'error' in orgDetailsResult ? orgDetailsResult.error : null
});
const orgDetails = {
data: orgData?.data ? {
name: orgData.data.name,
code_employeur: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.code_employeur || null : null,
prenom_contact: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.prenom_contact || null : null,
email_notifs: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.email_notifs || null : null,
email_notifs_cc: ('data' in orgDetailsResult && orgDetailsResult.data) ? orgDetailsResult.data.email_notifs_cc || null : null
} : null
};
console.log('🔍 [EMAIL] orgDetails final:', orgDetails);
// 1. Email de notification à l'employeur (corrigé: utilise email_notifs au lieu de user.email)
if (orgDetails?.data?.email_notifs) {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
// 🔒 SÉCURITÉ : Nettoyer et valider l'email principal
const cleanAndValidateEmail = (email: string | null, fallback: string = 'paie@odentas.fr'): string => {
if (!email) return fallback;
const trimmed = email.trim().toLowerCase();
// Valider le format email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
console.warn('⚠️ [SÉCURITÉ] Email invalide détecté, utilisation du fallback:', trimmed);
return fallback;
}
return trimmed;
};
const emailNotifs = cleanAndValidateEmail(orgDetails.data.email_notifs);
const emailNotifsCC = orgDetails.data.email_notifs_cc
? cleanAndValidateEmail(orgDetails.data.email_notifs_cc, '')
: null;
console.log('📧 [EMAIL] Envoi à email_notifs:', emailNotifs);
console.log('📧 [EMAIL] CC à email_notifs_cc:', emailNotifsCC || 'Aucun');
if (emailNotifs) {
await sendUniversalEmailV2({
type: 'employee-created',
toEmail: emailNotifs,
ccEmail: emailNotifsCC || undefined,
data: {
userName: orgDetails.data.prenom_contact || user?.user_metadata?.first_name || user?.user_metadata?.display_name?.split(' ')[0] || 'Cher client',
companyName: orgDetails.data.name,
employerCode: orgDetails.data.code_employeur || 'N/A',
handlerName: 'Renaud BREVIERE-ABRAHAM',
employeeName: data.salarie,
email: data.adresse_mail,
matricule: data.code_salarie,
ctaUrl: `${baseUrl}/salaries/${data.id}`,
}
});
}
} else {
console.warn('⚠️ [EMAIL] email_notifs non défini pour l\'organisation', orgId);
}
// 2. Générer token et envoyer invitation au salarié (nouveau)
if (data.adresse_mail) {
try {
const result = await generateAutoDeclarationToken({
salarie_id: data.id,
send_email: true
});
if (result.success) {
console.log('✅ [API /salaries POST] Token généré et invitation envoyée au salarié');
} else {
console.error('❌ [API /salaries POST] Erreur génération token:', result.error);
}
} catch (tokenError) {
console.error('❌ [API /salaries POST] Erreur lors de la génération du token:', tokenError);
}
} else {
console.warn('⚠️ [API /salaries POST] Pas d\'email pour le salarié, invitation non envoyée');
}
} catch (emailError) {
console.error('📧 [API /salaries POST] Failed to send email notifications:', emailError);
// Ne pas bloquer la réponse en cas d'échec des e-mails
}
return NextResponse.json({ ok: true, data }, { status: 201 });
} catch (e: any) {
console.error('💥 [API /salaries POST] unexpected', e?.message || e);
return NextResponse.json({ ok: false, error: 'server_exception', detail: e?.message || String(e) }, { status: 500 });
}
}