feat(ce): Sprint G — data model + admin validation
All checks were successful
CI / test (pull_request) Successful in 2m38s

Schema:
- Organization gagne le workflow d'approbation (approved + approvedAt +
  approvedBy + contactEmail). Backfill : toutes les orgs existantes
  (CMCK) → approved=true via migration.
- Nouveau OrganizationCarbetMembership (manyToMany Org↔Carbet) pour la
  co-gestion CE : un Carbet a un ownerId (créateur initial) + 0..n
  memberships ; chaque CE_MANAGER d'une org liée peut gérer le carbet
  en plus de l'owner. Pour un hôte individuel = pas de membership.
- RentalProvider.organizationId (nullable, SetNull on delete) : un CE
  peut posséder son provider ; les CE_MANAGERs membres de l'org y ont
  accès en plus du manager nominal.

Plugin ce-management ajouté au registry (catégorie business, off par
défaut). Quand off : signup CE caché + dashboard /espace-ce 404.

Admin organizations :
- Tab statut (Toutes / À valider [count] / Validées) avec compteur des
  organisations pending dans l'en-tête.
- Badge statut sur la liste et la page détail.
- Bouton « Valider l'organisation » sur le détail (action
  approveOrganizationAction → flip approved=true + approvedAt + audit
  log organization.approve). Idempotent : un re-appel sur une org déjà
  validée ne re-loggue pas.
- Détail montre les compteurs carbetMemberships + rentalProviders.

Migration appliquée à la DB prod (CMCK backfill validé).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 22:55:54 +00:00
parent d24e3b4af7
commit 946dd8d5d2
8 changed files with 271 additions and 18 deletions

View file

