261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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 });
|
||
}
|
||
}
|