feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (pull_request) Successful in 2m18s

Nouveau modèle `RentalItemMedia` parallèle de `Media` (carbet) :
- s3Key / s3Url / sortOrder / type (PHOTO|VIDEO), cascade sur RentalItem
- Migration `20260603100000_rental_item_media` appliquée

Endpoints upload dédiés (mêmes conventions que carbet) :
- POST /api/uploads/rental-presign + POST /api/uploads/rental-finalize
  → auth par canManageRentalProvider (admin OR provider manager)
  → s3Key préfixé `rental-items/<itemId>/`
  → finalize hydrate `RentalItem.imageUrl` avec la première PHOTO
  → générateur de variantes (320/800/1600 via sharp) en best-effort
- DELETE /api/rental-media/[id] + POST /api/rental-media/reorder
  → reorder rafraîchit imageUrl (cover = sortOrder 0)

`MediaUploader` rendu générique :
- Nouveau prop `scope: {kind: "carbet" | "rental-item", id}` ; conserve
  rétro-compat `carbetId` (deprecated)
- Endpoints + payload key (`carbetId` ↔ `itemId`) calculés via
  `endpointsFor()`. Aucun changement de comportement côté carbet.

UI branchée :
- /admin/rental-items/[id] : section « Photos & vidéos » au-dessus du
  form, alimentée par `item.media` chargé par `getRentalItemForAdmin`
- /espace-prestataire/items/[itemId] : idem, charge via `getHostItem`
- /materiel/[itemId] : nouveau `<ItemGallery />` (thumbs cliquables +
  support vidéo). Fallback : ancien `item.imageUrl` si pas de média
  dédié (compat seed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 09:34:09 +00:00
parent d42584cc4c
commit 9da58288dc
15 changed files with 521 additions and 25 deletions

View file

@ -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;

View file

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

View file

@ -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) {
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
<MediaUploader
scope={{ kind: "rental-item", itemId: item.id }}
initialMedia={item.media}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}

View file

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
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";
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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);
}

View file

@ -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) {
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Photos & vidéos
</h2>
<MediaUploader
scope={{ kind: "rental-item", itemId: item.id }}
initialMedia={item.media}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm
action={updateThis}

View file

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
type Media = { id: string; type: "PHOTO" | "VIDEO"; s3Url: string };
export function ItemGallery({
media,
fallbackEmoji,
alt,
}: {
media: Media[];
fallbackEmoji: string;
alt: string;
}) {
const [idx, setIdx] = useState(0);
if (media.length === 0) {
return (
<div className="flex aspect-[4/3] w-full items-center justify-center rounded-lg bg-zinc-100 text-7xl text-zinc-300">
{fallbackEmoji}
</div>
);
}
const current = media[idx];
return (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg bg-zinc-100">
{current.type === "VIDEO" ? (
<video
src={current.s3Url}
controls
playsInline
className="aspect-[4/3] w-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current.s3Url}
alt={alt}
className="aspect-[4/3] w-full object-cover"
/>
)}
</div>
{media.length > 1 ? (
<div className="grid grid-cols-5 gap-1">
{media.map((m, i) => (
<button
key={m.id}
type="button"
onClick={() => setIdx(i)}
className={
"aspect-square overflow-hidden rounded-md border transition " +
(i === idx
? "border-emerald-500 ring-2 ring-emerald-200"
: "border-zinc-200 hover:border-zinc-400 opacity-70 hover:opacity-100")
}
aria-label={`Photo ${i + 1}`}
>
{m.type === "VIDEO" ? (
<div className="flex h-full w-full items-center justify-center bg-zinc-900 text-white"></div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={m.s3Url} alt="" className="h-full w-full object-cover" />
)}
</button>
))}
</div>
) : null}
</div>
);
}

View file

@ -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) {
</p>
</header>
<div className="mt-5 overflow-hidden rounded-lg bg-zinc-100">
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.name}
className="aspect-[4/3] w-full object-cover"
/>
) : (
<div className="flex aspect-[4/3] w-full items-center justify-center text-7xl text-zinc-300">
{categoryEmoji}
</div>
)}
<div className="mt-5">
<ItemGallery
media={
item.media.length > 0
? item.media
: item.imageUrl
? [{ id: "legacy", type: "PHOTO", s3Url: item.imageUrl }]
: []
}
alt={item.name}
fallbackEmoji={categoryEmoji}
/>
</div>
{item.description ? (

View file

@ -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<MediaItem[]>(
[...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<void> {
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(() => {

View file

@ -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 },
},
},
});
}

View file

@ -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 },
},
},
});
}

View file

@ -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 },
},
},
});
}

View file

@ -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<PresignResult | { error: string }> {
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 };