@ -0,0 +1,54 @@
-- Sprint G : CE management.
-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy)
-- + un contactEmail dédié pour les notifications admin.
-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les
-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être
-- co-publié par plusieurs orgs (cas rare mais autorisé).
-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider.
ALTER TABLE "Organization"
ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN "approvedAt" TIMESTAMP(3),
ADD COLUMN "approvedBy" TEXT;
CREATE INDEX "Organization_approved_idx" ON "Organization"("approved");
-- Backfill : toutes les orgs existantes sont considérées validées.
-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront
-- en approved=false par défaut.)
UPDATE "Organization"
SET "approved" = TRUE,
"approvedAt" = NOW()
WHERE "approved" = FALSE;
CREATE TABLE "OrganizationCarbetMembership" (
"organizationId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"addedByUserId" TEXT,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId")
);
CREATE INDEX "OrganizationCarbetMembership_carbetId_idx"
ON "OrganizationCarbetMembership"("carbetId");
ALTER TABLE "OrganizationCarbetMembership"
ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationCarbetMembership"
ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey"
FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RentalProvider"
ADD COLUMN "organizationId" TEXT;
CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId");
ALTER TABLE "RentalProvider"
ADD CONSTRAINT "RentalProvider_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -72,16 +72,40 @@ enum TransportMode {
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
slug String @unique
description String?
contactEmail String?
approved Boolean @default(false)
approvedAt DateTime?
approvedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members User[]
members User[]
carbetMemberships OrganizationCarbetMembership[]
rentalProviders RentalProvider[]
@@index([name])
@@index([approved])
}
/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial),
/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet
/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE :
/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication.
model OrganizationCarbetMembership {
organizationId String
carbetId String
addedByUserId String?
addedAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@id([organizationId, carbetId])
@@index([carbetId])
}
model User {
@ -157,6 +181,7 @@ model Carbet {
bookings Booking[]
reviews Review[]
subscriptions Subscription[]
organizations OrganizationCarbetMembership[]
@@index([ownerId])
@@index([status])
@ -425,6 +450,9 @@ model RentalProvider {
name String
isSystemD Boolean @default(false)
managedByUserId String?
/// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER
/// membre de l'org peut gérer items et réservations en plus du manager nominal.
organizationId String?
contactEmail String?
contactPhone String?
rivers String[] @default([])
@ -438,11 +466,13 @@ model RentalProvider {
updatedAt DateTime @updatedAt
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
items RentalItem[]
rentalBookings RentalBooking[]
@@index([active, approved])
@@index([managedByUserId])
@@index([organizationId])
}
model RentalItem {

View file

@ -0,0 +1,36 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
};
export function ApproveOrgButton({ action }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function run() {
setError(null);
startTransition(async () => {
const res = await action();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
}
});
}
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={run}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
>
{pending ? "Validation…" : "Valider l'organisation"}
</button>
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
</div>
);
}

View file

@ -3,7 +3,8 @@ import Link from "next/link";
import { getOrganizationForAdmin } from "@/lib/admin/organizations";
import { OrgForm } from "../_components/OrgForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { deleteOrganizationAction, updateOrganizationAction } from "../actions";
import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions";
import { ApproveOrgButton } from "./_components/ApproveOrgButton";
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
export const dynamic = "force-dynamic";
@ -31,6 +32,10 @@ export default async function EditOrgPage({ params }: PageProps) {
"use server";
return await deleteOrganizationAction(id);
};
const approveThis = async () => {
"use server";
return await approveOrganizationAction(id);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) {
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les organisations
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{org.name}</h1>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{org.name}
{org.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Validée
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
À valider
</span>
)}
</h1>
<p className="mt-1 text-sm text-zinc-500">
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "}
{org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré
{org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental
</p>
{org.contactEmail ? (
<p className="text-xs text-zinc-500">
Contact : <a href={`mailto:${org.contactEmail}`} className="underline">{org.contactEmail}</a>
</p>
) : null}
</div>
<div className="flex items-center gap-2">
{!org.approved ? <ApproveOrgButton action={approveThis} /> : null}
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
</div>
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">

View file

@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
@ -75,6 +76,20 @@ export async function updateOrganizationAction(id: string, fd: FormData) {
return { ok: true as const };
}
export async function approveOrganizationAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actor = session?.user?.email ?? null;
const res = await approveOrganizationLib(id, actor ?? "admin");
if (!res.ok) return res;
if (!res.alreadyApproved) {
await audit("organization.approve", id, actor, {});
}
revalidatePath("/admin/organizations");
revalidatePath(`/admin/organizations/${id}`);
return { ok: true as const };
}
export async function deleteOrganizationAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();

View file

@ -1,16 +1,27 @@
import Link from "next/link";
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ q?: string }>;
searchParams: Promise<{ q?: string; status?: string }>;
};
const STATUS_VALUES = ["all", "pending", "approved"] as const;
type StatusFilter = (typeof STATUS_VALUES)[number];
function isStatusFilter(s: string | undefined): s is StatusFilter {
return STATUS_VALUES.includes(s as StatusFilter);
}
export default async function OrgsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = { q: sp.q?.trim() || undefined };
const orgs = await listOrganizationsAdmin(filters);
const approved = isStatusFilter(sp.status) ? sp.status : "all";
const filters = { q: sp.q?.trim() || undefined, approved };
const [orgs, pendingCount] = await Promise.all([
listOrganizationsAdmin(filters),
countPendingOrganizations(),
]);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
@ -30,7 +41,35 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
</Link>
</header>
<nav className="mb-3 flex flex-wrap gap-2 text-sm">
{(
[
{ key: "all", label: "Toutes" },
{ key: "pending", label: pendingCount > 0 ? `À valider (${pendingCount})` : "À valider" },
{ key: "approved", label: "Validées" },
] as { key: StatusFilter; label: string }[]
).map((t) => {
const href = `/admin/organizations?status=${t.key}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ""}`;
const active = approved === t.key;
return (
<Link
key={t.key}
href={href}
className={
"rounded-md px-3 py-1 font-medium " +
(active ? "bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-700 hover:bg-zinc-200")
}
>
{t.label}
</Link>
);
})}
</nav>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
{approved !== "all" ? (
<input type="hidden" name="status" value={approved} />
) : null}
<input
type="text"
name="q"
@ -53,6 +92,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-left font-semibold">Slug</th>
<th className="px-4 py-2 text-right font-semibold">Membres</th>
<th className="px-4 py-2 text-right font-semibold">Créée</th>
@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
<tbody className="divide-y divide-zinc-100">
{orgs.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-sm text-zinc-500">
<td colSpan={5} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune organisation.
</td>
</tr>
@ -76,6 +116,17 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
) : null}
</td>
<td className="px-4 py-2">
{o.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Validée
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
À valider
</span>
)}
</td>
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>

