All checks were successful
CI / test (pull_request) Successful in 2m41s
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>
105 lines
3.3 KiB
TypeScript
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 });
|
|
}
|