215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { z } from "zod";
|
|
import { auth } from "@/auth";
|
|
import { requireRole } from "@/lib/authorization";
|
|
import { recordAudit } from "@/lib/admin/audit";
|
|
import { prisma } from "@/lib/prisma";
|
|
import {
|
|
AccessType,
|
|
CarbetStatus,
|
|
MediaType,
|
|
TransportMode,
|
|
UserRole,
|
|
} from "@/generated/prisma/enums";
|
|
|
|
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
|
|
|
|
const baseCarbetSchema = z.object({
|
|
ownerId: z.string().min(1, "Propriétaire requis"),
|
|
title: z.string().trim().min(1).max(200),
|
|
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
|
|
description: z.string().trim().min(10).max(20000),
|
|
river: z.string().trim().min(2).max(100),
|
|
embarkPoint: z.string().trim().min(2).max(200),
|
|
latitude: z.coerce.number().min(-90).max(90),
|
|
longitude: z.coerce.number().min(-180).max(180),
|
|
capacity: z.coerce.number().int().min(1).max(100),
|
|
nightlyPrice: z.coerce.number().min(0).max(100000),
|
|
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
|
|
roadAccessNote: z.string().trim().max(1000).optional().nullable(),
|
|
pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(),
|
|
minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
|
maxStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
|
|
minCapacity: z.coerce.number().int().min(1).max(100).optional().nullable(),
|
|
transportMode: z
|
|
.enum([TransportMode.OWNER_PROVIDES, TransportMode.SELF_ARRANGE, TransportMode.PARTNER_PROVIDER])
|
|
.optional()
|
|
.nullable(),
|
|
pirogueProviderId: z.string().optional().nullable(),
|
|
status: z.enum([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]).default(CarbetStatus.DRAFT),
|
|
});
|
|
|
|
function normalizeNullable<T>(v: T | "" | undefined | null): T | null {
|
|
if (v === undefined || v === null || v === "") return null;
|
|
return v;
|
|
}
|
|
|
|
function parseFromFormData(fd: FormData) {
|
|
const obj: Record<string, unknown> = {};
|
|
for (const [k, v] of fd.entries()) {
|
|
if (typeof v === "string") obj[k] = v;
|
|
}
|
|
// Normalise les champs optionnels nullables
|
|
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach(
|
|
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
|
|
);
|
|
return obj;
|
|
}
|
|
|
|
export async function createCarbetAction(fd: FormData) {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
|
|
if (!parsed.success) {
|
|
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
}
|
|
const session = await auth();
|
|
try {
|
|
const created = await prisma.carbet.create({
|
|
data: {
|
|
...parsed.data,
|
|
lastBookedAt: null,
|
|
},
|
|
});
|
|
await audit("carbet.create", created.id, session?.user?.email ?? null, {
|
|
slug: created.slug,
|
|
status: created.status,
|
|
});
|
|
revalidatePath("/admin/carbets");
|
|
redirect(`/admin/carbets/${created.id}`);
|
|
} catch (e) {
|
|
if (e instanceof Error && e.message.includes("Unique constraint")) {
|
|
return { ok: false as const, error: "Slug déjà utilisé" };
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export async function updateCarbetAction(id: string, fd: FormData) {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
|
|
if (!parsed.success) {
|
|
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
|
}
|
|
const session = await auth();
|
|
try {
|
|
const updated = await prisma.carbet.update({
|
|
where: { id },
|
|
data: parsed.data,
|
|
});
|
|
await audit("carbet.update", updated.id, session?.user?.email ?? null, {
|
|
slug: updated.slug,
|
|
status: updated.status,
|
|
});
|
|
revalidatePath("/admin/carbets");
|
|
revalidatePath(`/admin/carbets/${id}`);
|
|
revalidatePath(`/carbets/${updated.slug}`);
|
|
return { ok: true as const };
|
|
} catch (e) {
|
|
if (e instanceof Error && e.message.includes("Unique constraint")) {
|
|
return { ok: false as const, error: "Slug déjà utilisé" };
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export async function updateCarbetStatusAction(id: string, status: CarbetStatus) {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const session = await auth();
|
|
await prisma.carbet.update({ where: { id }, data: { status } });
|
|
await audit("carbet.status", id, session?.user?.email ?? null, { status });
|
|
revalidatePath("/admin/carbets");
|
|
revalidatePath(`/admin/carbets/${id}`);
|
|
return { ok: true as const };
|
|
}
|
|
|
|
export async function deleteCarbetAction(id: string) {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const session = await auth();
|
|
// Soft : on archive plutôt que supprimer (bookings/reviews FK Restrict).
|
|
const archived = await prisma.carbet.update({
|
|
where: { id },
|
|
data: { status: CarbetStatus.ARCHIVED },
|
|
});
|
|
await audit("carbet.archive", id, session?.user?.email ?? null, { slug: archived.slug });
|
|
revalidatePath("/admin/carbets");
|
|
redirect("/admin/carbets");
|
|
}
|
|
|
|
const mediaSchema = z.object({
|
|
url: z.string().url().max(2000),
|
|
type: z.enum([MediaType.PHOTO, MediaType.VIDEO]).default(MediaType.PHOTO),
|
|
s3Key: z.string().max(500).optional(),
|
|
});
|
|
|
|
export async function addMediaAction(carbetId: string, fd: FormData) {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const parsed = mediaSchema.safeParse({
|
|
url: fd.get("url"),
|
|
type: fd.get("type") ?? "PHOTO",
|
|
s3Key: fd.get("s3Key") ?? undefined,
|
|
});
|
|
if (!parsed.success) {
|
|
return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
|
|
}
|
|
const existing = await prisma.media.count({ where: { carbetId } });
|
|
const session = await auth();
|
|
const m = await prisma.media.create({
|
|
data: {
|
|
carbetId,
|
|
type: parsed.data.type,
|
|
s3Url: parsed.data.url,
|
|
s3Key: parsed.data.s3Key ?? `external/${Date.now()}`,
|
|
sortOrder: existing,
|
|
},
|
|
});
|
|
await audit("media.create", m.id, session?.user?.email ?? null, { carbetId, url: parsed.data.url });
|
|
revalidatePath(`/admin/carbets/${carbetId}`);
|
|
return { ok: true as const };
|
|
}
|
|
|
|
export async function removeMediaAction(carbetId: string, mediaId: string) {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const session = await auth();
|
|
await prisma.media.delete({ where: { id: mediaId } });
|
|
await audit("media.delete", mediaId, session?.user?.email ?? null, { carbetId });
|
|
revalidatePath(`/admin/carbets/${carbetId}`);
|
|
return { ok: true as const };
|
|
}
|
|
|
|
export async function reorderMediaAction(carbetId: string, mediaId: string, direction: "up" | "down") {
|
|
await requireRole([UserRole.ADMIN]);
|
|
const all = await prisma.media.findMany({
|
|
where: { carbetId },
|
|
orderBy: { sortOrder: "asc" },
|
|
});
|
|
const idx = all.findIndex((m) => m.id === mediaId);
|
|
if (idx === -1) return { ok: false as const };
|
|
const swap = direction === "up" ? idx - 1 : idx + 1;
|
|
if (swap < 0 || swap >= all.length) return { ok: false as const };
|
|
const a = all[idx];
|
|
const b = all[swap];
|
|
await prisma.$transaction([
|
|
prisma.media.update({ where: { id: a.id }, data: { sortOrder: b.sortOrder } }),
|
|
prisma.media.update({ where: { id: b.id }, data: { sortOrder: a.sortOrder } }),
|
|
]);
|
|
revalidatePath(`/admin/carbets/${carbetId}`);
|
|
return { ok: true as const };
|
|
}
|
|
|
|
async function audit(
|
|
event: string,
|
|
entityId: string,
|
|
actor: string | null,
|
|
payload: Record<string, unknown>,
|
|
) {
|
|
await recordAudit({
|
|
scope: "admin.carbets",
|
|
event,
|
|
target: entityId,
|
|
actorEmail: actor,
|
|
details: payload,
|
|
});
|
|
}
|