feat(ce): Sprint I — CRUD carbets côté CE
All checks were successful
CI / test (pull_request) Successful in 2m36s

Session étendue :
- Ajout session.user.organizationId (typedef + auth callbacks JWT
  & session). Permet à canManageCarbet de check membership sans
  refetch DB.

src/lib/carbet-access.ts :
- MANAGER_ROLES inclut désormais CE_MANAGER → /espace-hote ET /espace-ce
  sont gardés par requireOwnerSession (CE_MANAGER passe, OWNER passe,
  ADMIN passe).
- canManageCarbet(session, carbetOwnerId, linkedOrgIds=[]) :
  - ADMIN → toujours vrai
  - OWNER + session.user.id === carbetOwnerId → vrai
  - CE_MANAGER + session.user.organizationId ∈ linkedOrgIds → vrai
  - sinon faux.
- Callers historiques (qui ne passent pas linkedOrgIds) restent sûrs :
  CE_MANAGER ne peut rien gérer par défaut.

createCarbet étendu : si role=CE_MANAGER + organizationId présent,
crée OrganizationCarbetMembership dans la même transaction. Redirige
ensuite vers /espace-ce/carbets/[id] au lieu de /espace-hote/.

Sweep des callers canManageCarbet (8 sites) : chargent désormais
`Carbet.organizations` + passent linkedOrgIds. Includes :
- updateCarbet, setCarbetStatus, deleteCarbet, reorderMedia, deleteMedia
  dans espace-hote/carbets/actions.ts
- espace-hote/carbets/[carbetId]/page.tsx
- API POST /api/carbets/[carbetId]/media

Pages /espace-ce/carbets/* :
- page.tsx : liste les carbets co-gérés via OrganizationCarbetMembership,
  forms Publier/Dépublier/Supprimer pointent vers les actions
  existantes de /espace-hote (réutilisation totale)
- nouveau/page.tsx : requireApprovedOrg (redirect dashboard si pending),
  CarbetForm + createCarbet (même action que /espace-hote — détecte
  CE_MANAGER et crée membership)
- [carbetId]/page.tsx : vérif que le carbet est lié à l'org du user
  + MediaUploader + CarbetForm (updateCarbet partagé)

Dashboard /espace-ce/page.tsx : ActionCard « Mes carbets » devient
active (le lien marche même en pending — l'org peut préparer des
brouillons, c'est juste la publication qui est bloquée).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 23:34:17 +00:00
parent 3d77632ba0
commit 74ea280f28
10 changed files with 462 additions and 28 deletions

View file

@ -26,7 +26,11 @@ export async function POST(
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
if (
session.user.role !== UserRole.OWNER &&
session.user.role !== UserRole.ADMIN &&
session.user.role !== UserRole.CE_MANAGER
) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
@ -34,12 +38,15 @@ export async function POST(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
if (!canManageCarbet(session, carbet.ownerId)) {
if (!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}

View file

@ -0,0 +1,126 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { MediaUploader } from "@/components/MediaUploader";
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { prisma } from "@/lib/prisma";
import { updateCarbet } from "../../../espace-hote/carbets/actions";
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
export const dynamic = "force-dynamic";
export default async function EditCeCarbetPage({
params,
searchParams,
}: {
params: Promise<{ carbetId: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const session = await requireOwnerSession();
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const { carbetId } = await params;
const { publishError } = await searchParams;
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: {
id: true,
ownerId: true,
title: true,
description: true,
river: true,
latitude: true,
longitude: true,
embarkPoint: true,
pirogueDurationMin: true,
capacity: true,
roadAccess: true,
electricity: true,
gsmAtCarbet: true,
gsmExitDistanceKm: true,
status: true,
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
organizations: { select: { organizationId: true } },
},
});
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}
// Sécurité supplémentaire : assure que le carbet est bien lié à l'org du user.
// (Un ADMIN peut éditer n'importe quel carbet via /admin, pas via /espace-ce.)
const isLinked = carbet.organizations.some((o) => o.organizationId === org.id);
if (!isLinked && session.user.role !== "ADMIN") {
notFound();
}
const defaults = {
title: carbet.title,
description: carbet.description,
river: carbet.river,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
embarkPoint: carbet.embarkPoint,
pirogueDurationMin: String(carbet.pirogueDurationMin),
capacity: String(carbet.capacity),
roadAccess: carbet.roadAccess ?? "",
electricity: carbet.electricity ?? "",
gsmAtCarbet: carbet.gsmAtCarbet,
gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "",
status: carbet.status,
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
};
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href="/espace-ce/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Carbets de {org.name}
</Link>
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
{carbet.title}
</h1>
<p className="mt-1 text-xs text-zinc-500">
Co-géré par <strong>{org.name}</strong>
</p>
{publishError ? (
<p className="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-800">
Ajoutez au moins un média avant de publier ce carbet.
</p>
) : null}
<section className="mt-8">
<h2 className="text-lg font-semibold text-zinc-900">Médias</h2>
<p className="mb-4 mt-1 text-sm text-zinc-600">
Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
Le premier média sert de cover sur le catalogue.
</p>
<MediaUploader carbetId={carbet.id} initialMedia={carbet.media} />
</section>
<section className="mt-10 border-t border-zinc-200 pt-8">
<CarbetForm
action={updateCarbet}
mode="edit"
carbetId={carbet.id}
defaults={defaults}
submitLabel="Enregistrer les modifications"
/>
</section>
</main>
);
}

View file

@ -0,0 +1,42 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireApprovedOrg } from "@/lib/ce-access";
import { createCarbet } from "../../../espace-hote/carbets/actions";
import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form";
export const dynamic = "force-dynamic";
export default async function NewCeCarbetPage() {
// Bloque la création si l'org n'est pas validée — redirect vers dashboard
// avec bannière « En attente de validation ».
const org = await requireApprovedOrg();
if (!org) redirect("/espace-ce");
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href="/espace-ce/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Carbets de {org.name}
</Link>
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
Nouveau carbet CE
</h1>
<p className="mt-1 text-sm text-zinc-600">
Ce carbet sera automatiquement lié à <strong>{org.name}</strong> et co-géré
par tous ses CE_MANAGERs. Vous ajouterez les médias après la création.
</p>
<div className="mt-8">
<CarbetForm
action={createCarbet}
mode="create"
submitLabel="Créer le carbet"
/>
</div>
</main>
);
}

View file

@ -0,0 +1,163 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { CarbetStatus } from "@/generated/prisma/enums";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { prisma } from "@/lib/prisma";
import {
deleteCarbet,
setCarbetStatus,
} from "../../espace-hote/carbets/actions";
export const dynamic = "force-dynamic";
export const metadata = { title: "Mes carbets CE — Karbé" };
const STATUS_LABELS: Record<CarbetStatus, string> = {
[CarbetStatus.DRAFT]: "Brouillon",
[CarbetStatus.PUBLISHED]: "Publié",
[CarbetStatus.ARCHIVED]: "Archivé",
};
const STATUS_STYLES: Record<CarbetStatus, string> = {
[CarbetStatus.DRAFT]: "bg-zinc-100 text-zinc-700",
[CarbetStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800",
[CarbetStatus.ARCHIVED]: "bg-amber-100 text-amber-800",
};
export default async function CeCarbetsListPage() {
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const memberships = await prisma.organizationCarbetMembership.findMany({
where: { organizationId: org.id },
orderBy: { addedAt: "desc" },
select: {
carbet: {
select: {
id: true,
title: true,
river: true,
status: true,
updatedAt: true,
ownerId: true,
owner: { select: { firstName: true, lastName: true } },
_count: { select: { media: true } },
},
},
},
});
const carbets = memberships.map((m) => m.carbet);
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<div className="flex items-center justify-between gap-3">
<div>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Carbets co-gérés par {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-600">
Les carbets visibles ici peuvent être édités par tous les CE_MANAGERs de votre
organisation. La propriété nominale reste sur leur créateur initial.
</p>
</div>
{org.approved ? (
<Link
href="/espace-ce/carbets/nouveau"
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Nouveau carbet
</Link>
) : (
<span className="rounded-md bg-zinc-100 px-3 py-2 text-xs text-zinc-500">
Publication bloquée : organisation en attente de validation
</span>
)}
</div>
{carbets.length === 0 ? (
<p className="mt-10 rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Votre CE n&apos;a pas encore de carbet.{" "}
{org.approved ? "Créez votre premier carbet pour démarrer." : "Vous pourrez en publier dès que votre organisation sera validée."}
</p>
) : (
<ul className="mt-8 space-y-4">
{carbets.map((carbet) => (
<li
key={carbet.id}
className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex items-center gap-3">
<Link
href={`/espace-ce/carbets/${carbet.id}`}
className="truncate text-lg font-medium text-zinc-900 hover:text-emerald-700"
>
{carbet.title}
</Link>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[carbet.status]}`}
>
{STATUS_LABELS[carbet.status]}
</span>
</div>
<p className="mt-1 text-sm text-zinc-500">
{carbet.river} · {carbet._count.media} média{carbet._count.media > 1 ? "s" : ""}
{" · "}créé par {carbet.owner.firstName} {carbet.owner.lastName}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Link
href={`/espace-ce/carbets/${carbet.id}`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Éditer
</Link>
{org.approved && carbet.status !== CarbetStatus.PUBLISHED ? (
<form action={setCarbetStatus}>
<input type="hidden" name="carbetId" value={carbet.id} />
<input type="hidden" name="status" value={CarbetStatus.PUBLISHED} />
<button
type="submit"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
>
Publier
</button>
</form>
) : null}
{carbet.status === CarbetStatus.PUBLISHED ? (
<form action={setCarbetStatus}>
<input type="hidden" name="carbetId" value={carbet.id} />
<input type="hidden" name="status" value={CarbetStatus.DRAFT} />
<button
type="submit"
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Dépublier
</button>
</form>
) : null}
<form action={deleteCarbet}>
<input type="hidden" name="carbetId" value={carbet.id} />
<button
type="submit"
className="rounded-md border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
>
Supprimer
</button>
</form>
</div>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -59,20 +59,18 @@ export default async function CeDashboardPage() {
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
href={org.approved ? "/espace-ce/carbets" : "/espace-ce"}
href="/espace-ce/carbets"
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."
: "Vous pouvez préparer vos carbets en brouillon, ils seront publiables après validation."
}
disabled={!org.approved}
comingSoon
/>
<ActionCard
href={org.approved ? "/espace-ce/materiel" : "/espace-ce"}
href="/espace-ce/materiel"
title="Matériel rental"
description={
kpis.rentalItemsCount > 0
@ -87,8 +85,7 @@ export default async function CeDashboardPage() {
</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.
Le bouton « Matériel rental » sera actif au Sprint J du plan CE management.
</p>
</main>
);

View file

@ -42,10 +42,14 @@ export default async function EditCarbetPage({
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
organizations: { select: { organizationId: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}

View file

@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
import { ensureUniqueCarbetSlug } from "@/lib/slug";
import { deleteObject } from "@/lib/storage";
import { Prisma } from "@/generated/prisma/client";
import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums";
import { CarbetStatus, Electricity, RoadAccess, UserRole } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
@ -213,6 +213,10 @@ export async function createCarbet(
const slug = await ensureUniqueCarbetSlug(data.title);
// Si CE_MANAGER : on lie automatiquement le carbet à son org via OrganizationCarbetMembership.
const isCeCreator =
session.user.role === UserRole.CE_MANAGER && Boolean(session.user.organizationId);
const carbet = await prisma.$transaction(async (tx) => {
const created = await tx.carbet.create({
data: {
@ -235,9 +239,22 @@ export async function createCarbet(
select: { id: true },
});
await syncAmenities(tx, created.id, data.amenities);
if (isCeCreator) {
await tx.organizationCarbetMembership.create({
data: {
organizationId: session.user.organizationId!,
carbetId: created.id,
addedByUserId: session.user.id,
},
});
}
return created;
});
if (isCeCreator) {
revalidatePath("/espace-ce/carbets");
redirect(`/espace-ce/carbets/${carbet.id}`);
}
revalidatePath("/espace-hote/carbets");
redirect(`/espace-hote/carbets/${carbet.id}`);
}
@ -251,10 +268,17 @@ export async function updateCarbet(
const existing = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, _count: { select: { media: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
_count: { select: { media: true } },
},
});
if (!existing || !canManageCarbet(session, existing.ownerId)) {
if (
!existing ||
!canManageCarbet(session, existing.ownerId, existing.organizations.map((o) => o.organizationId))
) {
return {
ok: false,
errors: { _global: "Carbet introuvable ou accès refusé." },
@ -313,10 +337,17 @@ export async function setCarbetStatus(formData: FormData): Promise<void> {
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, _count: { select: { media: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
_count: { select: { media: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}
@ -340,10 +371,17 @@ export async function deleteCarbet(formData: FormData): Promise<void> {
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, media: { select: { s3Key: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
media: { select: { s3Key: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
notFound();
}
@ -366,10 +404,17 @@ export async function reorderMedia(
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, media: { select: { id: true } } },
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
media: { select: { id: true } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
if (
!carbet ||
!canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId))
) {
return { ok: false };
}
@ -396,10 +441,26 @@ export async function deleteMedia(
const media = await prisma.media.findUnique({
where: { id: mediaId },
select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } },
select: {
s3Key: true,
carbetId: true,
carbet: {
select: {
ownerId: true,
organizations: { select: { organizationId: true } },
},
},
},
});
if (!media || !canManageCarbet(session, media.carbet.ownerId)) {
if (
!media ||
!canManageCarbet(
session,
media.carbet.ownerId,
media.carbet.organizations.map((o) => o.organizationId),
)
) {
return { ok: false };
}

View file

@ -31,6 +31,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
firstName: true,
lastName: true,
role: true,
organizationId: true,
isActive: true,
passwordHash: true,
},
@ -50,6 +51,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
organizationId: user.organizationId,
};
},
}),
@ -59,12 +61,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (user?.role) {
token.role = user.role;
}
if (user && "organizationId" in user) {
token.organizationId = (user as { organizationId?: string | null }).organizationId ?? null;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub ?? "";
session.user.role = token.role;
session.user.organizationId = token.organizationId ?? null;
}
return session;
},

View file

@ -3,19 +3,44 @@ import type { Session } from "next-auth";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
const MANAGER_ROLES: UserRole[] = [UserRole.OWNER, UserRole.ADMIN];
/**
* Espace hôte ET espace CE (les deux dashboards) : accessible à OWNER, CE_MANAGER, ADMIN.
* Chacun ne voit que ses propres carbets (own ou via membership). La page liste filtre
* par session.user.id / session.user.organizationId.
*/
const MANAGER_ROLES: UserRole[] = [
UserRole.OWNER,
UserRole.CE_MANAGER,
UserRole.ADMIN,
];
// Owner area (espace hôte) — accessible to carbet owners and admins.
export async function requireOwnerSession(): Promise<Session> {
return requireRole(MANAGER_ROLES);
}
// A user can manage a given carbet if they own it, or if they are an admin.
/**
* Vrai si :
* - ADMIN
* - OWNER + session.user.id === carbetOwnerId
* - CE_MANAGER + son organizationId est dans `linkedOrgIds`
*
* Les callers DOIVENT charger `Carbet.organizations.map(m => m.organizationId)` quand le rôle
* peut être CE_MANAGER. Pour un caller historique qui n'a que l'ownerId, le CE_MANAGER ne
* pourra pas gérer le carbet comportement sûr par défaut.
*/
export function canManageCarbet(
session: Session,
carbetOwnerId: string,
linkedOrgIds: string[] = [],
): boolean {
return (
session.user.role === UserRole.ADMIN || session.user.id === carbetOwnerId
);
if (session.user.role === UserRole.ADMIN) return true;
if (session.user.id === carbetOwnerId) return true;
if (
session.user.role === UserRole.CE_MANAGER &&
session.user.organizationId &&
linkedOrgIds.includes(session.user.organizationId)
) {
return true;
}
return false;
}

View file

@ -8,16 +8,19 @@ declare module "next-auth" {
user: {
id: string;
role?: UserRole;
organizationId?: string | null;
} & DefaultSession["user"];
}
interface User {
role?: UserRole;
organizationId?: string | null;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role?: UserRole;
organizationId?: string | null;
}
}