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

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