feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (push) Successful in 2m21s

This commit is contained in:
tarzzan 2026-06-02 23:13:15 +00:00
commit 3d77632ba0
9 changed files with 544 additions and 36 deletions

View file

@ -7,6 +7,7 @@ import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations";
import { requireRole } from "@/lib/authorization";
import { sendCeApproved } from "@/lib/email";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
@ -84,6 +85,24 @@ export async function approveOrganizationAction(id: string) {
if (!res.ok) return res;
if (!res.alreadyApproved) {
await audit("organization.approve", id, actor, {});
// Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué.
try {
const data = await prisma.organization.findUnique({
where: { id },
select: {
name: true,
members: {
where: { role: UserRole.CE_MANAGER, isActive: true },
select: { email: true, firstName: true },
},
},
});
for (const m of data?.members ?? []) {
await sendCeApproved(m.email, m.firstName, data?.name ?? "");
}
} catch (e) {
console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e);
}
}
revalidatePath("/admin/organizations");
revalidatePath(`/admin/organizations/${id}`);

View file

@ -5,8 +5,9 @@ import { UserRole } from "@/generated/prisma/enums";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
import { slugify } from "@/lib/slug";
export const runtime = "nodejs";
@ -17,10 +18,11 @@ const schema = z.object({
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])
.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(),
});
export async function POST(req: Request) {
@ -49,6 +51,9 @@ export async function POST(req: Request) {
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 });
}
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
if (existing) {
@ -56,38 +61,87 @@ export async function POST(req: Request) {
}
const passwordHash = await hashPassword(data.password);
const 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.
// 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;
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 },
let createdOrgId: string | null = null;
let user: { id: string; email: string; role: UserRole };
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 };
});
createdProviderId = provider.id;
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
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({
@ -95,10 +149,15 @@ export async function POST(req: Request) {
event: "user.create",
target: user.id,
actorEmail: user.email,
details: { role: user.role, rentalProviderId: createdProviderId },
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
});
sendSignupWelcome(user.email, data.firstName).catch(() => {});
return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId });
return NextResponse.json({
ok: true,
userId: user.id,
providerId: createdProviderId,
organizationId: createdOrgId,
});
}

View file

@ -0,0 +1,8 @@
import { requirePluginOr404 } from "@/lib/plugins/guard";
import { requireCeManagerSession } from "@/lib/ce-access";
export default async function CeLayout({ children }: { children: React.ReactNode }) {
await requirePluginOr404("ce-management");
await requireCeManagerSession();
return <>{children}</>;
}

145
src/app/espace-ce/page.tsx Normal file
View file

