import { NextResponse } from "next/server"; import { z } from "zod"; import { UserRole } from "@/generated/prisma/enums"; import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites"; import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email"; import { rateLimitRequest } from "@/lib/rate-limit"; import { slugify } from "@/lib/slug"; export const runtime = "nodejs"; const schema = z.object({ email: z.string().trim().toLowerCase().email().max(200), password: z.string().min(8).max(200), firstName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100), phone: z.string().trim().max(40).optional().nullable(), role: z .enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER]) .default(UserRole.TOURIST), providerName: z.string().trim().min(2).max(200).optional(), providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(), orgName: z.string().trim().min(2).max(200).optional(), inviteToken: z.string().trim().min(8).max(200).optional(), }); export async function POST(req: Request) { const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5); if (!rl.ok) { return NextResponse.json( { error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` }, { status: 429, headers: { "Retry-After": String(rl.retryAfter) } }, ); } let body: unknown; try { body = await req.json(); } catch { return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); } const parsed = schema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }, { status: 400 }, ); } const data = parsed.data; if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) { return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 }); } if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) { return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 }); } // Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER // et on rattache à l'org du token (org déjà validée — pas de bannière pending). let inviteOrgId: string | null = null; if (data.inviteToken) { const invite = await getOrgInviteByToken(data.inviteToken); if (!invite) { return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 }); } if (invite.email && invite.email.toLowerCase() !== data.email) { return NextResponse.json( { error: "Ce lien d'invitation est réservé à un autre email." }, { status: 400 }, ); } inviteOrgId = invite.organizationId; } const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } }); if (existing) { return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 }); } const passwordHash = await hashPassword(data.password); // CE_MANAGER : transaction atomique User + Organization. Le slug est unique // sur Organization → on retente avec un suffixe en cas de collision. let createdProviderId: string | null = null; let createdOrgId: string | null = null; let user: { id: string; email: string; role: UserRole }; if (inviteOrgId) { // Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role. user = await prisma.user.create({ data: { email: data.email, passwordHash, firstName: data.firstName, lastName: data.lastName, phone: data.phone?.trim() || null, role: UserRole.CE_MEMBER, organizationId: inviteOrgId, isActive: true, }, select: { id: true, email: true, role: true }, }); createdOrgId = inviteOrgId; await markOrgInviteConsumed(data.inviteToken!).catch(() => {}); } else if (data.role === UserRole.CE_MANAGER) { const orgName = data.orgName!.trim(); const baseSlug = slugify(orgName); const result = await prisma.$transaction(async (tx) => { // Trouve un slug libre let candidate = baseSlug || "ce"; let suffix = 1; for (;;) { const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } }); if (!exists) break; suffix += 1; candidate = `${baseSlug}-${suffix}`; } // candidate now holds a free slug const org = await tx.organization.create({ data: { name: orgName, slug: candidate, contactEmail: data.email, approved: false, }, select: { id: true }, }); const u = await tx.user.create({ data: { email: data.email, passwordHash, firstName: data.firstName, lastName: data.lastName, phone: data.phone?.trim() || null, role: UserRole.CE_MANAGER, organizationId: org.id, isActive: true, }, select: { id: true, email: true, role: true }, }); return { user: u, orgId: org.id }; }); user = result.user; createdOrgId = result.orgId; sendNewCeRequest(orgName, user.email).catch(() => {}); } else { user = await prisma.user.create({ data: { email: data.email, passwordHash, firstName: data.firstName, lastName: data.lastName, phone: data.phone?.trim() || null, role: data.role, isActive: true, }, select: { id: true, email: true, role: true }, }); // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation. if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) { const provider = await prisma.rentalProvider.create({ data: { name: data.providerName, isSystemD: false, managedByUserId: user.id, contactEmail: user.email, contactPhone: data.phone?.trim() || null, rivers: data.providerRivers ?? [], commissionPct: 10, // valeur par défaut, ajustable par admin active: true, approved: false, }, select: { id: true, name: true }, }); createdProviderId = provider.id; sendNewRentalProviderRequest(provider.name, user.email).catch(() => {}); } } await recordAudit({ scope: "public.signup", event: "user.create", target: user.id, actorEmail: user.email, details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId }, }); sendSignupWelcome(user.email, data.firstName).catch(() => {}); return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId, organizationId: createdOrgId, }); }