- 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
560 lines
22 KiB
TypeScript
560 lines
22 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
|
import { cookies } from "next/headers";
|
|
|
|
// =============================================================================
|
|
// Helper: Poll PDFMonkey document status
|
|
// =============================================================================
|
|
async function pollDocumentStatus(
|
|
documentUrl: string,
|
|
apiKey: string,
|
|
maxAttempts = 10,
|
|
intervalMs = 3000
|
|
): Promise<{ status: string; download_url?: string }> {
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
const res = await fetch(documentUrl, {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Poll failed: ${res.statusText}`);
|
|
}
|
|
const data = await res.json();
|
|
const doc = data.document || data;
|
|
const status = doc.status;
|
|
if (status === "success") {
|
|
return { status, download_url: doc.download_url };
|
|
}
|
|
if (status === "failure") {
|
|
throw new Error("PDFMonkey document generation failed");
|
|
}
|
|
}
|
|
throw new Error("PDFMonkey polling timed out");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper: Format date as DD/MM/YYYY
|
|
// =============================================================================
|
|
function formatDateFR(dateString: string | null | undefined): string {
|
|
if (!dateString) return "";
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString("fr-FR", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper: Format amount
|
|
// =============================================================================
|
|
function formatAmount(amount: string | number | null | undefined): string {
|
|
if (!amount) return "0,00";
|
|
try {
|
|
const num = typeof amount === "string" ? parseFloat(amount) : amount;
|
|
return new Intl.NumberFormat("fr-FR", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(num);
|
|
} catch {
|
|
return "0,00";
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper: Format month period as "Mois YYYY"
|
|
// =============================================================================
|
|
function formatMonthPeriod(periodMonth: string | null | undefined): string {
|
|
if (!periodMonth) return "";
|
|
try {
|
|
const date = new Date(periodMonth);
|
|
return date.toLocaleDateString("fr-FR", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper: Slugify organization name for S3 path
|
|
// =============================================================================
|
|
function slugify(text: string): string {
|
|
return text
|
|
.toString()
|
|
.toLowerCase()
|
|
.normalize("NFD") // Decompose accented characters
|
|
.replace(/[\u0300-\u036f]/g, "") // Remove diacritics
|
|
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
|
|
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
|
|
}
|
|
|
|
// =============================================================================
|
|
// POST /api/staff/virements-salaires/generate-pdf
|
|
// =============================================================================
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
console.log("[generate-pdf] Starting PDF generation");
|
|
|
|
// 1) Check auth
|
|
const supabase = createRouteHandlerClient({ cookies });
|
|
const {
|
|
data: { session },
|
|
error: sessionError,
|
|
} = await supabase.auth.getSession();
|
|
|
|
if (sessionError || !session) {
|
|
console.error("[generate-pdf] Auth error:", sessionError);
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const user = session.user;
|
|
console.log("[generate-pdf] User authenticated:", user.id);
|
|
|
|
// 2) Check if staff
|
|
const { data: staffData, error: staffError } = await supabase
|
|
.from("staff_users")
|
|
.select("is_staff")
|
|
.eq("user_id", user.id)
|
|
.maybeSingle();
|
|
|
|
const isStaff = staffData?.is_staff || false;
|
|
console.log("[generate-pdf] Is staff:", isStaff);
|
|
|
|
if (!isStaff) {
|
|
return NextResponse.json({ error: "Forbidden: staff only" }, { status: 403 });
|
|
}
|
|
|
|
// 3) Parse request body
|
|
const body = await req.json();
|
|
const { salary_transfer_id } = body;
|
|
console.log("[generate-pdf] Salary transfer ID:", salary_transfer_id);
|
|
|
|
if (!salary_transfer_id) {
|
|
return NextResponse.json({ error: "Missing salary_transfer_id" }, { status: 400 });
|
|
}
|
|
|
|
// 4) Get salary transfer details
|
|
console.log("[generate-pdf] Fetching salary transfer...");
|
|
const { data: salaryTransfer, error: stError } = await supabase
|
|
.from("salary_transfers")
|
|
.select("*")
|
|
.eq("id", salary_transfer_id)
|
|
.single();
|
|
if (stError || !salaryTransfer) {
|
|
console.error("[generate-pdf] Salary transfer fetch error:", stError);
|
|
return NextResponse.json({ error: "Salary transfer not found", details: stError?.message }, { status: 404 });
|
|
}
|
|
console.log("[generate-pdf] Salary transfer found:", { org_id: salaryTransfer.org_id, period_month: salaryTransfer.period_month });
|
|
|
|
// 5) Get organization details
|
|
console.log("[generate-pdf] Fetching organization and details...");
|
|
const { data: organization, error: orgError } = await supabase
|
|
.from("organizations")
|
|
.select("*")
|
|
.eq("id", salaryTransfer.org_id)
|
|
.single();
|
|
if (orgError || !organization) {
|
|
console.error("[generate-pdf] Organization fetch error:", orgError);
|
|
return NextResponse.json({ error: "Organization not found", details: orgError?.message }, { status: 404 });
|
|
}
|
|
console.log("[generate-pdf] Organization found:", organization.name);
|
|
|
|
// 5b) Get organization_details
|
|
const { data: orgDetails, error: orgDetailsError } = await supabase
|
|
.from("organization_details")
|
|
.select("*")
|
|
.eq("org_id", salaryTransfer.org_id)
|
|
.single();
|
|
if (orgDetailsError) {
|
|
console.warn("[generate-pdf] orgDetails fetch error:", orgDetailsError);
|
|
}
|
|
console.log("[generate-pdf] Organization details loaded:", orgDetails ? "✓" : "✗");
|
|
|
|
// 6) Get payslips based on selection mode
|
|
console.log("[generate-pdf] Fetching payslips...");
|
|
console.log("[generate-pdf] Selection mode:", salaryTransfer.selection_mode || 'period');
|
|
console.log("[generate-pdf] Organization ID:", salaryTransfer.org_id);
|
|
|
|
let payslips: any[] | null = null;
|
|
let payslipsError: any = null;
|
|
|
|
if (salaryTransfer.selection_mode === 'manual') {
|
|
// Mode manuel: récupérer les paies liées via la table de liaison
|
|
console.log("[generate-pdf] Manual mode: fetching linked payslips");
|
|
|
|
const { data: linkedPayslips, error: linkedError } = await supabase
|
|
.from("salary_transfer_payslips")
|
|
.select(`
|
|
payslip_id,
|
|
custom_amount,
|
|
payslips (
|
|
*,
|
|
cddu_contracts (
|
|
employee_matricule,
|
|
contract_number,
|
|
analytique,
|
|
profession,
|
|
salaries (
|
|
nom,
|
|
prenom,
|
|
iban
|
|
)
|
|
)
|
|
)
|
|
`)
|
|
.eq("salary_transfer_id", salary_transfer_id);
|
|
|
|
payslipsError = linkedError;
|
|
|
|
if (!linkedError && linkedPayslips) {
|
|
// Flatten the structure and add custom_amount to each payslip
|
|
payslips = linkedPayslips
|
|
.map(lp => {
|
|
if (!lp.payslips) return null;
|
|
return {
|
|
...lp.payslips,
|
|
custom_amount: lp.custom_amount, // Ajouter le montant personnalisé
|
|
};
|
|
})
|
|
.filter(p => p !== null);
|
|
console.log("[generate-pdf] Found", payslips.length, "linked payslips");
|
|
|
|
// Log custom amounts for debugging
|
|
const withCustomAmounts = payslips.filter(p => p.custom_amount !== null).length;
|
|
console.log("[generate-pdf] Payslips with custom amounts:", withCustomAmounts);
|
|
}
|
|
} else {
|
|
// Mode période: récupérer toutes les paies du mois (comportement actuel)
|
|
console.log("[generate-pdf] Period mode: fetching all payslips for period");
|
|
const periodMonth = salaryTransfer.period_month; // Already in YYYY-MM-01 format
|
|
console.log("[generate-pdf] Period month:", periodMonth);
|
|
|
|
const { data: periodPayslips, error: periodError } = await supabase
|
|
.from("payslips")
|
|
.select(`
|
|
*,
|
|
cddu_contracts (
|
|
employee_matricule,
|
|
contract_number,
|
|
analytique,
|
|
profession,
|
|
salaries (
|
|
nom,
|
|
prenom,
|
|
iban
|
|
)
|
|
)
|
|
`)
|
|
.eq("organization_id", salaryTransfer.org_id)
|
|
.eq("period_month", periodMonth);
|
|
|
|
payslips = periodPayslips;
|
|
payslipsError = periodError;
|
|
}
|
|
|
|
console.log("[generate-pdf] Payslips query result:", {
|
|
error: payslipsError,
|
|
count: payslips?.length || 0,
|
|
sample: payslips?.[0] ? {
|
|
id: payslips[0].id,
|
|
net_amount: payslips[0].net_amount,
|
|
has_contract: !!payslips[0].cddu_contracts,
|
|
has_salarie: !!payslips[0].cddu_contracts?.salaries
|
|
} : null
|
|
});
|
|
|
|
if (payslipsError) {
|
|
console.error("[generate-pdf] Payslips fetch error:", payslipsError);
|
|
return NextResponse.json({
|
|
error: "Failed to fetch payslips",
|
|
details: (payslipsError as any)?.message || "Unknown error",
|
|
debugInfo: {
|
|
org_id: salaryTransfer.org_id,
|
|
selection_mode: salaryTransfer.selection_mode,
|
|
period_month: salaryTransfer.period_month
|
|
}
|
|
}, { status: 500 });
|
|
}
|
|
|
|
if (!payslips || payslips.length === 0) {
|
|
console.warn("[generate-pdf] No payslips found!");
|
|
console.warn("[generate-pdf] Query params:", {
|
|
organization_id: salaryTransfer.org_id,
|
|
selection_mode: salaryTransfer.selection_mode,
|
|
period_month: salaryTransfer.period_month
|
|
});
|
|
// Continue anyway - we'll generate a PDF with empty payslips array
|
|
} else {
|
|
console.log("[generate-pdf] Found", payslips.length, "payslips");
|
|
// Log details about contracts
|
|
const withContracts = payslips.filter(p => p.cddu_contracts).length;
|
|
const withSalaries = payslips.filter(p => p.cddu_contracts?.salaries).length;
|
|
console.log("[generate-pdf] Payslips with contracts:", withContracts);
|
|
console.log("[generate-pdf] Payslips with salaries:", withSalaries);
|
|
|
|
// Log first payslip sample
|
|
console.log("[generate-pdf] First payslip sample:", JSON.stringify(payslips[0], null, 2));
|
|
}
|
|
|
|
// 7) Build PDFMonkey payload
|
|
console.log("═══════════════════════════════════════════════════════════");
|
|
console.log("[generate-pdf] 🔍 BUILDING PDFMONKEY PAYLOAD");
|
|
console.log("[generate-pdf] 📊 Payslips count:", payslips?.length || 0);
|
|
console.log("═══════════════════════════════════════════════════════════");
|
|
|
|
// Build line items for payslips
|
|
const lineItems = (payslips || []).map((p: any) => {
|
|
const contract = p.cddu_contracts;
|
|
const salarie = contract?.salaries;
|
|
|
|
// Get employee name
|
|
const employee_name = `${salarie?.prenom || ""} ${salarie?.nom || ""}`.trim();
|
|
|
|
// Utiliser le montant personnalisé si disponible, sinon le montant de la paie
|
|
const montant = p.custom_amount !== null && p.custom_amount !== undefined
|
|
? parseFloat(p.custom_amount)
|
|
: parseFloat(p.net_after_withholding || p.net_amount || 0);
|
|
|
|
console.log("[generate-pdf] 👤 Processing payslip:", {
|
|
payslip_id: p.id,
|
|
has_contract: !!contract,
|
|
has_salarie: !!salarie,
|
|
employee_name,
|
|
net_amount: p.net_amount,
|
|
net_after_withholding: p.net_after_withholding,
|
|
custom_amount: p.custom_amount,
|
|
final_montant: montant
|
|
});
|
|
|
|
return {
|
|
employee_name,
|
|
matricule: contract?.employee_matricule || "",
|
|
contrat: contract?.contract_number || "",
|
|
montant,
|
|
analytique: contract?.analytique || "",
|
|
profession: contract?.profession || "",
|
|
};
|
|
});
|
|
|
|
console.log("[generate-pdf] ✅ Line items built:", lineItems.length, "items");
|
|
if (lineItems.length > 0) {
|
|
console.log("[generate-pdf] 📝 First line item:", JSON.stringify(lineItems[0], null, 2));
|
|
}
|
|
|
|
// Calculate totals
|
|
const totalNet = (payslips || []).reduce((sum: number, p: any) => {
|
|
// Utiliser le montant personnalisé si disponible, sinon le montant de la paie
|
|
const amount = p.custom_amount !== null && p.custom_amount !== undefined
|
|
? (typeof p.custom_amount === "string" ? parseFloat(p.custom_amount) : p.custom_amount)
|
|
: (typeof p.net_after_withholding === "string"
|
|
? parseFloat(p.net_after_withholding)
|
|
: (p.net_after_withholding || (typeof p.net_amount === "string" ? parseFloat(p.net_amount) : p.net_amount) || 0));
|
|
return sum + amount;
|
|
}, 0);
|
|
console.log("[generate-pdf] Total net amount:", totalNet);
|
|
|
|
// Get callsheet date (date d'appel)
|
|
const callsheetDate = formatDateFR(salaryTransfer.callsheet_date || salaryTransfer.created_at);
|
|
|
|
// Get limit date (date d'échéance)
|
|
const limitDate = formatDateFR(salaryTransfer.deadline);
|
|
|
|
// Calculate number of payslips
|
|
const nbrePayslips = payslips?.length || 0;
|
|
|
|
// Generate transfer reference using code_employeur from organization_details
|
|
const codeEmployeur = orgDetails?.code_employeur || organization.code || "ORG";
|
|
const transferReference = `${codeEmployeur}-${salaryTransfer.num_appel || "00000"}`;
|
|
|
|
console.log("═══════════════════════════════════════════════════════════");
|
|
console.log("[generate-pdf] 📋 PAYLOAD SUMMARY:");
|
|
console.log("[generate-pdf] Transfer reference:", transferReference);
|
|
console.log("[generate-pdf] Total net:", totalNet);
|
|
console.log("[generate-pdf] Number of payslips:", nbrePayslips);
|
|
console.log("[generate-pdf] Number of line items:", lineItems.length);
|
|
console.log("═══════════════════════════════════════════════════════════");
|
|
|
|
const pdfMonkeyPayload = {
|
|
document: {
|
|
document_template_id: "F4BCB5FF-1AB1-4CEE-B57F-82A6B9893E9E",
|
|
status: "pending",
|
|
payload: {
|
|
// Client information (simplified from organization)
|
|
client_address: orgDetails?.adresse || "",
|
|
client_cp: orgDetails?.cp || "",
|
|
client_city: orgDetails?.ville || "",
|
|
client_code: organization.code || "",
|
|
client_name: organization.name || "",
|
|
|
|
// Callsheet information
|
|
callsheet_date: callsheetDate,
|
|
limit_date: limitDate,
|
|
callsheet_number: salaryTransfer.num_appel || "00000",
|
|
transfer_method: salaryTransfer.mode || "Virement SEPA",
|
|
|
|
// Period and amounts
|
|
periode: salaryTransfer.period_label || formatMonthPeriod(salaryTransfer.period_month),
|
|
total_salaires_nets: totalNet,
|
|
solde_compte_client: 0.0, // À implémenter si nécessaire
|
|
total_transfer: totalNet, // Pour l'instant, même que total_salaires_nets
|
|
deposit: 0.0, // À implémenter si nécessaire
|
|
|
|
// Payslip count and reference
|
|
nbre_paies: nbrePayslips,
|
|
transfer_reference: transferReference,
|
|
|
|
// Line items (payslips array)
|
|
lineItems: lineItems,
|
|
},
|
|
meta: {},
|
|
},
|
|
};
|
|
|
|
console.log("═══════════════════════════════════════════════════════════");
|
|
console.log("[generate-pdf] 🚀 FINAL PAYLOAD TO PDFMONKEY:");
|
|
console.log("[generate-pdf] Payload prepared with", lineItems.length, "payslips");
|
|
console.log("[generate-pdf] lineItems in payload:", pdfMonkeyPayload.document.payload.lineItems.length);
|
|
console.log("═══════════════════════════════════════════════════════════");
|
|
|
|
if (lineItems.length === 0) {
|
|
console.warn("[generate-pdf] ⚠️ WARNING: Payload has no payslips!");
|
|
console.warn("[generate-pdf] This will generate an empty PDF!");
|
|
} else {
|
|
console.log("[generate-pdf] ✅ Sample line item in payload:", JSON.stringify(lineItems[0], null, 2));
|
|
}
|
|
|
|
// 8) Call PDFMonkey API
|
|
console.log("[generate-pdf] 📤 Calling PDFMonkey API...");
|
|
const pdfMonkeyUrl = process.env.PDFMONKEY_URL || "https://api.pdfmonkey.io/api/v1/documents";
|
|
const pdfMonkeyApiKey = process.env.PDFMONKEY_API_KEY;
|
|
if (!pdfMonkeyApiKey) {
|
|
console.error("[generate-pdf] Missing PDFMONKEY_API_KEY");
|
|
return NextResponse.json({ error: "Missing PDFMONKEY_API_KEY" }, { status: 500 });
|
|
}
|
|
|
|
const createRes = await fetch(pdfMonkeyUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${pdfMonkeyApiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(pdfMonkeyPayload),
|
|
});
|
|
|
|
if (!createRes.ok) {
|
|
const errorText = await createRes.text();
|
|
console.error("[generate-pdf] PDFMonkey create error:", errorText);
|
|
return NextResponse.json(
|
|
{ error: "PDFMonkey API error", details: errorText },
|
|
{ status: createRes.status }
|
|
);
|
|
}
|
|
|
|
const createData = await createRes.json();
|
|
console.log("[generate-pdf] PDFMonkey response:", createData);
|
|
|
|
const documentId = createData.document?.id;
|
|
if (!documentId) {
|
|
console.error("[generate-pdf] No document ID in response");
|
|
return NextResponse.json({ error: "No document ID returned from PDFMonkey" }, { status: 500 });
|
|
}
|
|
|
|
// Construct the document URL for polling
|
|
const documentUrl = `${pdfMonkeyUrl}/${documentId}`;
|
|
console.log("[generate-pdf] Document URL for polling:", documentUrl);
|
|
|
|
// 9) Poll for completion
|
|
console.log("[generate-pdf] Polling document status...");
|
|
const { status, download_url } = await pollDocumentStatus(documentUrl, pdfMonkeyApiKey, 15, 3000);
|
|
console.log("[generate-pdf] Poll result:", { status, has_download_url: !!download_url });
|
|
if (status !== "success" || !download_url) {
|
|
console.error("[generate-pdf] PDF generation failed or timed out");
|
|
return NextResponse.json({ error: "PDF generation failed or timed out", status }, { status: 500 });
|
|
}
|
|
|
|
// 10) Download PDF
|
|
console.log("[generate-pdf] Downloading PDF from:", download_url);
|
|
const pdfRes = await fetch(download_url);
|
|
if (!pdfRes.ok) {
|
|
console.error("[generate-pdf] Failed to download PDF:", pdfRes.status, pdfRes.statusText);
|
|
return NextResponse.json({ error: "Failed to download PDF" }, { status: 500 });
|
|
}
|
|
const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
|
|
console.log("[generate-pdf] PDF downloaded, size:", pdfBuffer.length, "bytes");
|
|
|
|
// 11) Upload to S3
|
|
console.log("[generate-pdf] Uploading to S3...");
|
|
const s3Client = new S3Client({
|
|
region: process.env.AWS_REGION || "eu-west-3",
|
|
credentials: {
|
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
},
|
|
});
|
|
|
|
const bucketName = "odentas-docs";
|
|
const clientSlug = slugify(organization.name || "client");
|
|
const s3Key = `documents/${clientSlug}/appel-virement/${salary_transfer_id}-${Date.now()}.pdf`;
|
|
console.log("[generate-pdf] S3 target:", { bucket: bucketName, key: s3Key });
|
|
|
|
const uploadCommand = new PutObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: s3Key,
|
|
Body: pdfBuffer,
|
|
ContentType: "application/pdf",
|
|
});
|
|
await s3Client.send(uploadCommand);
|
|
console.log("[generate-pdf] S3 upload successful");
|
|
|
|
// Generate a presigned URL valid for 7 days
|
|
const getObjectCommand = new GetObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: s3Key,
|
|
});
|
|
const s3Url = await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 604800 }); // 7 days in seconds
|
|
console.log("[generate-pdf] Presigned URL generated (expires in 7 days)");
|
|
|
|
// 12) Update salary_transfers with callsheet_url
|
|
console.log("[generate-pdf] Updating salary_transfers table...");
|
|
const { error: updateError } = await supabase
|
|
.from("salary_transfers")
|
|
.update({
|
|
callsheet_url: s3Url,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("id", salary_transfer_id);
|
|
|
|
if (updateError) {
|
|
console.error("[generate-pdf] Failed to update salary_transfers:", updateError);
|
|
return NextResponse.json(
|
|
{ error: "PDF generated but failed to update database", url: s3Url, details: updateError.message },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
console.log("[generate-pdf] Database updated successfully");
|
|
|
|
// 13) Return success
|
|
console.log("[generate-pdf] PDF generation complete!");
|
|
return NextResponse.json({
|
|
success: true,
|
|
callsheet_url: s3Url,
|
|
payslips_count: payslips?.length || 0,
|
|
total_net: totalNet,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("[generate-pdf] Unexpected error:", err);
|
|
console.error("[generate-pdf] Stack trace:", err.stack);
|
|
return NextResponse.json({
|
|
error: err.message || "Internal server error",
|
|
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
|
}, { status: 500 });
|
|
}
|
|
}
|