@ -0,0 +1,145 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { getCeOrgKpis } from "@/lib/ce-dashboard";
export const dynamic = "force-dynamic";
export const metadata = { title: "Espace CE — Karbé" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function CeDashboardPage() {
const org = await getCurrentCeOrganization();
if (!org) {
// ADMIN sans organizationId ciblé : pour l'instant, renvoyer vers la liste admin.
redirect("/admin/organizations");
}
const kpis = await getCeOrgKpis(org.id);
return (
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
<header>
<h1 className="text-3xl font-semibold text-zinc-900">
Espace CE {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Dashboard de votre comité d&apos;entreprise. Co-gérez vos carbets et activez la location
de matériel pour vos membres et le public touriste.
</p>
</header>
{!org.approved ? (
<section className="rounded-lg border border-amber-200 bg-amber-50/60 px-5 py-4">
<h2 className="text-base font-semibold text-amber-900">
🕒 Votre organisation est en attente de validation
</h2>
<p className="mt-1 text-sm text-amber-900">
L&apos;équipe Karbé vérifie votre demande. Vous pouvez préparer vos carbets et items
en brouillon, mais rien ne sera publié tant que votre organisation n&apos;est pas
validée. Cela prend généralement moins de 48h. Si vous n&apos;avez pas de retour
sous 72h, contactez{" "}
<a href="mailto:contact@karbe.cosmolan.fr" className="underline">
contact@karbe.cosmolan.fr
</a>
.
</p>
</section>
) : null}
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Carbets co-gérés" value={kpis.carbetsCount} />
<KpiCard label="Items matériel" value={kpis.rentalItemsCount} />
<KpiCard label="Réservations 30j" value={kpis.bookings30dCount + kpis.rentalBookings30dCount} />
<KpiCard label="Revenu 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
href={org.approved ? "/espace-ce/carbets" : "/espace-ce"}
title="Mes carbets"
description={
kpis.carbetsCount > 0
? `${kpis.carbetsCount} carbet${kpis.carbetsCount > 1 ? "s" : ""} co-géré${kpis.carbetsCount > 1 ? "s" : ""} par votre CE.`
: org.approved
? "Ajoutez votre premier carbet et ouvrez-le à vos membres + au public."
: "Disponible après validation de votre organisation."
}
disabled={!org.approved}
comingSoon
/>
<ActionCard
href={org.approved ? "/espace-ce/materiel" : "/espace-ce"}
title="Matériel rental"
description={
kpis.rentalItemsCount > 0
? `${kpis.rentalItemsCount} item${kpis.rentalItemsCount > 1 ? "s" : ""} en location.`
: org.approved
? "Proposez hamacs, kayaks, pirogue… à vos membres et au public."
: "Disponible après validation de votre organisation."
}
disabled={!org.approved}
comingSoon
/>
</section>
<p className="text-xs text-zinc-500">
Les liens « Mes carbets » et « Matériel rental » seront actifs au Sprint I et J du plan
CE management.
</p>
</main>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}
function ActionCard({
href,
title,
description,
disabled,
comingSoon,
}: {
href: string;
title: string;
description: string;
disabled?: boolean;
comingSoon?: boolean;
}) {
const baseCls =
"rounded-lg border bg-white px-5 py-4 shadow-sm transition " +
(disabled
? "border-zinc-200 opacity-60"
: "border-zinc-200 hover:border-zinc-400 hover:shadow");
const inner = (
<>
<h3 className="text-base font-semibold text-zinc-900">
{title}
{comingSoon ? (
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-600">
Bientôt
</span>
) : null}
</h3>
<p className="mt-1 text-sm text-zinc-600">{description}</p>
</>
);
if (disabled || comingSoon) {
return <div className={baseCls}>{inner}</div>;
}
return (
<Link href={href} className={baseCls}>
{inner}
</Link>
);
}

View file

@ -10,9 +10,10 @@ export function SignupForm({ next }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER">("TOURIST");
const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER" | "CE_MANAGER">("TOURIST");
const [providerName, setProviderName] = useState("");
const [providerRivers, setProviderRivers] = useState("");
const [orgName, setOrgName] = useState("");
function onSubmit(formData: FormData) {
setError(null);
@ -30,6 +31,10 @@ export function SignupForm({ next }: Props) {
setError("Le nom de votre activité de loueur est requis.");
return;
}
if (role === "CE_MANAGER" && orgName.trim().length < 2) {
setError("Le nom de votre Comité d'Entreprise est requis.");
return;
}
startTransition(async () => {
const body: Record<string, unknown> = {
@ -47,6 +52,9 @@ export function SignupForm({ next }: Props) {
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
if (role === "CE_MANAGER") {
body.orgName = orgName.trim();
}
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -112,7 +120,7 @@ export function SignupForm({ next }: Props) {
<fieldset className="space-y-1">
<legend className="text-xs text-zinc-600">Type de compte</legend>
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-2 lg:grid-cols-4">
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
@ -170,9 +178,49 @@ export function SignupForm({ next }: Props) {
<span className="font-semibold text-zinc-900">Loueur matériel</span>
<span className="text-[11px] text-zinc-500">Hamac, pirogue, kayak</span>
</label>
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
(role === "CE_MANAGER"
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
: "border-zinc-300 hover:bg-zinc-50")
}
>
<input
type="radio"
name="role"
value="CE_MANAGER"
checked={role === "CE_MANAGER"}
onChange={() => setRole("CE_MANAGER")}
className="sr-only"
/>
<span className="font-semibold text-zinc-900">Comité d&apos;Entreprise</span>
<span className="text-[11px] text-zinc-500">Gérer les carbets et matériel d&apos;un CE.</span>
</label>
</div>
</fieldset>
{role === "CE_MANAGER" ? (
<div className="space-y-2 rounded-md border border-amber-200 bg-amber-50/40 p-3">
<p className="text-[11px] text-amber-900">
Votre Comité d&apos;Entreprise sera créé en <strong>attente de validation</strong>.
Vous pouvez vous connecter à votre espace CE dès la création mais ne publierez
vos carbets et matériel qu&apos;après validation par l&apos;équipe Karbé.
</p>
<label className="block">
<span className="text-xs text-zinc-600">Nom de votre Comité d&apos;Entreprise</span>
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
placeholder="ex. CE Spatiale de Kourou"
maxLength={200}
className={inputCls + " mt-0.5"}
/>
</label>
</div>
) : null}
{role === "RENTAL_PROVIDER" ? (
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50/30 p-3">
<p className="text-[11px] text-emerald-900">

View file

@ -18,7 +18,11 @@ export async function SiteHeader() {
const isAdmin = u?.role === UserRole.ADMIN;
const isOwner = u?.role === UserRole.OWNER || isAdmin;
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
const rentalEnabled = await isPluginEnabled("gear-rental");
const isCeManager = u?.role === UserRole.CE_MANAGER || isAdmin;
const [rentalEnabled, ceEnabled] = await Promise.all([
isPluginEnabled("gear-rental"),
isPluginEnabled("ce-management"),
]);
return (
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
@ -72,6 +76,11 @@ export async function SiteHeader() {
Espace prestataire
</Link>
) : null}
{isCeManager && ceEnabled ? (
<Link href="/espace-ce" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace CE
</Link>
) : null}
{isAdmin ? (
<Link href="/admin" className="hidden rounded-md bg-zinc-900 px-2.5 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block">
Admin

92
src/lib/ce-access.ts Normal file
View file

@ -0,0 +1,92 @@
import "server-only";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* Garde-fou commun pour /espace-ce/* : redirige vers /connexion si pas de session,
* vers / si le rôle n'est pas CE_MANAGER ni ADMIN.
*/
export async function requireCeManagerSession() {
const session = await auth();
if (!session?.user?.id) {
redirect("/connexion?next=/espace-ce");
}
const role = session.user.role;
if (role !== UserRole.CE_MANAGER && role !== UserRole.ADMIN) {
redirect("/");
}
return session;
}
/**
* Récupère l'Organization de l'utilisateur connecté (via User.organizationId).
* - CE_MANAGER son org (toujours rattaché)
* - ADMIN soit l'org ciblée par `organizationId`, soit null pour forcer le choix
*/
export async function getCurrentCeOrganization(opts: { organizationId?: string } = {}) {
const session = await auth();
if (!session?.user?.id) return null;
const role = session.user.role;
if (role === UserRole.ADMIN && opts.organizationId) {
return prisma.organization.findUnique({ where: { id: opts.organizationId } });
}
if (role === UserRole.ADMIN && !opts.organizationId) {
return null;
}
// CE_MANAGER : retourne son org via User.organizationId
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { organization: true },
});
return user?.organization ?? null;
}
/**
* Un CE_MANAGER peut-il gérer ce carbet ?
* - vrai s'il en est l'owner direct (`Carbet.ownerId == userId`)
* - OU s'il est membre d'une org liée au carbet via OrganizationCarbetMembership
* - ADMIN passe toujours.
*/
export async function canManageCarbetForCe(
userId: string,
role: string | undefined,
carbetId: string,
): Promise<boolean> {
if (role === UserRole.ADMIN) return true;
if (role !== UserRole.CE_MANAGER) return false;
const [carbet, user] = await Promise.all([
prisma.carbet.findUnique({
where: { id: carbetId },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { organizationId: true },
}),
]);
if (!carbet || !user?.organizationId) return false;
if (carbet.ownerId === userId) return true;
return carbet.organizations.some((m) => m.organizationId === user.organizationId);
}
/**
* Garantit que l'org du user est `approved=true`. Sinon redirige vers le dashboard
* /espace-ce qui affiche une bannière « En attente de validation ».
* Utiliser sur les pages qui doivent publier du contenu (créer carbet/item).
*/
export async function requireApprovedOrg() {
const org = await getCurrentCeOrganization();
if (!org || !org.approved) {
redirect("/espace-ce?pending=1");
}
return org;
}

90
src/lib/ce-dashboard.ts Normal file
View file

@ -0,0 +1,90 @@
import "server-only";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* KPIs agrégés à l'échelle d'une organisation CE.
* - carbets : nombre de carbets co-gérés via OrganizationCarbetMembership
* - rentalItems : items des providers liés à l'org
* - bookings30d : bookings confirmées sur les carbets de l'org (30 derniers jours)
* - rentalBookings30d : RentalBooking confirmées sur les providers de l'org
* - revenue30d : somme des amounts (booking + rental) sur 30j
*/
export async function getCeOrgKpis(organizationId: string) {
const since = new Date(Date.now() - 30 * 86_400_000);
const [carbetsCount, providers, bookings30d, rentalBookings30d] = await Promise.all([
prisma.organizationCarbetMembership.count({ where: { organizationId } }),
prisma.rentalProvider.findMany({
where: { organizationId },
select: {
id: true,
approved: true,
active: true,
_count: { select: { items: true } },
},
}),
prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
createdAt: { gte: since },
carbet: { organizations: { some: { organizationId } } },
},
select: { amount: true, currency: true },
}),
prisma.rentalBooking.findMany({
where: {
status: RentalBookingStatus.CONFIRMED,
createdAt: { gte: since },
provider: { organizationId },
},
select: { amount: true, currency: true },
}),
]);
const itemsCount = providers.reduce((s, p) => s + p._count.items, 0);
const revenue30d = [
...bookings30d.map((b) => Number(b.amount)),
...rentalBookings30d.map((r) => Number(r.amount)),
].reduce((s, n) => s + n, 0);
return {
carbetsCount,
providersCount: providers.length,
rentalItemsCount: itemsCount,
rentalProviderApproved: providers.every((p) => p.approved),
bookings30dCount: bookings30d.length,
rentalBookings30dCount: rentalBookings30d.length,
revenue30d,
};
}
/**
* Liste les carbets co-gérés par une org (joinés via membership).
*/
export async function listCeCarbets(organizationId: string) {
const memberships = await prisma.organizationCarbetMembership.findMany({
where: { organizationId },
orderBy: { addedAt: "desc" },
select: {
carbet: {
select: {
id: true,
slug: true,
title: true,
river: true,
status: true,
capacity: true,
nightlyPrice: true,
ownerId: true,
owner: { select: { firstName: true, lastName: true } },
},
},
},
});
return memberships.map((m) => m.carbet);
}

View file

@ -186,6 +186,44 @@ export async function sendBookingConfirmed(
});
}
export async function sendNewCeRequest(
orgName: string,
managerEmail: string,
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
await sendEmail({
to: adminEmail,
subject: `Nouvelle demande CE — ${orgName}`,
html: wrap(
"Demande de Comité d'Entreprise à valider",
`<p>Une organisation vient de s'inscrire en tant que Comité d'Entreprise.</p>
<ul>
<li>Nom : <strong>${orgName}</strong></li>
<li>Email du manager : ${managerEmail}</li>
</ul>
<p><a href="${SITE_URL}/admin/organizations?status=pending" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
<p style="font-size:12px;color:#71717a;">Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que <code>Organization.approved=false</code>.</p>`,
),
});
}
export async function sendCeApproved(
to: string,
firstName: string,
orgName: string,
): Promise<void> {
await sendEmail({
to,
subject: `Votre CE « ${orgName} » est validé sur Karbé`,
html: wrap(
"Organisation validée",
`<p>Bonjour ${firstName},</p>
<p>Votre Comité d'Entreprise <strong>${orgName}</strong> vient d'être validé. Vous pouvez désormais publier vos carbets et activer la location de matériel pour vos membres et le public touriste.</p>
<p><a href="${SITE_URL}/espace-ce" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Accéder à mon espace CE</a></p>`,
),
});
}
export async function sendNewRentalProviderRequest(
providerName: string,
userEmail: string,