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) { /> +
+

Photos & vidéos

+ +
+
}) { + 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" ? ( +
+ {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 - {item.name} - ) : ( -
- {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 };