feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (pull_request) Successful in 2m18s
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:
parent
d42584cc4c
commit
9da58288dc
15 changed files with 521 additions and 25 deletions
38
src/app/api/rental-media/[id]/route.ts
Normal file
38
src/app/api/rental-media/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
74
src/app/api/rental-media/reorder/route.ts
Normal file
74
src/app/api/rental-media/reorder/route.ts
Normal 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 });
|
||||
}
|
||||
104
src/app/api/uploads/rental-finalize/route.ts
Normal file
104
src/app/api/uploads/rental-finalize/route.ts
Normal 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 });
|
||||
}
|
||||
62
src/app/api/uploads/rental-presign/route.ts
Normal file
62
src/app/api/uploads/rental-presign/route.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue