feat(espace-hote): CRUD carbet propriétaire + upload médias S3/MinIO
Interface propriétaire sous /espace-hote/carbets : - Liste, création, édition et suppression de carbets (formulaire complet : présentation, localisation, accès pirogue, commodités). - Upload photos/vidéos vers S3/MinIO (route handler multipart), réordonnancement et suppression des médias, photo de couverture. - Statut de publication (brouillon / publié / archivé) avec garde « au moins un média avant publication ». Réutilise le schéma Prisma (SYS-2) et l'authentification NextAuth (SYS-3) : gating via requireRole([OWNER, ADMIN]) et contrôle de propriété sur chaque mutation. Stockage objet configurable par variables S3_* (compatible MinIO). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2764137d2b
commit
b9bfc5ee32
16 changed files with 2577 additions and 15 deletions
128
src/app/api/carbets/[carbetId]/media/route.ts
Normal file
128
src/app/api/carbets/[carbetId]/media/route.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { canManageCarbet } from "@/lib/carbet-access";
|
||||
import {
|
||||
buildMediaKey,
|
||||
humanFileSize,
|
||||
maxBytesForType,
|
||||
mediaTypeForMime,
|
||||
} from "@/lib/media";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isStorageConfigured, putObject } from "@/lib/storage";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type UploadError = { name: string; error: string };
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ carbetId: string }> },
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
}
|
||||
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
|
||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||
}
|
||||
|
||||
const { carbetId } = await params;
|
||||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (!carbet) {
|
||||
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
|
||||
}
|
||||
if (!canManageCarbet(session, carbet.ownerId)) {
|
||||
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!isStorageConfigured()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Le stockage des médias (S3/MinIO) n'est pas configuré sur le serveur.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData
|
||||
.getAll("files")
|
||||
.filter((entry): entry is File => entry instanceof File);
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aucun fichier reçu." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const lastMedia = await prisma.media.findFirst({
|
||||
where: { carbetId },
|
||||
orderBy: { sortOrder: "desc" },
|
||||
select: { sortOrder: true },
|
||||
});
|
||||
let nextOrder = (lastMedia?.sortOrder ?? -1) + 1;
|
||||
|
||||
const created: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
s3Url: string;
|
||||
sortOrder: number;
|
||||
}> = [];
|
||||
const errors: UploadError[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const type = mediaTypeForMime(file.type);
|
||||
if (!type) {
|
||||
errors.push({ name: file.name, error: `Type non supporté (${file.type}).` });
|
||||
continue;
|
||||
}
|
||||
const maxBytes = maxBytesForType(type);
|
||||
if (file.size > maxBytes) {
|
||||
errors.push({
|
||||
name: file.name,
|
||||
error: `Fichier trop volumineux (max ${humanFileSize(maxBytes)}).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = buildMediaKey(carbetId, file.type);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const url = await putObject(key, buffer, file.type);
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
carbetId,
|
||||
type,
|
||||
s3Key: key,
|
||||
s3Url: url,
|
||||
sortOrder: nextOrder,
|
||||
},
|
||||
select: { id: true, type: true, s3Url: true, sortOrder: true },
|
||||
});
|
||||
nextOrder += 1;
|
||||
created.push(media);
|
||||
} catch (error) {
|
||||
console.error("Échec de l'upload média", error);
|
||||
errors.push({ name: file.name, error: "Échec de l'envoi vers le stockage." });
|
||||
}
|
||||
}
|
||||
|
||||
if (created.length > 0) {
|
||||
revalidatePath(`/espace-hote/carbets/${carbetId}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ created, errors },
|
||||
{ status: created.length > 0 ? 201 : 400 },
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue