espace-paie-odentas/app/api/contrats/[id]/paies/route.ts

261 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } 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";
// Upstream (Lambda/API Gateway)
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 ?? "/api"; // ex: "/api" ou ""
function b64(s: string) {
return Buffer.from(s, "utf8").toString("base64");
}
async function createSbServiceRole() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || "",
process.env.SUPABASE_SERVICE_ROLE_KEY || ""
);
}
async function assertStaff(sb: any, userId: string) {
const { data: me } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", userId)
.maybeSingle();
return !!me?.is_staff;
}
/** Resolve l'organisation active (staff ou client) de la façon la plus simple possible. */
async function resolveOrganization(supabase: any, session: any) {
const meta = session?.user?.user_metadata || {};
const app = session?.user?.app_metadata || {};
// Vérifier d'abord si c'est un utilisateur staff via la table staff_users
const sbService = await createSbServiceRole();
const isStaff = await assertStaff(sbService, session.user.id);
if (isStaff) {
const jar = cookies();
const id = jar.get("active_org_id")?.value || null;
const name = jar.get("active_org_name")?.value || null;
// If staff has not selected an active org, allow global access by returning a null id.
// Callers should treat `id === null` as "staff/global access" and avoid applying org filters.
if (!id || !name) {
return { id: null, name: "Staff Access", isStaff: true } as const;
}
return { id, name, isStaff: true } as const;
}
// Utilisateur client : récupérer son org via 2 requêtes simples
const { data: member, error: mErr } = await supabase
.from("organization_members")
.select("org_id")
.eq("user_id", session.user.id)
.single();
if (mErr || !member?.org_id) throw new Error("Aucune organisation associée à l'utilisateur.");
const { data: org, error: oErr } = await supabase
.from("organizations")
.select("structure_api")
.eq("id", member.org_id)
.single();
if (oErr || !org?.structure_api) throw new Error("Organisation introuvable.");
return { id: member.org_id, name: org.structure_api, isStaff: false } as const;
}
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const requestId = randomUUID();
try {
const id = params?.id;
if (!id) 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 });
// Organisation
const org = await resolveOrganization(supabase, session);
// Choix du régime via query param, défaut = CDDU_MULTI
const url = new URL(req.url);
const regimeParam = (url.searchParams.get("regime") || "CDDU_MULTI").toUpperCase();
const allowed = new Set(["CDDU_MULTI", "RG"]);
const regime = allowed.has(regimeParam) ? regimeParam : "CDDU_MULTI";
// If CDDU_MULTI or RG, read payslips from Supabase directly
if (regime === "CDDU_MULTI" || regime === "RG") {
// Helper slugify for organization name
const slugify = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 200);
// Try to fetch the contract reference to build storage paths
// When staff (org.isStaff === true), use the admin client to bypass RLS.
let contractRow: any = null;
let contractErr: any = null;
if (org.isStaff) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const r = await admin.from("cddu_contracts").select("contract_number").eq("id", id).maybeSingle();
contractRow = r.data;
contractErr = r.error;
} else {
let contractQuery: any = supabase.from("cddu_contracts").select("contract_number").eq("id", id);
if (org.id) contractQuery = contractQuery.eq("org_id", org.id);
const r = await contractQuery.maybeSingle();
contractRow = r.data;
contractErr = r.error;
}
if (contractErr) {
// If Supabase fails, return an error so frontend can fallback
return NextResponse.json({ error: contractErr.message, requestId }, { status: 500 });
}
const contractRef = contractRow?.contract_number || null;
const orgSlug = slugify(org.name || "unknown");
// Query payslips table for this contract
// When staff (org.isStaff === true) use admin client to return all payslips for contract,
// regardless of whether they have an active org selected or not.
let pays: any = null;
let paysErr: any = null;
if (org.isStaff) {
const admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL || "", process.env.SUPABASE_SERVICE_ROLE_KEY || "");
const r = await admin.from("payslips").select("*").eq("contract_id", id).order("pay_number", { ascending: true });
pays = r.data;
paysErr = r.error;
} else {
let paysQuery: any = supabase.from("payslips").select("*").eq("contract_id", id);
if (org.id) paysQuery = paysQuery.eq("organization_id", org.id);
const r = await paysQuery.order("pay_number", { ascending: true });
pays = r.data;
paysErr = r.error;
}
if (paysErr) {
return NextResponse.json({ error: paysErr.message }, { status: 500 });
}
const items = await Promise.all((pays || []).map(async (row: any) => {
// Determine year/month from period_month or period_start
const period = row.period_month || row.period_start || null;
let year: number | null = null;
let month: number | null = null;
if (period) {
const d = new Date(period);
if (!isNaN(d.getTime())) {
year = d.getFullYear();
month = d.getMonth() + 1;
}
}
// Use Supabase-provided storage_path or storage_url exclusively.
// Prefer `row.storage_path` (S3 key) and presign it if it's not already a full URL.
const storagePath = row.storage_path || null;
let paieUrl: string | null = null;
if (storagePath && !/^https?:\/\//.test(String(storagePath))) {
try {
const maybe = await getS3SignedUrlIfExists(storagePath);
paieUrl = maybe ?? (row.storage_url || row.storage_path || null);
} catch (e) {
paieUrl = row.storage_url || row.storage_path || null;
}
} else {
// If storage_path is already a URL or absent, prefer storage_url then storage_path
paieUrl = row.storage_url || row.storage_path || null;
}
return {
id: row.id,
ordre: row.pay_number,
mois: month,
annee: year,
aem_statut: row.aem_status || null,
paie_pdf: paieUrl,
// Expose net_amount as net_avant_pas and prefer net_after_withholding for net_a_payer
net_avant_pas: row.net_amount || null,
net_a_payer: row.net_after_withholding || row.net_amount || null,
brut: row.gross_amount || null,
cout_total: row.employer_cost || null,
transfer_done: !!row.transfer_done,
traite: row.processed ? "oui" : "non",
// Expose raw period_start/period_end (from Supabase) as separate fields so the frontend
// can format them reliably. Keep paie_traitee for backward compatibility.
period_start: row.period_start || null,
period_end: row.period_end || null,
paie_traitee: row.period_start && row.period_end ? `${row.period_start} ${row.period_end}` : null,
};
}));
// Ensure ordered by ordre ascending
items.sort((a: any, b: any) => (a.ordre ?? 0) - (b.ordre ?? 0));
return NextResponse.json({ items, requestId }, { status: 200 });
}
const upstream = `${UPSTREAM_BASE}${PATH_PREFIX}/contrats/${encodeURIComponent(id)}/paies?regime=${encodeURIComponent(regime)}&_t=${Date.now()}`;
// Prépare les headers minimalistes attendus par la Lambda
const jar = cookies();
const orgKey = jar.get("active_org_key")?.value || ""; // optionnel
const headers = new Headers({
Accept: "application/json",
"x-forwarded-from": "odentas-espace-paie",
"x-request-id": requestId,
});
// Only include organization-specific headers when an organization is selected.
if (org.id) {
headers.set("x-active-org-id", String(org.id));
if (org.name) {
headers.set("x-company-name", org.name);
headers.set("x-company-name-b64", b64(org.name));
}
}
if (orgKey) headers.set("x-company-key", orgKey);
if (process.env.STRUCTURE_API_TOKEN) headers.set("authorization", `Bearer ${process.env.STRUCTURE_API_TOKEN}`);
// Timeout 10s — simple et robuste
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);
// Normalisation : 404 upstream => items vides
if (res.status === 404) return NextResponse.json({ items: [] }, { status: 200 });
if (!res.ok) {
const text = await res.text().catch(() => "");
return NextResponse.json({ error: "upstream_error", status: res.status, body: text, requestId }, { status: 502 });
}
// Le backend peut renvoyer soit { items: [...] } soit directement [...]
const data = await res.json().catch(() => null);
const items = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
// Tri de secours par "ordre" si présent, pour un affichage cohérent
items.sort((a: any, b: any) => (a?.ordre ?? 1e9) - (b?.ordre ?? 1e9));
return NextResponse.json({ items, requestId }, { status: 200 });
} catch (err: any) {
const msg = String(err?.message || err || "");
if (msg.toLowerCase().includes("timeout") || err?.name === "AbortError") {
return NextResponse.json({ error: "gateway_timeout", message: "Upstream timeout", requestId }, { status: 504 });
}
return NextResponse.json({ error: "server_error", message: msg, requestId }, { status: 500 });
}
}