diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 1ded993..8953cf7 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -5,7 +5,7 @@ import { UserRole } from "@/generated/prisma/enums"; import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; -import { sendSignupWelcome } from "@/lib/email"; +import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email"; import { rateLimitRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -16,11 +16,14 @@ const schema = z.object({ firstName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100), phone: z.string().trim().max(40).optional().nullable(), - role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST), + role: z + .enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER]) + .default(UserRole.TOURIST), + providerName: z.string().trim().min(2).max(200).optional(), + providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(), }); export async function POST(req: Request) { - // 5 inscriptions max par IP par heure. const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5); if (!rl.ok) { return NextResponse.json( @@ -43,6 +46,10 @@ export async function POST(req: Request) { } const data = parsed.data; + if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) { + return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 }); + } + const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } }); if (existing) { return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 }); @@ -62,16 +69,36 @@ export async function POST(req: Request) { select: { id: true, email: true, role: true }, }); + // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation. + let createdProviderId: string | null = null; + if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) { + const provider = await prisma.rentalProvider.create({ + data: { + name: data.providerName, + isSystemD: false, + managedByUserId: user.id, + contactEmail: user.email, + contactPhone: data.phone?.trim() || null, + rivers: data.providerRivers ?? [], + commissionPct: 10, // valeur par défaut, ajustable par admin + active: true, + approved: false, + }, + select: { id: true, name: true }, + }); + createdProviderId = provider.id; + sendNewRentalProviderRequest(provider.name, user.email).catch(() => {}); + } + await recordAudit({ scope: "public.signup", event: "user.create", target: user.id, actorEmail: user.email, - details: { role: user.role }, + details: { role: user.role, rentalProviderId: createdProviderId }, }); - // Best-effort welcome email. sendSignupWelcome(user.email, data.firstName).catch(() => {}); - return NextResponse.json({ ok: true, userId: user.id }); + return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId }); } diff --git a/src/app/espace-prestataire/actions.ts b/src/app/espace-prestataire/actions.ts new file mode 100644 index 0000000..2474e91 --- /dev/null +++ b/src/app/espace-prestataire/actions.ts @@ -0,0 +1,237 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums"; +import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access"; +import { recordAudit } from "@/lib/admin/audit"; +import { prisma } from "@/lib/prisma"; + +const itemSchema = z.object({ + category: z.enum([ + RentalCategory.SLEEP, + RentalCategory.NAVIGATION, + RentalCategory.FISHING, + RentalCategory.COOKING, + RentalCategory.SAFETY, + ]), + name: z.string().trim().min(2).max(200), + description: z.string().trim().max(5000).nullable().optional(), + imageUrl: z.string().trim().url().max(500).nullable().optional(), + pricePerDay: z.coerce.number().min(0).max(10000), + pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(), + deposit: z.coerce.number().min(0).max(10000), + totalQty: z.coerce.number().int().min(1).max(1000), + withMotor: z.boolean(), + fuelIncluded: z.boolean(), + requiresLicense: z.boolean(), + active: z.boolean(), +}); + +async function requireOwnedProvider(): Promise<{ providerId: string; actorEmail: string | null }> { + const session = await auth(); + if (!session?.user?.id) throw new Error("Non authentifié"); + const provider = await getCurrentRentalProvider(); + if (!provider) throw new Error("Aucun provider associé"); + return { providerId: provider.id, actorEmail: session.user.email ?? null }; +} + +function parseItemFD(fd: FormData) { + const get = (k: string) => { + const v = (fd.get(k) as string | null) ?? ""; + return v.trim() === "" ? null : v.trim(); + }; + return { + category: ((fd.get("category") as string | null) ?? "").trim(), + name: ((fd.get("name") as string | null) ?? "").trim(), + description: get("description"), + imageUrl: get("imageUrl"), + pricePerDay: fd.get("pricePerDay"), + pricePerWeek: get("pricePerWeek"), + deposit: fd.get("deposit") ?? "0", + totalQty: fd.get("totalQty") ?? "1", + withMotor: fd.get("withMotor") === "on", + fuelIncluded: fd.get("fuelIncluded") === "on", + requiresLicense: fd.get("requiresLicense") === "on", + active: fd.get("active") === "on", + }; +} + +export async function createHostItemAction(fd: FormData) { + const { providerId, actorEmail } = await requireOwnedProvider(); + const parsed = itemSchema.safeParse(parseItemFD(fd)); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }; + } + const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } }); + await recordAudit({ + scope: "host.rental-items", + event: "create", + target: created.id, + actorEmail, + details: { name: created.name, providerId }, + }); + revalidatePath("/espace-prestataire/items"); + redirect(`/espace-prestataire/items/${created.id}`); +} + +export async function updateHostItemAction(itemId: string, fd: FormData) { + const { providerId, actorEmail } = await requireOwnedProvider(); + const session = await auth(); + if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId))) { + return { ok: false as const, error: "Accès refusé" }; + } + const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } }); + if (!existing || existing.providerId !== providerId) { + return { ok: false as const, error: "Item introuvable." }; + } + const parsed = itemSchema.safeParse(parseItemFD(fd)); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }; + } + await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data }); + await recordAudit({ + scope: "host.rental-items", + event: "update", + target: itemId, + actorEmail, + details: { name: parsed.data.name }, + }); + revalidatePath("/espace-prestataire/items"); + revalidatePath(`/espace-prestataire/items/${itemId}`); + return { ok: true as const }; +} + +export async function deleteHostItemAction(itemId: string) { + const { providerId, actorEmail } = await requireOwnedProvider(); + const existing = await prisma.rentalItem.findUnique({ + where: { id: itemId }, + select: { providerId: true, _count: { select: { lines: true } } }, + }); + if (!existing || existing.providerId !== providerId) { + return { ok: false as const, error: "Item introuvable." }; + } + if (existing._count.lines > 0) { + return { ok: false as const, error: "Impossible : item référencé par des locations." }; + } + await prisma.rentalItem.delete({ where: { id: itemId } }); + await recordAudit({ + scope: "host.rental-items", + event: "delete", + target: itemId, + actorEmail, + details: {}, + }); + revalidatePath("/espace-prestataire/items"); + redirect("/espace-prestataire/items"); +} + +const blockSchema = z.object({ + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + qty: z.coerce.number().int().min(1).max(1000), + reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]), +}); + +export async function addItemBlockAction(itemId: string, fd: FormData) { + const { providerId, actorEmail } = await requireOwnedProvider(); + const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } }); + if (!existing || existing.providerId !== providerId) { + return { ok: false as const, error: "Item introuvable." }; + } + const parsed = blockSchema.safeParse({ + startDate: fd.get("startDate"), + endDate: fd.get("endDate"), + qty: fd.get("qty"), + reason: fd.get("reason"), + }); + if (!parsed.success) { + return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") }; + } + const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`); + const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`); + if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." }; + + await prisma.rentalItemAvailability.create({ + data: { + itemId, + startDate: start, + endDate: end, + qty: parsed.data.qty, + reason: parsed.data.reason, + }, + }); + await recordAudit({ + scope: "host.rental-items", + event: "block.add", + target: itemId, + actorEmail, + details: { ...parsed.data }, + }); + revalidatePath(`/espace-prestataire/items/${itemId}`); + return { ok: true as const }; +} + +export async function removeItemBlockAction(blockId: string) { + const { providerId, actorEmail } = await requireOwnedProvider(); + const block = await prisma.rentalItemAvailability.findUnique({ + where: { id: blockId }, + select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } }, + }); + if (!block || block.item.providerId !== providerId) { + return { ok: false as const, error: "Blocage introuvable." }; + } + if (block.rentalBookingId) { + return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." }; + } + await prisma.rentalItemAvailability.delete({ where: { id: blockId } }); + await recordAudit({ + scope: "host.rental-items", + event: "block.remove", + target: blockId, + actorEmail, + details: { itemId: block.itemId }, + }); + revalidatePath(`/espace-prestataire/items/${block.itemId}`); + return { ok: true as const }; +} + +const statusSchema = z.enum([ + RentalBookingStatus.PENDING, + RentalBookingStatus.CONFIRMED, + RentalBookingStatus.HANDED_OVER, + RentalBookingStatus.RETURNED, + RentalBookingStatus.CANCELLED, +]); + +export async function updateBookingStatusAction(bookingId: string, status: string) { + const { providerId, actorEmail } = await requireOwnedProvider(); + const session = await auth(); + const role = session?.user?.role; + const parsed = statusSchema.safeParse(status); + if (!parsed.success) return { ok: false as const, error: "Statut invalide." }; + + const existing = await prisma.rentalBooking.findUnique({ + where: { id: bookingId }, + select: { providerId: true }, + }); + if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) { + return { ok: false as const, error: "Réservation introuvable." }; + } + await prisma.rentalBooking.update({ + where: { id: bookingId }, + data: { status: parsed.data }, + }); + await recordAudit({ + scope: "host.rental-bookings", + event: "status.update", + target: bookingId, + actorEmail, + details: { status: parsed.data }, + }); + revalidatePath("/espace-prestataire/reservations"); + return { ok: true as const }; +} diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx new file mode 100644 index 0000000..e83e53b --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +type Block = { + id: string; + startDate: string; + endDate: string; + qty: number; + reason: string; + isBooking: boolean; +}; + +type Props = { + blocks: Block[]; + totalQty: number; + addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>; + removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>; +}; + +const REASON_LABEL: Record = { + MAINTENANCE: "🔧 Maintenance", + MANUAL_BLOCK: "⛔ Blocage personnel", + RENTAL_BOOKING: "🛒 Réservation", +}; + +export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onAdd(fd: FormData) { + setError(null); + startTransition(async () => { + const res = await addAction(fd); + if (res && res.ok === false) setError(res.error); + router.refresh(); + }); + } + + function onRemove(blockId: string) { + setError(null); + startTransition(async () => { + const res = await removeAction(blockId); + if (res && res.ok === false) setError(res.error); + router.refresh(); + }); + } + + return ( +
+
+
+ + + + +
+ +
+
+
+ + {error ? ( +
{error}
+ ) : null} + + {blocks.length === 0 ? ( +

+ Aucun blocage manuel. Toutes les dates sont disponibles. +

+ ) : ( +
    + {blocks.map((b) => ( +
  • +
    + + {b.startDate} → {b.endDate} + + + {b.qty} unité{b.qty > 1 ? "s" : ""} · {REASON_LABEL[b.reason] ?? b.reason} + +
    + {!b.isBooking ? ( + + ) : ( + Auto + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx new file mode 100644 index 0000000..bc81c8b --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useTransition } from "react"; + +type Props = { + canDelete: boolean; + deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>; +}; + +export function ItemInlineDelete({ canDelete, deleteAction }: Props) { + const [pending, startTransition] = useTransition(); + const [confirm, setConfirm] = useState(false); + const [error, setError] = useState(null); + + function run() { + setError(null); + startTransition(async () => { + const res = await deleteAction(); + if (res && (res as { ok?: boolean }).ok === false) { + setError((res as { error: string }).error); + setConfirm(false); + } + }); + } + + if (!canDelete) { + return ( + + Suppression impossible — item référencé par des locations + + ); + } + + return ( +
+ {confirm ? ( +
+ Supprimer ? + + +
+ ) : ( + + )} + {error ? ( +
{error}
+ ) : null} +
+ ); +} diff --git a/src/app/espace-prestataire/items/[itemId]/page.tsx b/src/app/espace-prestataire/items/[itemId]/page.tsx new file mode 100644 index 0000000..699a8b0 --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/page.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access"; +import { getHostItem } from "@/lib/rental-host"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +import { HostItemForm } from "../_components/ItemForm"; +import { ItemBlocksManager } from "./_components/ItemBlocksManager"; +import { ItemInlineDelete } from "./_components/ItemInlineDelete"; +import { + addItemBlockAction, + deleteHostItemAction, + removeItemBlockAction, + updateHostItemAction, +} from "../../actions"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ itemId: string }> }; + +export default async function EditHostItemPage({ params }: PageProps) { + await requireRentalProviderSession(); + const provider = await getCurrentRentalProvider(); + if (!provider) redirect("/admin/rental-providers"); + const { itemId } = await params; + const item = await getHostItem(provider.id, itemId); + if (!item) notFound(); + + const updateThis = async (fd: FormData) => { + "use server"; + return await updateHostItemAction(itemId, fd); + }; + const deleteThis = async () => { + "use server"; + return await deleteHostItemAction(itemId); + }; + const addBlockThis = async (fd: FormData) => { + "use server"; + return await addItemBlockAction(itemId, fd); + }; + const removeBlockThis = async (blockId: string) => { + "use server"; + return await removeItemBlockAction(blockId); + }; + + return ( +
+
+
+ + ← Mes items + +

{item.name}

+

+ {RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines} location(s) historique +

+
+ +
+ +
+ +
+ +
+

+ Calendrier de disponibilité +

+

+ Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations + confirmées sont gérées automatiquement. +

+ ({ + id: a.id, + startDate: a.startDate.toISOString().slice(0, 10), + endDate: a.endDate.toISOString().slice(0, 10), + qty: a.qty, + reason: a.reason, + isBooking: Boolean(a.rentalBookingId), + }))} + addAction={addBlockThis} + removeAction={removeBlockThis} + totalQty={item.totalQty} + /> +
+
+ ); +} diff --git a/src/app/espace-prestataire/items/_components/ItemForm.tsx b/src/app/espace-prestataire/items/_components/ItemForm.tsx new file mode 100644 index 0000000..c0033ad --- /dev/null +++ b/src/app/espace-prestataire/items/_components/ItemForm.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useTransition } from "react"; + +import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels"; + +const inputCls = + "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"; +const labelCls = "block text-sm font-medium text-zinc-800"; + +type Props = { + initial?: { + category?: string; + name?: string; + description?: string | null; + imageUrl?: string | null; + pricePerDay?: string | number; + pricePerWeek?: string | number | null; + deposit?: string | number; + totalQty?: number; + withMotor?: boolean; + fuelIncluded?: boolean; + requiresLicense?: boolean; + active?: boolean; + }; + action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>; + submitLabel?: string; +}; + +export function HostItemForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await action(fd); + if (res && res.ok === false) setError(res.error); + else if (res && res.ok === true) setSuccess("Enregistré."); + }); + } + + return ( +
+
+
+ + + +