diff --git a/prisma/migrations/20260603200000_ce_management/migration.sql b/prisma/migrations/20260603200000_ce_management/migration.sql new file mode 100644 index 0000000..eb2cc87 --- /dev/null +++ b/prisma/migrations/20260603200000_ce_management/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2772d7..49f703a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx new file mode 100644 index 0000000..d53a21c --- /dev/null +++ b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx @@ -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(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 ( +
+ + {error ? {error} : null} +
+ ); +} diff --git a/src/app/admin/organizations/[id]/page.tsx b/src/app/admin/organizations/[id]/page.tsx index 90c91b6..810ba23 100644 --- a/src/app/admin/organizations/[id]/page.tsx +++ b/src/app/admin/organizations/[id]/page.tsx @@ -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 (
@@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) { ← Toutes les organisations -

{org.name}

+

+ {org.name} + {org.approved ? ( + + Validée + + ) : ( + + À valider + + )} +

- /{org.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""} + /{org.slug} · {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

+ {org.contactEmail ? ( +

+ Contact : {org.contactEmail} +

+ ) : null} +
+
+ {!org.approved ? : null} +
-
diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts index 5f8bcf6..6b852a8 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -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(); diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx index 0d394ba..b6a5e95 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -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) { + +
+ {approved !== "all" ? ( + + ) : null} Nom + Statut Slug Membres Créée @@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { {orgs.length === 0 ? ( - + Aucune organisation. @@ -76,6 +116,17 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}
) : null} + + {o.approved ? ( + + Validée + + ) : ( + + À valider + + )} + /{o.slug} {o.membersCount} {dateFmt.format(o.createdAt)} diff --git a/src/lib/admin/organizations.ts b/src/lib/admin/organizations.ts index e333006..b2bc680 100644 --- a/src/lib/admin/organizations.ts +++ b/src/lib/admin/organizations.ts @@ -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 { + 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 }; +} diff --git a/src/lib/plugins/registry.ts b/src/lib/plugins/registry.ts index 3294a20..854b48c 100644 --- a/src/lib/plugins/registry.ts +++ b/src/lib/plugins/registry.ts @@ -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 {