feat(ce): Sprint H — signup CE public + /espace-ce shell
All checks were successful
CI / test (push) Successful in 2m21s
All checks were successful
CI / test (push) Successful in 2m21s
This commit is contained in:
commit
3d77632ba0
9 changed files with 544 additions and 36 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
8
src/app/espace-ce/layout.tsx
Normal file
8
src/app/espace-ce/layout.tsx
Normal 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
145
src/app/espace-ce/page.tsx
Normal 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'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'é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{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'Entreprise</span>
|
||||
<span className="text-[11px] text-zinc-500">Gérer les carbets et matériel d'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'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'après validation par l'équipe Karbé.
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nom de votre Comité d'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">
|
||||
|
|
|
|||
|
|
@ -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
92
src/lib/ce-access.ts
Normal 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
90
src/lib/ce-dashboard.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue