diff --git a/src/app/api/carbets/[carbetId]/media/route.ts b/src/app/api/carbets/[carbetId]/media/route.ts index 9661048..1519158 100644 --- a/src/app/api/carbets/[carbetId]/media/route.ts +++ b/src/app/api/carbets/[carbetId]/media/route.ts @@ -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 }); } diff --git a/src/app/espace-ce/carbets/[carbetId]/page.tsx b/src/app/espace-ce/carbets/[carbetId]/page.tsx new file mode 100644 index 0000000..08c8c21 --- /dev/null +++ b/src/app/espace-ce/carbets/[carbetId]/page.tsx @@ -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 ( +
+ + ← Carbets de {org.name} + +

+ {carbet.title} +

+

+ Co-géré par {org.name} +

+ + {publishError ? ( +

+ Ajoutez au moins un média avant de publier ce carbet. +

+ ) : null} + +
+

Médias

+

+ Déposez photos et vidéos courtes, réorganisez par glisser-déposer. + Le premier média sert de cover sur le catalogue. +

+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/espace-ce/carbets/nouveau/page.tsx b/src/app/espace-ce/carbets/nouveau/page.tsx new file mode 100644 index 0000000..c168cd3 --- /dev/null +++ b/src/app/espace-ce/carbets/nouveau/page.tsx @@ -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 ( +
+ + ← Carbets de {org.name} + +

+ Nouveau carbet CE +

+

+ Ce carbet sera automatiquement lié à {org.name} et co-géré + par tous ses CE_MANAGERs. Vous ajouterez les médias après la création. +

+ +
+ +
+
+ ); +} diff --git a/src/app/espace-ce/carbets/page.tsx b/src/app/espace-ce/carbets/page.tsx new file mode 100644 index 0000000..d4d9f6d --- /dev/null +++ b/src/app/espace-ce/carbets/page.tsx @@ -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.DRAFT]: "Brouillon", + [CarbetStatus.PUBLISHED]: "Publié", + [CarbetStatus.ARCHIVED]: "Archivé", +}; + +const STATUS_STYLES: Record = { + [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 ( +
+
+
+ + ← Tableau de bord CE + +

+ Carbets co-gérés par {org.name} +

+

+ 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. +

+
+ {org.approved ? ( + + Nouveau carbet + + ) : ( + + Publication bloquée : organisation en attente de validation + + )} +
+ + {carbets.length === 0 ? ( +

+ Votre CE n'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."} +

+ ) : ( +
    + {carbets.map((carbet) => ( +
  • +
    +
    + + {carbet.title} + + + {STATUS_LABELS[carbet.status]} + +
    +

    + {carbet.river} · {carbet._count.media} média{carbet._count.media > 1 ? "s" : ""} + {" · "}créé par {carbet.owner.firstName} {carbet.owner.lastName} +

    +
    + +
    + + Éditer + + + {org.approved && carbet.status !== CarbetStatus.PUBLISHED ? ( +
    + + + +
    + ) : null} + + {carbet.status === CarbetStatus.PUBLISHED ? ( +
    + + + +
    + ) : null} + +
    + + +
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/espace-ce/page.tsx b/src/app/espace-ce/page.tsx index fd64787..e6eaee6 100644 --- a/src/app/espace-ce/page.tsx +++ b/src/app/espace-ce/page.tsx @@ -59,20 +59,18 @@ export default async function CeDashboardPage() {
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 /> 0 @@ -87,8 +85,7 @@ export default async function CeDashboardPage() {

- 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.

); diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx index 39ae0f9..8ecdaaa 100644 --- a/src/app/espace-hote/carbets/[carbetId]/page.tsx +++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx @@ -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(); } diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts index ba29ac4..470d1ad 100644 --- a/src/app/espace-hote/carbets/actions.ts +++ b/src/app/espace-hote/carbets/actions.ts @@ -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 { 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 { 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 }; } diff --git a/src/auth.ts b/src/auth.ts index 4071c3d..edbd27d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -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; }, diff --git a/src/lib/carbet-access.ts b/src/lib/carbet-access.ts index 420cd49..9822b24 100644 --- a/src/lib/carbet-access.ts +++ b/src/lib/carbet-access.ts @@ -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 { 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; } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index d470fff..e9771ae 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -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; } }