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