karbe/src/app/admin/carbets/actions.ts

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,
});
}