diff --git a/prisma/migrations/20260603100000_rental_item_media/migration.sql b/prisma/migrations/20260603100000_rental_item_media/migration.sql
new file mode 100644
index 0000000..67a2d76
--- /dev/null
+++ b/prisma/migrations/20260603100000_rental_item_media/migration.sql
@@ -0,0 +1,22 @@
+-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
+-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
+-- sortOrder pour cover (0). Cascade sur RentalItem.
+
+CREATE TABLE "RentalItemMedia" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "type" "MediaType" NOT NULL,
+ "s3Key" TEXT NOT NULL,
+ "s3Url" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
+ ON "RentalItemMedia"("itemId", "sortOrder");
+
+ALTER TABLE "RentalItemMedia"
+ ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
+ FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7580413..b2772d7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -466,11 +466,26 @@ model RentalItem {
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
availabilities RentalItemAvailability[]
lines RentalLine[]
+ media RentalItemMedia[]
@@index([providerId])
@@index([category, active])
}
+model RentalItemMedia {
+ id String @id @default(cuid())
+ itemId String
+ type MediaType
+ s3Key String
+ s3Url String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+
+ item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
+
+ @@index([itemId, sortOrder])
+}
+
model RentalItemAvailability {
id String @id @default(cuid())
itemId String
diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx
index 8f4dd4a..59295d2 100644
--- a/src/app/admin/rental-items/[id]/page.tsx
+++ b/src/app/admin/rental-items/[id]/page.tsx
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
+import { MediaUploader } from "@/components/MediaUploader";
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
import { ItemForm } from "../_components/ItemForm";
@@ -56,6 +57,14 @@ export default async function EditRentalItemPage({ params }: PageProps) {
/>
+
+
}) {
+ const { id } = await ctx.params;
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const media = await prisma.rentalItemMedia.findUnique({
+ where: { id },
+ select: { id: true, itemId: true, item: { select: { providerId: true } } },
+ });
+ if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ media.item.providerId,
+ );
+ if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+
+ await prisma.rentalItemMedia.delete({ where: { id } });
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.delete",
+ target: id,
+ actorEmail: session.user.email ?? null,
+ details: { itemId: media.itemId },
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/rental-media/reorder/route.ts b/src/app/api/rental-media/reorder/route.ts
new file mode 100644
index 0000000..a8cc26a
--- /dev/null
+++ b/src/app/api/rental-media/reorder/route.ts
@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ itemId: z.string().min(1),
+ orderedIds: z.array(z.string()).min(1).max(50),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
+ }
+ const { itemId, orderedIds } = parsed.data;
+
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: itemId },
+ select: { providerId: true },
+ });
+ if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ item.providerId,
+ );
+ if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+
+ const existing = await prisma.rentalItemMedia.findMany({
+ where: { itemId, id: { in: orderedIds } },
+ select: { id: true },
+ });
+ if (existing.length !== orderedIds.length) {
+ return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
+ }
+ await prisma.$transaction(
+ orderedIds.map((id, idx) =>
+ prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
+ ),
+ );
+
+ // Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
+ const firstId = orderedIds[0];
+ const firstMedia = await prisma.rentalItemMedia.findUnique({
+ where: { id: firstId },
+ select: { s3Url: true, type: true },
+ });
+ if (firstMedia && firstMedia.type === "PHOTO") {
+ await prisma.rentalItem.update({
+ where: { id: itemId },
+ data: { imageUrl: firstMedia.s3Url },
+ });
+ }
+
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.reorder",
+ target: itemId,
+ actorEmail: session.user.email ?? null,
+ details: { count: orderedIds.length },
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/uploads/rental-finalize/route.ts b/src/app/api/uploads/rental-finalize/route.ts
new file mode 100644
index 0000000..4f1f9b9
--- /dev/null
+++ b/src/app/api/uploads/rental-finalize/route.ts
@@ -0,0 +1,104 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { MediaType } from "@/generated/prisma/enums";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+import { classifyMime } from "@/lib/uploads";
+import { generateImageVariants } from "@/lib/variants-server";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ itemId: z.string().min(1),
+ s3Key: z.string().min(5).max(500),
+ s3Url: z.string().url(),
+ mime: z.string().min(3).max(100),
+});
+
+export async function POST(req: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Payload invalide" },
+ { status: 400 },
+ );
+ }
+ const kind = classifyMime(parsed.data.mime);
+ if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
+
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: parsed.data.itemId },
+ select: { id: true, providerId: true },
+ });
+ if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ item.providerId,
+ );
+ if (!allowed) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
+ return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
+ }
+
+ const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
+ const media = await prisma.rentalItemMedia.create({
+ data: {
+ itemId: item.id,
+ type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
+ s3Key: parsed.data.s3Key,
+ s3Url: parsed.data.s3Url,
+ sortOrder: existingCount,
+ },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ });
+
+ // Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
+ // avec les listings (RentalItemCard, /carbets/[slug] panel).
+ if (existingCount === 0 && kind === "photo") {
+ await prisma.rentalItem.update({
+ where: { id: item.id },
+ data: { imageUrl: parsed.data.s3Url },
+ });
+ }
+
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.finalize",
+ target: media.id,
+ actorEmail: session.user.email ?? null,
+ details: { itemId: item.id, kind },
+ });
+
+ try {
+ const variants = await generateImageVariants({
+ originalS3Key: parsed.data.s3Key,
+ mime: parsed.data.mime,
+ });
+ if (!variants.skipped) {
+ const okCount = variants.results.filter((r) => r.ok).length;
+ await recordAudit({
+ scope: "uploads",
+ event: "rental.media.variants",
+ target: media.id,
+ actorEmail: session.user.email ?? null,
+ details: { generated: okCount, total: variants.results.length },
+ });
+ }
+ } catch (e) {
+ console.error("[rental-uploads] variants generation error:", e);
+ }
+
+ return NextResponse.json({ media });
+}
diff --git a/src/app/api/uploads/rental-presign/route.ts b/src/app/api/uploads/rental-presign/route.ts
new file mode 100644
index 0000000..f3b26e2
--- /dev/null
+++ b/src/app/api/uploads/rental-presign/route.ts
@@ -0,0 +1,62 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { canManageRentalProvider } from "@/lib/rental-access";
+import { rateLimitRequest } from "@/lib/rate-limit";
+import { presignRentalItemUpload } from "@/lib/uploads";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ itemId: z.string().min(1),
+ mime: z.string().min(3).max(100),
+ sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
+});
+
+export async function POST(req: Request) {
+ const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
+ if (!rl.ok) {
+ return NextResponse.json(
+ { error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
+ { status: 429 },
+ );
+ }
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const parsed = schema.safeParse(await req.json().catch(() => ({})));
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message ?? "Payload invalide" },
+ { status: 400 },
+ );
+ }
+
+ const item = await prisma.rentalItem.findUnique({
+ where: { id: parsed.data.itemId },
+ select: { id: true, providerId: true },
+ });
+ if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
+
+ const allowed = await canManageRentalProvider(
+ session.user.id,
+ session.user.role,
+ item.providerId,
+ );
+ if (!allowed) {
+ return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
+ }
+
+ const result = await presignRentalItemUpload({
+ itemId: item.id,
+ mime: parsed.data.mime,
+ sizeBytes: parsed.data.sizeBytes,
+ });
+ if ("error" in result) {
+ return NextResponse.json({ error: result.error }, { status: 400 });
+ }
+ return NextResponse.json(result);
+}
diff --git a/src/app/espace-prestataire/items/[itemId]/page.tsx b/src/app/espace-prestataire/items/[itemId]/page.tsx
index 699a8b0..ee46102 100644
--- a/src/app/espace-prestataire/items/[itemId]/page.tsx
+++ b/src/app/espace-prestataire/items/[itemId]/page.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
+import { MediaUploader } from "@/components/MediaUploader";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostItem } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
@@ -59,6 +60,16 @@ export default async function EditHostItemPage({ params }: PageProps) {
+
+
+ Photos & vidéos
+
+
+
+
+ {fallbackEmoji}
+
+ );
+ }
+
+ const current = media[idx];
+
+ return (
+
+
+ {current.type === "VIDEO" ? (
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+ {media.length > 1 ? (
+
+ {media.map((m, i) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx
index e073f9b..d9a56b5 100644
--- a/src/app/materiel/[itemId]/page.tsx
+++ b/src/app/materiel/[itemId]/page.tsx
@@ -8,6 +8,7 @@ import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { AddToCart } from "./_components/AddToCart";
import { AvailabilityPreview } from "./_components/AvailabilityPreview";
+import { ItemGallery } from "./_components/ItemGallery";
export const dynamic = "force-dynamic";
@@ -58,19 +59,18 @@ export default async function RentalItemDetailPage({ params }: PageProps) {
-
- {item.imageUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-

- ) : (
-
- {categoryEmoji}
-
- )}
+
+ 0
+ ? item.media
+ : item.imageUrl
+ ? [{ id: "legacy", type: "PHOTO", s3Url: item.imageUrl }]
+ : []
+ }
+ alt={item.name}
+ fallbackEmoji={categoryEmoji}
+ />
{item.description ? (
diff --git a/src/components/MediaUploader.tsx b/src/components/MediaUploader.tsx
index 815d991..1eb1cdd 100644
--- a/src/components/MediaUploader.tsx
+++ b/src/components/MediaUploader.tsx
@@ -28,11 +28,53 @@ export type MediaItem = {
sortOrder: number;
};
+/**
+ * Le composant gère deux périmètres : carbet (par défaut) et item de location.
+ * Les endpoints sont alors `/api/uploads/{rental-}{presign,finalize}`,
+ * `/api/{rental-}media/{id}` et `/api/{rental-}media/reorder` ; la clé de
+ * scope dans les payloads passe de `carbetId` à `itemId`.
+ */
+export type UploaderScope =
+ | { kind: "carbet"; carbetId: string }
+ | { kind: "rental-item"; itemId: string };
+
type Props = {
- carbetId: string;
+ scope?: UploaderScope;
+ /** @deprecated — passer `scope={{kind:"carbet", carbetId}}` à la place. */
+ carbetId?: string;
initialMedia: MediaItem[];
};
+type Endpoints = {
+ presign: string;
+ finalize: string;
+ reorder: string;
+ remove: (mediaId: string) => string;
+ idKey: "carbetId" | "itemId";
+ idValue: string;
+};
+
+function endpointsFor(scope: UploaderScope): Endpoints {
+ if (scope.kind === "carbet") {
+ return {
+ presign: "/api/uploads/presign",
+ finalize: "/api/uploads/finalize",
+ reorder: "/api/media/reorder",
+ remove: (id) => `/api/media/${id}`,
+ idKey: "carbetId",
+ idValue: scope.carbetId,
+ };
+ }
+ return {
+ presign: "/api/uploads/rental-presign",
+ finalize: "/api/uploads/rental-finalize",
+ reorder: "/api/rental-media/reorder",
+ remove: (id) => `/api/rental-media/${id}`,
+ idKey: "itemId",
+ idValue: scope.itemId,
+ };
+}
+
type UploadEntry = {
tempId: string;
name: string;
@@ -45,7 +87,11 @@ type UploadEntry = {
const MAX_PARALLEL = 3;
-export function MediaUploader({ carbetId, initialMedia }: Props) {
+export function MediaUploader({ scope, carbetId, initialMedia }: Props) {
+ const endpoints = useMemo(
+ () => endpointsFor(scope ?? { kind: "carbet", carbetId: carbetId ?? "" }),
+ [scope, carbetId],
+ );
const [items, setItems] = useState
(
[...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
);
@@ -66,13 +112,13 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
const reorderOnServer = useCallback(
async (orderedIds: string[]) => {
- await fetch("/api/media/reorder", {
+ await fetch(endpoints.reorder, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ carbetId, orderedIds }),
+ body: JSON.stringify({ [endpoints.idKey]: endpoints.idValue, orderedIds }),
}).catch(() => {});
},
- [carbetId],
+ [endpoints],
);
function onDragEnd(e: DragEndEvent) {
@@ -103,9 +149,9 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
const removeItem = useCallback(async (id: string) => {
if (!confirm("Supprimer ce média ?")) return;
- const res = await fetch(`/api/media/${id}`, { method: "DELETE" });
+ const res = await fetch(endpoints.remove(id), { method: "DELETE" });
if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
- }, []);
+ }, [endpoints]);
const processFile = useCallback(async function processFile(file: File): Promise {
const tempId = crypto.randomUUID();
@@ -114,10 +160,14 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
{ tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false },
]);
try {
- const presignRes = await fetch("/api/uploads/presign", {
+ const presignRes = await fetch(endpoints.presign, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }),
+ body: JSON.stringify({
+ [endpoints.idKey]: endpoints.idValue,
+ mime: file.type,
+ sizeBytes: file.size,
+ }),
});
const presign = await presignRes.json();
if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
@@ -138,11 +188,11 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
xhr.send(file);
});
- const finalizeRes = await fetch("/api/uploads/finalize", {
+ const finalizeRes = await fetch(endpoints.finalize, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- carbetId,
+ [endpoints.idKey]: endpoints.idValue,
s3Key: presign.s3Key,
s3Url: presign.publicUrl,
mime: file.type,
@@ -160,7 +210,7 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
const msg = e instanceof Error ? e.message : String(e);
setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x)));
}
- }, [carbetId]);
+ }, [endpoints]);
const popQueueRef = useRef<() => void>(() => {});
const popQueue = useCallback(() => {
diff --git a/src/lib/admin/rental-items.ts b/src/lib/admin/rental-items.ts
index 01dd655..114c6d4 100644
--- a/src/lib/admin/rental-items.ts
+++ b/src/lib/admin/rental-items.ts
@@ -80,6 +80,10 @@ export async function getRentalItemForAdmin(id: string) {
where: { id },
include: {
provider: { select: { id: true, name: true, isSystemD: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ },
},
});
}
diff --git a/src/lib/rental-host.ts b/src/lib/rental-host.ts
index ba6f543..8959b76 100644
--- a/src/lib/rental-host.ts
+++ b/src/lib/rental-host.ts
@@ -115,6 +115,10 @@ export async function getHostItem(providerId: string, itemId: string) {
include: {
availabilities: { orderBy: { startDate: "asc" } },
_count: { select: { lines: true } },
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
+ },
},
});
}
diff --git a/src/lib/rentals-public.ts b/src/lib/rentals-public.ts
index af08f4a..1ffea63 100644
--- a/src/lib/rentals-public.ts
+++ b/src/lib/rentals-public.ts
@@ -91,6 +91,10 @@ export async function getPublicRentalItem(id: string) {
contactPhone: true,
},
},
+ media: {
+ orderBy: { sortOrder: "asc" },
+ select: { id: true, type: true, s3Url: true, sortOrder: true },
+ },
},
});
}
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts
index 708504a..484775f 100644
--- a/src/lib/uploads.ts
+++ b/src/lib/uploads.ts
@@ -100,5 +100,30 @@ export async function presignCarbetUpload(opts: {
return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
}
+export async function presignRentalItemUpload(opts: {
+ itemId: string;
+ mime: string;
+ sizeBytes: number;
+}): Promise {
+ const kind = classifyMime(opts.mime);
+ if (!kind) return { error: `Type non supporté : ${opts.mime}` };
+ const max = maxBytesFor(kind);
+ if (opts.sizeBytes > max) {
+ return { error: `Fichier trop volumineux (${Math.round(opts.sizeBytes / 1_000_000)} Mo, max ${Math.round(max / 1_000_000)} Mo).` };
+ }
+ const id = crypto.randomBytes(12).toString("hex");
+ const ext = extensionFor(opts.mime);
+ const s3Key = `rental-items/${opts.itemId}/${Date.now()}-${id}.${ext}`;
+
+ const cmd = new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: s3Key,
+ ContentType: opts.mime,
+ });
+ const uploadUrl = await getSignedUrl(s3Presign, cmd, { expiresIn: 600 });
+ const publicUrl = `${PUBLIC_BASE_EXTERNAL.replace(/\/$/, "")}/${s3Key}`;
+ return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
+}
+
export { s3Internal };
export { BUCKET as UPLOAD_BUCKET };