View file

@ -3,13 +3,16 @@ import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
export type AdminOrgFilters = { q?: string };
export type AdminOrgFilters = { q?: string; approved?: "all" | "pending" | "approved" };
export type AdminOrgListItem = {
id: string;
name: string;
slug: string;
description: string | null;
contactEmail: string | null;
approved: boolean;
approvedAt: Date | null;
createdAt: Date;
membersCount: number;
};
@ -23,16 +26,21 @@ export async function listOrganizationsAdmin(filters: AdminOrgFilters = {}): Pro
{ description: { contains: filters.q, mode: "insensitive" } },
];
}
if (filters.approved === "pending") where.approved = false;
else if (filters.approved === "approved") where.approved = true;
const rows = await prisma.organization.findMany({
where,
orderBy: [{ name: "asc" }],
orderBy: [{ approved: "asc" }, { name: "asc" }],
take: 200,
select: {
id: true,
name: true,
slug: true,
description: true,
contactEmail: true,
approved: true,
approvedAt: true,
createdAt: true,
_count: { select: { members: true } },
},
@ -42,11 +50,18 @@ export async function listOrganizationsAdmin(filters: AdminOrgFilters = {}): Pro
name: o.name,
slug: o.slug,
description: o.description,
contactEmail: o.contactEmail,
approved: o.approved,
approvedAt: o.approvedAt,
createdAt: o.createdAt,
membersCount: o._count.members,
}));
}
export async function countPendingOrganizations(): Promise<number> {
return prisma.organization.count({ where: { approved: false } });
}
export async function getOrganizationForAdmin(id: string) {
return prisma.organization.findUnique({
where: { id },
@ -55,6 +70,24 @@ export async function getOrganizationForAdmin(id: string) {
orderBy: [{ role: "asc" }, { lastName: "asc" }],
select: { id: true, firstName: true, lastName: true, email: true, role: true, isActive: true },
},
_count: { select: { carbetMemberships: true, rentalProviders: true } },
},
});
}
export async function approveOrganization(
id: string,
adminEmail: string,
): Promise<{ ok: true; alreadyApproved: boolean } | { ok: false; error: string }> {
const org = await prisma.organization.findUnique({
where: { id },
select: { id: true, approved: true },
});
if (!org) return { ok: false, error: "Organisation introuvable" };
if (org.approved) return { ok: true, alreadyApproved: true };
await prisma.organization.update({
where: { id },
data: { approved: true, approvedAt: new Date(), approvedBy: adminEmail },
});
return { ok: true, alreadyApproved: false };
}

View file

@ -117,6 +117,14 @@ export const PLUGINS: PluginDescriptor[] = [
category: "business",
version: "0.1.0",
},
{
key: "ce-management",
name: "Gestion des Comités d'Entreprise",
description:
"Permet à un CE de s'inscrire (validation admin), publier ses carbets en co-gestion (OrganizationCarbetMembership), et activer un RentalProvider org-scoped pour louer son matériel. Dashboard /espace-ce avec KPIs agrégés par organisation. Si désactivé : /espace-ce et le choix « Comité d'Entreprise » sur /inscription disparaissent.",
category: "business",
version: "0.1.0",
},
// Contenus / i18n
{