karbe/src/app/api/uploads/rental-finalize/route.ts
Ubuntu caa3d5214f
All checks were successful
CI / test (pull_request) Successful in 2m41s
feat(ce): Sprint J — matériel rental côté CE
src/lib/rental-access.ts CE-aware :
- requireRentalProviderSession accepte CE_MANAGER (en plus de
  RENTAL_PROVIDER et ADMIN).
- getCurrentRentalProvider : CE_MANAGER → findFirst par
  organizationId ; RENTAL_PROVIDER → par managedByUserId.
- getCurrentRentalProviderForCe(organizationId) helper explicite.
- canManageRentalProvider gagne un userOrgId? optionnel : vrai si
  manager nominal OU CE_MANAGER + provider.organizationId === userOrgId.
- Callers existants (5 sites : actions.ts + 4 routes API rental)
  passent désormais session.user.organizationId.

Actions /espace-prestataire/actions.ts role-aware :
- requireOwnedProvider() dérive basePath selon le rôle :
  CE_MANAGER → /espace-ce/materiel ; sinon → /espace-prestataire.
- Tous les redirect/revalidatePath utilisent basePath, donc
  createHostItemAction, updateHostItemAction, deleteHostItemAction,
  addItemBlockAction, removeItemBlockAction, updateBookingStatusAction
  emmènent le user vers son espace contextuel après chaque opération.

/espace-ce/materiel/page.tsx — onboarding :
- Plugin gear-rental disabled → message d'info.
- Pas de provider activé → CTA « Activer la location matériel pour
  <org> » (bouton bloqué si org pending, message bannière).
- Provider existant → dashboard avec KPIs (items actifs, résa pending,
  confirmées à venir, revenu 30j) + 2 ActionCards Items + Réservations.

actions.ts (CE) :
- activateRentalProviderForCeAction → crée RentalProvider(organizationId,
  name="Matériel <org>", managedByUserId=session.user.id, approved=true)
  + audit + redirect /espace-ce/materiel.

Pages CE clonées (réutilisent les composants, actions, helpers
existants — zéro duplication de logique métier) :
- /espace-ce/materiel/items/page.tsx (liste)
- /espace-ce/materiel/items/new/page.tsx (HostItemForm)
- /espace-ce/materiel/items/[itemId]/page.tsx (MediaUploader +
  HostItemForm + ItemBlocksManager + ItemInlineDelete)
- /espace-ce/materiel/reservations/page.tsx (BookingDecision)

Tous importent depuis /espace-prestataire/{actions, items, reservations}
pour rester DRY. Les breadcrumbs et links sont adaptés au contexte CE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 23:47:57 +00:00

105 lines
3.3 KiB
TypeScript

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,
session.user.organizationId,
);
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 });
}