diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts
index 6b852a8..6a0ae6f 100644
--- a/src/app/admin/organizations/actions.ts
+++ b/src/app/admin/organizations/actions.ts
@@ -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}`);
diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts
index 8953cf7..e056d9b 100644
--- a/src/app/api/signup/route.ts
+++ b/src/app/api/signup/route.ts
@@ -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,
+ });
}
diff --git a/src/app/espace-ce/layout.tsx b/src/app/espace-ce/layout.tsx
new file mode 100644
index 0000000..75cdc1c
--- /dev/null
+++ b/src/app/espace-ce/layout.tsx
@@ -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}>;
+}
diff --git a/src/app/espace-ce/page.tsx b/src/app/espace-ce/page.tsx
new file mode 100644
index 0000000..fd64787
--- /dev/null
+++ b/src/app/espace-ce/page.tsx
@@ -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 (
+
+
+
+ {!org.approved ? (
+
+
+ 🕒 Votre organisation est en attente de validation
+
+
+ L'é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'est pas
+ validée. Cela prend généralement moins de 48h. Si vous n'avez pas de retour
+ sous 72h, contactez{" "}
+
+ contact@karbe.cosmolan.fr
+
+ .
+
+
+ ) : null}
+
+
+
+
+ 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
+ />
+ 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
+ />
+
+
+
+ Les liens « Mes carbets » et « Matériel rental » seront actifs au Sprint I et J du plan
+ CE management.
+
+
+ );
+}
+
+function KpiCard({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
+
+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 = (
+ <>
+
+ {title}
+ {comingSoon ? (
+
+ Bientôt
+
+ ) : null}
+
+ {description}
+ >
+ );
+ if (disabled || comingSoon) {
+ return {inner}
;
+ }
+ return (
+
+ {inner}
+
+ );
+}
diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx
index 6f8f7bd..3ac9fc5 100644
--- a/src/app/inscription/_components/SignupForm.tsx
+++ b/src/app/inscription/_components/SignupForm.tsx
@@ -10,9 +10,10 @@ export function SignupForm({ next }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState(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 = {
@@ -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) {
Type de compte
-
+
Loueur matériel
Hamac, pirogue, kayak…
+
+ setRole("CE_MANAGER")}
+ className="sr-only"
+ />
+ Comité d'Entreprise
+ Gérer les carbets et matériel d'un CE.
+
+ {role === "CE_MANAGER" ? (
+
+
+ Votre Comité d'Entreprise sera créé en attente de validation .
+ Vous pouvez vous connecter à votre espace CE dès la création mais ne publierez
+ vos carbets et matériel qu'après validation par l'équipe Karbé.
+
+
+ Nom de votre Comité d'Entreprise
+ setOrgName(e.target.value)}
+ placeholder="ex. CE Spatiale de Kourou"
+ maxLength={200}
+ className={inputCls + " mt-0.5"}
+ />
+
+
+ ) : null}
+
{role === "RENTAL_PROVIDER" ? (
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
index 3d3f8f0..dd5b682 100644
--- a/src/components/SiteHeader.tsx
+++ b/src/components/SiteHeader.tsx
@@ -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 (
@@ -72,6 +76,11 @@ export async function SiteHeader() {
Espace prestataire
) : null}
+ {isCeManager && ceEnabled ? (
+
+ Espace CE
+
+ ) : null}
{isAdmin ? (
Admin
diff --git a/src/lib/ce-access.ts b/src/lib/ce-access.ts
new file mode 100644
index 0000000..c525d04
--- /dev/null
+++ b/src/lib/ce-access.ts
@@ -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 {
+ 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;
+}
diff --git a/src/lib/ce-dashboard.ts b/src/lib/ce-dashboard.ts
new file mode 100644
index 0000000..c5edb40
--- /dev/null
+++ b/src/lib/ce-dashboard.ts
@@ -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);
+}
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 2e9f79a..070370f 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -186,6 +186,44 @@ export async function sendBookingConfirmed(
});
}
+export async function sendNewCeRequest(
+ orgName: string,
+ managerEmail: string,
+): Promise {
+ 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",
+ `Une organisation vient de s'inscrire en tant que Comité d'Entreprise.
+
+ Nom : ${orgName}
+ Email du manager : ${managerEmail}
+
+ Valider sur l'admin Karbé
+ Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que Organization.approved=false.
`,
+ ),
+ });
+}
+
+export async function sendCeApproved(
+ to: string,
+ firstName: string,
+ orgName: string,
+): Promise {
+ await sendEmail({
+ to,
+ subject: `Votre CE « ${orgName} » est validé sur Karbé`,
+ html: wrap(
+ "Organisation validée",
+ `Bonjour ${firstName},
+ Votre Comité d'Entreprise ${orgName} 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.
+ Accéder à mon espace CE
`,
+ ),
+ });
+}
+
export async function sendNewRentalProviderRequest(
providerName: string,
userEmail: string,