diff --git a/prisma/migrations/20260603100000_rental_item_media/migration.sql b/prisma/migrations/20260603100000_rental_item_media/migration.sql new file mode 100644 index 0000000..67a2d76 --- /dev/null +++ b/prisma/migrations/20260603100000_rental_item_media/migration.sql @@ -0,0 +1,22 @@ +-- Sprint F : RentalItemMedia (photos & vidéos pour items rental). +-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url, +-- sortOrder pour cover (0). Cascade sur RentalItem. + +CREATE TABLE "RentalItemMedia" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "type" "MediaType" NOT NULL, + "s3Key" TEXT NOT NULL, + "s3Url" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx" + ON "RentalItemMedia"("itemId", "sortOrder"); + +ALTER TABLE "RentalItemMedia" + ADD CONSTRAINT "RentalItemMedia_itemId_fkey" + FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603200000_ce_management/migration.sql b/prisma/migrations/20260603200000_ce_management/migration.sql new file mode 100644 index 0000000..eb2cc87 --- /dev/null +++ b/prisma/migrations/20260603200000_ce_management/migration.sql @@ -0,0 +1,54 @@ +-- Sprint G : CE management. +-- * Organization gagne le workflow d'approbation (approved + approvedAt + approvedBy) +-- + un contactEmail dédié pour les notifications admin. +-- * Nouveau modèle OrganizationCarbetMembership : co-gestion des carbets par les +-- CE_MANAGERs d'une org liée. Pas de unique sur carbet → un Carbet pourrait être +-- co-publié par plusieurs orgs (cas rare mais autorisé). +-- * RentalProvider gagne organizationId (nullable) : un CE peut posséder son provider. + +ALTER TABLE "Organization" + ADD COLUMN "contactEmail" TEXT, + ADD COLUMN "approved" BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN "approvedAt" TIMESTAMP(3), + ADD COLUMN "approvedBy" TEXT; + +CREATE INDEX "Organization_approved_idx" ON "Organization"("approved"); + +-- Backfill : toutes les orgs existantes sont considérées validées. +-- (Aujourd'hui : CMCK uniquement. Les futures orgs créées via signup arriveront +-- en approved=false par défaut.) +UPDATE "Organization" + SET "approved" = TRUE, + "approvedAt" = NOW() + WHERE "approved" = FALSE; + +CREATE TABLE "OrganizationCarbetMembership" ( + "organizationId" TEXT NOT NULL, + "carbetId" TEXT NOT NULL, + "addedByUserId" TEXT, + "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OrganizationCarbetMembership_pkey" PRIMARY KEY ("organizationId", "carbetId") +); + +CREATE INDEX "OrganizationCarbetMembership_carbetId_idx" + ON "OrganizationCarbetMembership"("carbetId"); + +ALTER TABLE "OrganizationCarbetMembership" + ADD CONSTRAINT "OrganizationCarbetMembership_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "OrganizationCarbetMembership" + ADD CONSTRAINT "OrganizationCarbetMembership_carbetId_fkey" + FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "RentalProvider" + ADD COLUMN "organizationId" TEXT; + +CREATE INDEX "RentalProvider_organizationId_idx" ON "RentalProvider"("organizationId"); + +ALTER TABLE "RentalProvider" + ADD CONSTRAINT "RentalProvider_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603300000_org_invite_token/migration.sql b/prisma/migrations/20260603300000_org_invite_token/migration.sql new file mode 100644 index 0000000..7ce99e5 --- /dev/null +++ b/prisma/migrations/20260603300000_org_invite_token/migration.sql @@ -0,0 +1,22 @@ +-- Sprint K : tokens d'invitation CE_MEMBER. +-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit +-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation. + +CREATE TABLE "OrgInviteToken" ( + "tokenHash" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT, + "createdByUserId" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash") +); + +CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId"); +CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt"); + +ALTER TABLE "OrgInviteToken" + ADD CONSTRAINT "OrgInviteToken_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql new file mode 100644 index 0000000..cff28de --- /dev/null +++ b/prisma/migrations/20260603400000_rental_payout_mark/migration.sql @@ -0,0 +1,28 @@ +-- Sprint O : reversements prestataires. +-- RentalPayoutMark trace les virements bancaires manuels effectués par System D +-- vers les RentalProvider tiers (le marketplace encaisse centralisé, redistribue +-- hors plateforme une fois par mois). Unique (provider, mois) pour empêcher +-- les marquages en doublon. + +CREATE TABLE "RentalPayoutMark" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "periodMonth" TIMESTAMP(3) NOT NULL, + "amount" DECIMAL(10, 2) NOT NULL, + "reference" TEXT, + "paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "paidByEmail" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RentalPayoutMark_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "RentalPayoutMark_providerId_periodMonth_key" + ON "RentalPayoutMark"("providerId", "periodMonth"); + +CREATE INDEX "RentalPayoutMark_periodMonth_idx" + ON "RentalPayoutMark"("periodMonth"); + +ALTER TABLE "RentalPayoutMark" + ADD CONSTRAINT "RentalPayoutMark_providerId_fkey" + FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7580413..6ae7e3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,16 +72,59 @@ enum TransportMode { } model Organization { - id String @id @default(cuid()) - name String - slug String @unique - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + slug String @unique + description String? + contactEmail String? + approved Boolean @default(false) + approvedAt DateTime? + approvedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - members User[] + members User[] + carbetMemberships OrganizationCarbetMembership[] + rentalProviders RentalProvider[] + invites OrgInviteToken[] @@index([name]) + @@index([approved]) +} + +/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER. +/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN. +/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire. +model OrgInviteToken { + tokenHash String @id + organizationId String + email String? + createdByUserId String? + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([expiresAt]) +} + +/// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial), +/// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet +/// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE : +/// 1 membership pour l'org du créateur. Plusieurs orgs possibles si co-publication. +model OrganizationCarbetMembership { + organizationId String + carbetId String + addedByUserId String? + addedAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade) + + @@id([organizationId, carbetId]) + @@index([carbetId]) } model User { @@ -157,6 +200,7 @@ model Carbet { bookings Booking[] reviews Review[] subscriptions Subscription[] + organizations OrganizationCarbetMembership[] @@index([ownerId]) @@index([status]) @@ -425,6 +469,9 @@ model RentalProvider { name String isSystemD Boolean @default(false) managedByUserId String? + /// Si renseigné, le provider appartient à une organisation (CE) ; tout CE_MANAGER + /// membre de l'org peut gérer items et réservations en plus du manager nominal. + organizationId String? contactEmail String? contactPhone String? rivers String[] @default([]) @@ -438,11 +485,36 @@ model RentalProvider { updatedAt DateTime @updatedAt manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull) + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull) items RentalItem[] rentalBookings RentalBooking[] + payoutMarks RentalPayoutMark[] @@index([active, approved]) @@index([managedByUserId]) + @@index([organizationId]) +} + +/// Trace les reversements bancaires manuels (System D paie le provider hors plateforme). +/// La période est représentée par le mois (1er du mois minuit UTC) ; unique par +/// (provider, période) pour empêcher de marquer 2 fois le même mois. +model RentalPayoutMark { + id String @id @default(cuid()) + providerId String + /// 1er du mois minuit UTC — sert de clé de période. + periodMonth DateTime + /// Montant effectivement viré au provider, en euros. + amount Decimal @db.Decimal(10, 2) + /// Référence de virement (optionnelle, à coller depuis la banque). + reference String? + paidAt DateTime @default(now()) + paidByEmail String? + createdAt DateTime @default(now()) + + provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) + + @@unique([providerId, periodMonth]) + @@index([periodMonth]) } model RentalItem { @@ -466,11 +538,26 @@ model RentalItem { provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) availabilities RentalItemAvailability[] lines RentalLine[] + media RentalItemMedia[] @@index([providerId]) @@index([category, active]) } +model RentalItemMedia { + id String @id @default(cuid()) + itemId String + type MediaType + s3Key String + s3Url String + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@index([itemId, sortOrder]) +} + model RentalItemAvailability { id String @id @default(cuid()) itemId String diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx new file mode 100644 index 0000000..0ec2427 --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -0,0 +1,169 @@ +import Link from "next/link"; + +import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart"; +import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Analytics globaux — Karbé admin" }; + +const ROLE_LABEL: Record = { + ADMIN: "Admin", + OWNER: "Hôte", + RENTAL_PROVIDER: "Loueur matériel", + CE_MANAGER: "CE Manager", + CE_MEMBER: "CE Membre", + TOURIST: "Voyageur", +}; + +function fmtEur(n: number): string { + return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); +} + +export default async function AdminAnalyticsPage() { + const [kpis, series] = await Promise.all([ + getAdminGlobalKpis(), + getMonthlyRevenueSeries({ monthsBack: 12 }), + ]); + + return ( +
+
+

Analytics globaux

+

+ Vue d'ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers. +

+
+ +
+ + + + +
+ +
+
+

+ Utilisateurs par rôle +

+ {kpis.usersTotal === 0 ? ( +

Aucun utilisateur.

+ ) : ( +
    + {Object.entries(kpis.usersByRole) + .sort((a, b) => b[1] - a[1]) + .map(([role, count]) => { + const pct = Math.round((count / kpis.usersTotal) * 100); + return ( +
  • +
    + {ROLE_LABEL[role] ?? role} + + {count} ({pct}%) + +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+ +
+

+ Activité 30 derniers jours +

+
    +
  • + Bookings carbet + {kpis.bookings30d} +
  • +
  • + Locations matériel + {kpis.rentals30d} +
  • +
  • + Total CA 30j + + {fmtEur(kpis.revenue30d)} + +
  • +
+
+
+ +
+

+ Chiffre d'affaires mensuel +

+ +
+ +
+
+

+ Top carbets (30j) +

+ {kpis.topCarbets.length === 0 ? ( +

Aucune réservation sur les 30 derniers jours.

+ ) : ( +
    + {kpis.topCarbets.map((c, i) => ( +
  • + + #{i + 1} + + {c.title} + + + {fmtEur(c.revenue)} +
  • + ))} +
+ )} +
+ +
+

+ Top prestataires rental (30j) +

+ {kpis.topProviders.length === 0 ? ( +

Aucune location sur les 30 derniers jours.

+ ) : ( +
    + {kpis.topProviders.map((p, i) => ( +
  • + + #{i + 1} + + {p.name} + + + {fmtEur(p.revenue)} +
  • + ))} +
+ )} +
+
+
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx new file mode 100644 index 0000000..95bfc88 --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState, useTransition } from "react"; + +type Org = { id: string; name: string; slug: string; approved: boolean }; +type LinkedOrg = Org & { addedAt: Date }; + +type Props = { + carbetId: string; + linked: LinkedOrg[]; + available: Org[]; + linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>; + unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>; +}; + +export function CarbetMemberships({ + carbetId, + linked, + available, + linkAction, + unlinkAction, +}: Props) { + const [pending, startTransition] = useTransition(); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [error, setError] = useState(null); + + // Filtre les orgs non encore liées + const linkedIds = new Set(linked.map((l) => l.id)); + const options = available.filter((o) => !linkedIds.has(o.id)); + + function link() { + if (!selectedOrgId) return; + setError(null); + startTransition(async () => { + const res = await linkAction(carbetId, selectedOrgId); + if (!res.ok) setError(res.error || "Échec de la liaison"); + else setSelectedOrgId(""); + }); + } + + function unlink(orgId: string) { + setError(null); + startTransition(async () => { + const res = await unlinkAction(carbetId, orgId); + if (!res.ok) setError(res.error || "Échec"); + }); + } + + return ( +
+ {linked.length === 0 ? ( +

+ Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel. +

+ ) : ( +
    + {linked.map((o) => ( +
  • +
    + {o.name} + /{o.slug} + {!o.approved ? ( + + Pending + + ) : null} +
    + +
  • + ))} +
+ )} + + {options.length > 0 ? ( +
+ + +
+ ) : ( +

+ Toutes les organisations existantes sont déjà liées à ce carbet. +

+ )} + + {error ? ( +
+ {error} +
+ ) : null} + +

+ Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du + propriétaire nominal. +

+
+ ); +} diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index bf7a972..6a69e2e 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -1,15 +1,23 @@ import { notFound } from "next/navigation"; import Link from "next/link"; + +import { MediaUploader } from "@/components/MediaUploader"; +import { StatusBadge } from "@/components/admin/StatusBadge"; import { getCarbetForEdit, + listOrganizationsForLink, listOwners, listPirogueProviders, } from "@/lib/admin/carbets"; + import { CarbetForm } from "../_components/CarbetForm"; -import { StatusBadge } from "@/components/admin/StatusBadge"; -import { MediaUploader } from "@/components/MediaUploader"; +import { + linkCarbetToOrganizationAction, + unlinkCarbetFromOrganizationAction, + updateCarbetAction, +} from "../actions"; +import { CarbetMemberships } from "./_components/CarbetMemberships"; import { StatusActions } from "./_components/StatusActions"; -import { updateCarbetAction } from "../actions"; export const dynamic = "force-dynamic"; @@ -17,10 +25,11 @@ type PageProps = { params: Promise<{ id: string }> }; export default async function EditCarbetPage({ params }: PageProps) { const { id } = await params; - const [carbet, owners, providers] = await Promise.all([ + const [carbet, owners, providers, organizations] = await Promise.all([ getCarbetForEdit(id), listOwners(), listPirogueProviders(), + listOrganizationsForLink(), ]); if (!carbet) notFound(); @@ -28,6 +37,14 @@ export default async function EditCarbetPage({ params }: PageProps) { "use server"; return await updateCarbetAction(id, fd); }; + const linkThis = async (carbetId: string, orgId: string) => { + "use server"; + return await linkCarbetToOrganizationAction(carbetId, orgId); + }; + const unlinkThis = async (carbetId: string, orgId: string) => { + "use server"; + return await unlinkCarbetFromOrganizationAction(carbetId, orgId); + }; return (
@@ -61,6 +78,25 @@ export default async function EditCarbetPage({ params }: PageProps) {
+
+

+ Organisations co-gestionnaires (CE) +

+ ({ + id: m.organization.id, + name: m.organization.name, + slug: m.organization.slug, + approved: m.organization.approved, + addedAt: m.addedAt, + }))} + available={organizations} + linkAction={linkThis} + unlinkAction={unlinkThis} + /> +
+

Médias diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts index 2004bd8..f85950a 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -213,6 +213,42 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire return { ok: true as const }; } +export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actorEmail = session?.user?.email ?? null; + // findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas. + const existing = await prisma.organizationCarbetMembership.findUnique({ + where: { organizationId_carbetId: { organizationId, carbetId } }, + select: { organizationId: true }, + }); + if (existing) { + return { ok: true as const, alreadyLinked: true }; + } + await prisma.organizationCarbetMembership.create({ + data: { + organizationId, + carbetId, + addedByUserId: session?.user?.id ?? null, + }, + }); + await audit("carbet.org.link", carbetId, actorEmail, { organizationId }); + revalidatePath(`/admin/carbets/${carbetId}`); + return { ok: true as const, alreadyLinked: false }; +} + +export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actorEmail = session?.user?.email ?? null; + await prisma.organizationCarbetMembership + .delete({ where: { organizationId_carbetId: { organizationId, carbetId } } }) + .catch(() => {}); + await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId }); + revalidatePath(`/admin/carbets/${carbetId}`); + return { ok: true as const }; +} + async function audit( event: string, entityId: string, diff --git a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx new file mode 100644 index 0000000..d53a21c --- /dev/null +++ b/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState, useTransition } from "react"; + +type Props = { + action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>; +}; + +export function ApproveOrgButton({ action }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function run() { + setError(null); + startTransition(async () => { + const res = await action(); + if (res && (res as { ok?: boolean }).ok === false) { + setError((res as { error: string }).error); + } + }); + } + + return ( +
+ + {error ? {error} : null} +
+ ); +} diff --git a/src/app/admin/organizations/[id]/page.tsx b/src/app/admin/organizations/[id]/page.tsx index 90c91b6..810ba23 100644 --- a/src/app/admin/organizations/[id]/page.tsx +++ b/src/app/admin/organizations/[id]/page.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { getOrganizationForAdmin } from "@/lib/admin/organizations"; import { OrgForm } from "../_components/OrgForm"; import { StatusBadge } from "@/components/admin/StatusBadge"; -import { deleteOrganizationAction, updateOrganizationAction } from "../actions"; +import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions"; +import { ApproveOrgButton } from "./_components/ApproveOrgButton"; import { DeleteOrgButton } from "./_components/DeleteOrgButton"; export const dynamic = "force-dynamic"; @@ -31,6 +32,10 @@ export default async function EditOrgPage({ params }: PageProps) { "use server"; return await deleteOrganizationAction(id); }; + const approveThis = async () => { + "use server"; + return await approveOrganizationAction(id); + }; return (
@@ -39,12 +44,33 @@ export default async function EditOrgPage({ params }: PageProps) { ← Toutes les organisations -

{org.name}

+

+ {org.name} + {org.approved ? ( + + Validée + + ) : ( + + À valider + + )} +

- /{org.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""} + /{org.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""} ·{" "} + {org._count.carbetMemberships} carbet{org._count.carbetMemberships > 1 ? "s" : ""} co-géré + {org._count.carbetMemberships > 1 ? "s" : ""} · {org._count.rentalProviders} provider rental

+ {org.contactEmail ? ( +

+ Contact : {org.contactEmail} +

+ ) : null} +
+
+ {!org.approved ? : null} +
-
diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts index 5f8bcf6..6a0ae6f 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -5,7 +5,9 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { auth } from "@/auth"; import { UserRole } from "@/generated/prisma/enums"; +import { approveOrganization as approveOrganizationLib } from "@/lib/admin/organizations"; import { requireRole } from "@/lib/authorization"; +import { sendCeApproved } from "@/lib/email"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; @@ -75,6 +77,38 @@ export async function updateOrganizationAction(id: string, fd: FormData) { return { ok: true as const }; } +export async function approveOrganizationAction(id: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actor = session?.user?.email ?? null; + const res = await approveOrganizationLib(id, actor ?? "admin"); + if (!res.ok) return res; + if (!res.alreadyApproved) { + await audit("organization.approve", id, actor, {}); + // Notifier les CE_MANAGERs de l'org : leur compte vient d'être débloqué. + try { + const data = await prisma.organization.findUnique({ + where: { id }, + select: { + name: true, + members: { + where: { role: UserRole.CE_MANAGER, isActive: true }, + select: { email: true, firstName: true }, + }, + }, + }); + for (const m of data?.members ?? []) { + await sendCeApproved(m.email, m.firstName, data?.name ?? ""); + } + } catch (e) { + console.error("[admin.org.approve] email send failed:", e instanceof Error ? e.message : e); + } + } + revalidatePath("/admin/organizations"); + revalidatePath(`/admin/organizations/${id}`); + return { ok: true as const }; +} + export async function deleteOrganizationAction(id: string) { await requireRole([UserRole.ADMIN]); const session = await auth(); diff --git a/src/app/admin/organizations/page.tsx b/src/app/admin/organizations/page.tsx index 0d394ba..b6a5e95 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -1,16 +1,27 @@ import Link from "next/link"; -import { listOrganizationsAdmin } from "@/lib/admin/organizations"; +import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations"; export const dynamic = "force-dynamic"; type PageProps = { - searchParams: Promise<{ q?: string }>; + searchParams: Promise<{ q?: string; status?: string }>; }; +const STATUS_VALUES = ["all", "pending", "approved"] as const; +type StatusFilter = (typeof STATUS_VALUES)[number]; + +function isStatusFilter(s: string | undefined): s is StatusFilter { + return STATUS_VALUES.includes(s as StatusFilter); +} + export default async function OrgsAdminPage({ searchParams }: PageProps) { const sp = await searchParams; - const filters = { q: sp.q?.trim() || undefined }; - const orgs = await listOrganizationsAdmin(filters); + const approved = isStatusFilter(sp.status) ? sp.status : "all"; + const filters = { q: sp.q?.trim() || undefined, approved }; + const [orgs, pendingCount] = await Promise.all([ + listOrganizationsAdmin(filters), + countPendingOrganizations(), + ]); const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); return ( @@ -30,7 +41,35 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { + +
+ {approved !== "all" ? ( + + ) : null} Nom + Statut Slug Membres Créée @@ -61,7 +101,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { {orgs.length === 0 ? ( - + Aucune organisation. @@ -76,6 +116,17 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) {
{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}
) : null} + + {o.approved ? ( + + Validée + + ) : ( + + À valider + + )} + /{o.slug} {o.membersCount} {dateFmt.format(o.createdAt)} diff --git a/src/app/admin/payouts/_components/MarkPaidForm.tsx b/src/app/admin/payouts/_components/MarkPaidForm.tsx new file mode 100644 index 0000000..b2129d1 --- /dev/null +++ b/src/app/admin/payouts/_components/MarkPaidForm.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import type { ProviderPayout } from "@/lib/payouts"; + +type Props = { + payout: ProviderPayout; + markAction: ( + providerId: string, + periodMonthISO: string, + fd: FormData, + ) => Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }>; + unmarkAction: (providerId: string, periodMonthISO: string) => Promise; +}; + +function fmtEur(n: number): string { + return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" }); +} + +export function MarkPaidForm({ payout, markAction, unmarkAction }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [opened, setOpened] = useState(false); + const [error, setError] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + startTransition(async () => { + const res = await markAction(payout.providerId, payout.periodMonth.toISOString(), fd); + if (!res.ok) { + setError(res.error); + return; + } + setOpened(false); + router.refresh(); + }); + } + + function onUnmark() { + startTransition(async () => { + await unmarkAction(payout.providerId, payout.periodMonth.toISOString()); + router.refresh(); + }); + } + + if (payout.paid) { + return ( +
+ + Payé {fmtEur(payout.paid.amount)} + + {payout.paid.reference ? ( + Ref : {payout.paid.reference} + ) : null} + +
+ ); + } + + if (payout.netAmount <= 0) { + return ; + } + + if (!opened) { + return ( + + ); + } + + return ( + + + + {error ? {error} : null} +
+ + +
+ + ); +} diff --git a/src/app/admin/payouts/actions.ts b/src/app/admin/payouts/actions.ts new file mode 100644 index 0000000..ac92fd2 --- /dev/null +++ b/src/app/admin/payouts/actions.ts @@ -0,0 +1,96 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { requireRole } from "@/lib/authorization"; +import { + createPayoutMark, + deletePayoutMark, +} from "@/lib/payouts"; +import { prisma } from "@/lib/prisma"; +import { sendPayoutSent } from "@/lib/email"; + +export async function markPayoutPaidAction( + providerId: string, + periodMonthISO: string, + fd: FormData, +): Promise<{ ok: false; error: string } | { ok: true; alreadyExists: boolean }> { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actor = session?.user?.email ?? null; + const amount = Number(fd.get("amount") ?? 0); + const reference = ((fd.get("reference") as string | null) ?? "").trim() || null; + + if (!Number.isFinite(amount) || amount < 0) { + return { ok: false, error: "Montant invalide." }; + } + const periodMonth = new Date(periodMonthISO); + if (Number.isNaN(periodMonth.getTime())) { + return { ok: false, error: "Période invalide." }; + } + + const res = await createPayoutMark({ + providerId, + periodMonth, + amount, + reference, + paidByEmail: actor, + }); + if (!res.ok) return res; + + await recordAudit({ + scope: "admin.payouts", + event: res.alreadyExists ? "payout.already_marked" : "payout.mark", + target: providerId, + actorEmail: actor, + details: { + periodMonth: periodMonth.toISOString().slice(0, 7), + amount, + reference, + }, + }); + + // Notif provider best-effort (n'envoie que si on a un contactEmail) + if (!res.alreadyExists) { + try { + const provider = await prisma.rentalProvider.findUnique({ + where: { id: providerId }, + select: { name: true, contactEmail: true }, + }); + if (provider?.contactEmail) { + await sendPayoutSent( + provider.contactEmail, + provider.name, + periodMonth, + amount.toFixed(2), + reference, + ); + } + } catch (e) { + console.error("[payouts] email send failed:", e instanceof Error ? e.message : e); + } + } + + revalidatePath("/admin/payouts"); + return { ok: true, alreadyExists: res.alreadyExists }; +} + +export async function unmarkPayoutPaidAction(providerId: string, periodMonthISO: string) { + await requireRole([UserRole.ADMIN]); + const session = await auth(); + const actor = session?.user?.email ?? null; + const periodMonth = new Date(periodMonthISO); + if (Number.isNaN(periodMonth.getTime())) return; + await deletePayoutMark(providerId, periodMonth); + await recordAudit({ + scope: "admin.payouts", + event: "payout.unmark", + target: providerId, + actorEmail: actor, + details: { periodMonth: periodMonth.toISOString().slice(0, 7) }, + }); + revalidatePath("/admin/payouts"); +} diff --git a/src/app/admin/payouts/page.tsx b/src/app/admin/payouts/page.tsx new file mode 100644 index 0000000..0c40c19 --- /dev/null +++ b/src/app/admin/payouts/page.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; + +import { formatMonth, listProviderPayouts } from "@/lib/payouts"; + +import { markPayoutPaidAction, unmarkPayoutPaidAction } from "./actions"; +import { MarkPaidForm } from "./_components/MarkPaidForm"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Reversements prestataires — Karbé admin" }; + +function fmtEur(n: number): string { + return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" }); +} + +export default async function PayoutsAdminPage() { + const payouts = await listProviderPayouts({ monthsBack: 6 }); + + // Group by month + const byMonth = new Map(); + for (const p of payouts) { + const k = p.periodMonth.getTime(); + if (!byMonth.has(k)) byMonth.set(k, []); + byMonth.get(k)!.push(p); + } + + // Globals + const totalDue = payouts + .filter((p) => !p.paid && p.netAmount > 0) + .reduce((s, p) => s + p.netAmount, 0); + const totalPaid = payouts + .filter((p) => p.paid) + .reduce((s, p) => s + (p.paid!.amount), 0); + + return ( +
+
+

Reversements prestataires

+

+ Le marketplace encaisse centralisé sur System D ; voici les montants à reverser à chaque + prestataire pour les locations matériel des 6 derniers mois. System D n'apparaît pas + dans la liste (commission 0 %). +

+
+ +
+ + + +
+ + {Array.from(byMonth.entries()) + .sort((a, b) => b[0] - a[0]) + .map(([periodTs, rows]) => { + const period = new Date(periodTs); + const monthDue = rows + .filter((r) => !r.paid && r.netAmount > 0) + .reduce((s, r) => s + r.netAmount, 0); + return ( +
+
+

+ {formatMonth(period)} +

+ + Reste à payer ce mois :{" "} + {fmtEur(monthDue)} + +
+ + + + + + + + + + + + + {rows + .sort((a, b) => b.netAmount - a.netAmount) + .map((p) => ( + + + + + + + + + ))} + +
PrestataireRésaCA brutCommissionNet dûStatut
+ + {p.providerName} + + + {p.bookingsCount} + + {fmtEur(p.grossAmount)} + + {fmtEur(p.commission)} + + {fmtEur(p.netAmount)} + +
+ +
+
+
+ ); + })} +
+ ); +} + +function KpiCard({ + label, + value, + highlight, +}: { + label: string; + value: string; + highlight?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx index 8f4dd4a..59295d2 100644 --- a/src/app/admin/rental-items/[id]/page.tsx +++ b/src/app/admin/rental-items/[id]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { StatusBadge } from "@/components/admin/StatusBadge"; +import { MediaUploader } from "@/components/MediaUploader"; import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items"; import { ItemForm } from "../_components/ItemForm"; @@ -56,6 +57,14 @@ export default async function EditRentalItemPage({ params }: PageProps) { /> +
+

Photos & vidéos

+ +
+
o.organizationId))) { return NextResponse.json({ error: "Accès refusé." }, { status: 403 }); } diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts new file mode 100644 index 0000000..103315e --- /dev/null +++ b/src/app/api/cron/cleanup/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; + +import { + BookingStatus, + PaymentStatus, + RentalBookingStatus, +} from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { isAuthorizedCronRequest } from "@/lib/cron-auth"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const INVITE_EXPIRY_GRACE_DAYS = 30; +const ABANDONED_PENDING_DAYS = 7; + +/** + * GET /api/cron/cleanup + * + * Purge : + * - OrgInviteToken expirés depuis plus de 30j (rétention pour audit court). + * - Booking carbet PENDING dont createdAt > 7j et paiement non SUCCEEDED : + * status passé à CANCELLED (libère le créneau via cascade des + * Availabilities seulement si onDelete CASCADE — ici on flip juste + * status pour conserver le log). + * - RentalBooking PENDING idem + delete RentalItemAvailability associée + * (libère le stock). + * + * Auth : Bearer CRON_TOKEN. + */ +export async function GET(req: Request) { + if (!isAuthorizedCronRequest(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const now = new Date(); + const inviteCutoff = new Date(now.getTime() - INVITE_EXPIRY_GRACE_DAYS * 86_400_000); + const abandonedCutoff = new Date(now.getTime() - ABANDONED_PENDING_DAYS * 86_400_000); + + // 1. Invites expirés (expiresAt < cutoff) + const { count: invitesDeleted } = await prisma.orgInviteToken.deleteMany({ + where: { expiresAt: { lt: inviteCutoff } }, + }); + + // 2. Bookings carbet PENDING abandonnés + const abandonedBookings = await prisma.booking.findMany({ + where: { + status: BookingStatus.PENDING, + paymentStatus: { not: PaymentStatus.SUCCEEDED }, + createdAt: { lt: abandonedCutoff }, + }, + select: { id: true, carbetId: true }, + }); + let bookingsCancelled = 0; + if (abandonedBookings.length > 0) { + const { count } = await prisma.booking.updateMany({ + where: { id: { in: abandonedBookings.map((b) => b.id) } }, + data: { status: BookingStatus.CANCELLED, paymentStatus: PaymentStatus.FAILED }, + }); + bookingsCancelled = count; + } + + // 3. RentalBookings PENDING abandonnés + delete availability associée + const abandonedRentals = await prisma.rentalBooking.findMany({ + where: { + status: RentalBookingStatus.PENDING, + paymentStatus: { not: PaymentStatus.SUCCEEDED }, + createdAt: { lt: abandonedCutoff }, + }, + select: { id: true }, + }); + let rentalsCancelled = 0; + let availabilityFreed = 0; + if (abandonedRentals.length > 0) { + const ids = abandonedRentals.map((r) => r.id); + const [rentalRes, availRes] = await prisma.$transaction([ + prisma.rentalBooking.updateMany({ + where: { id: { in: ids } }, + data: { + status: RentalBookingStatus.CANCELLED, + paymentStatus: PaymentStatus.FAILED, + }, + }), + prisma.rentalItemAvailability.deleteMany({ + where: { rentalBookingId: { in: ids } }, + }), + ]); + rentalsCancelled = rentalRes.count; + availabilityFreed = availRes.count; + } + + await recordAudit({ + scope: "cron", + event: "cron.cleanup.run", + target: null, + actorEmail: "system:cron", + details: { + invitesDeleted, + bookingsCancelled, + rentalsCancelled, + availabilityFreed, + }, + }); + + return NextResponse.json({ + ok: true, + invitesDeleted, + bookingsCancelled, + rentalsCancelled, + availabilityFreed, + }); +} diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts new file mode 100644 index 0000000..96e0573 --- /dev/null +++ b/src/app/api/cron/reminders/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; + +import { + BookingStatus, + RentalBookingStatus, +} from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { isAuthorizedCronRequest } from "@/lib/cron-auth"; +import { sendBookingReminder, sendRentalReminder } from "@/lib/email"; +import { prisma } from "@/lib/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/cron/reminders + * + * Envoie des rappels J-1 (24h avant le début) pour : + * - Booking CONFIRMED dont startDate ∈ [now+22h, now+26h] + * - RentalBooking CONFIRMED idem + * + * Idempotent à l'échelle d'une journée : le filtre temporel narrow limite + * naturellement le risque de double-envoi (en pratique le cron tourne 1× par + * jour à heure fixe). Pour une garantie at-most-once stricte il faudrait + * stocker un flag `reminderSentAt` sur Booking/RentalBooking — défensif + * mais pas critique pour v1. + * + * Auth : Bearer CRON_TOKEN. + */ +export async function GET(req: Request) { + if (!isAuthorizedCronRequest(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const now = new Date(); + const from = new Date(now.getTime() + 22 * 60 * 60 * 1000); + const to = new Date(now.getTime() + 26 * 60 * 60 * 1000); + + const [carbetBookings, rentalBookings] = await Promise.all([ + prisma.booking.findMany({ + where: { + status: BookingStatus.CONFIRMED, + startDate: { gte: from, lt: to }, + }, + include: { + tenant: { select: { email: true, firstName: true } }, + carbet: { select: { title: true, slug: true } }, + }, + }), + prisma.rentalBooking.findMany({ + where: { + status: RentalBookingStatus.CONFIRMED, + startDate: { gte: from, lt: to }, + }, + include: { + tenant: { select: { email: true, firstName: true } }, + provider: { select: { name: true, contactEmail: true, contactPhone: true } }, + }, + }), + ]); + + let bookingSent = 0; + let bookingErrors = 0; + for (const b of carbetBookings) { + if (!b.tenant.email) continue; + try { + await sendBookingReminder( + b.tenant.email, + b.tenant.firstName, + b.id, + b.carbet.title, + b.startDate, + b.carbet.slug, + ); + bookingSent++; + } catch (e) { + bookingErrors++; + console.error( + "[cron.reminders] booking email failed:", + b.id, + e instanceof Error ? e.message : e, + ); + } + } + + let rentalSent = 0; + let rentalErrors = 0; + for (const r of rentalBookings) { + if (!r.tenant.email) continue; + try { + await sendRentalReminder( + r.tenant.email, + r.tenant.firstName, + r.id, + r.provider.name, + r.startDate, + { email: r.provider.contactEmail, phone: r.provider.contactPhone }, + ); + rentalSent++; + } catch (e) { + rentalErrors++; + console.error( + "[cron.reminders] rental email failed:", + r.id, + e instanceof Error ? e.message : e, + ); + } + } + + await recordAudit({ + scope: "cron", + event: "cron.reminders.run", + target: null, + actorEmail: "system:cron", + details: { + window: { from: from.toISOString(), to: to.toISOString() }, + booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors }, + rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors }, + }, + }); + + return NextResponse.json({ + ok: true, + window: { from: from.toISOString(), to: to.toISOString() }, + booking: { candidates: carbetBookings.length, sent: bookingSent, errors: bookingErrors }, + rental: { candidates: rentalBookings.length, sent: rentalSent, errors: rentalErrors }, + }); +} diff --git a/src/app/api/rental-media/[id]/route.ts b/src/app/api/rental-media/[id]/route.ts new file mode 100644 index 0000000..8420d8c --- /dev/null +++ b/src/app/api/rental-media/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { recordAudit } from "@/lib/admin/audit"; +import { prisma } from "@/lib/prisma"; +import { canManageRentalProvider } from "@/lib/rental-access"; + +export const runtime = "nodejs"; + +export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const media = await prisma.rentalItemMedia.findUnique({ + where: { id }, + select: { id: true, itemId: true, item: { select: { providerId: true } } }, + }); + if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 }); + + const allowed = await canManageRentalProvider( + session.user.id, + session.user.role, + media.item.providerId, + session.user.organizationId, + ); + if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + + await prisma.rentalItemMedia.delete({ where: { id } }); + await recordAudit({ + scope: "uploads", + event: "rental.media.delete", + target: id, + actorEmail: session.user.email ?? null, + details: { itemId: media.itemId }, + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/rental-media/reorder/route.ts b/src/app/api/rental-media/reorder/route.ts new file mode 100644 index 0000000..d375aa0 --- /dev/null +++ b/src/app/api/rental-media/reorder/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { recordAudit } from "@/lib/admin/audit"; +import { prisma } from "@/lib/prisma"; +import { canManageRentalProvider } from "@/lib/rental-access"; + +export const runtime = "nodejs"; + +const schema = z.object({ + itemId: z.string().min(1), + orderedIds: z.array(z.string()).min(1).max(50), +}); + +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: "Payload invalide" }, { status: 400 }); + } + const { itemId, orderedIds } = parsed.data; + + const item = await prisma.rentalItem.findUnique({ + where: { id: itemId }, + select: { 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 }); + + const existing = await prisma.rentalItemMedia.findMany({ + where: { itemId, id: { in: orderedIds } }, + select: { id: true }, + }); + if (existing.length !== orderedIds.length) { + return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 }); + } + await prisma.$transaction( + orderedIds.map((id, idx) => + prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }), + ), + ); + + // Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings + const firstId = orderedIds[0]; + const firstMedia = await prisma.rentalItemMedia.findUnique({ + where: { id: firstId }, + select: { s3Url: true, type: true }, + }); + if (firstMedia && firstMedia.type === "PHOTO") { + await prisma.rentalItem.update({ + where: { id: itemId }, + data: { imageUrl: firstMedia.s3Url }, + }); + } + + await recordAudit({ + scope: "uploads", + event: "rental.media.reorder", + target: itemId, + actorEmail: session.user.email ?? null, + details: { count: orderedIds.length }, + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/rentals/[id]/cancel/route.ts b/src/app/api/rentals/[id]/cancel/route.ts new file mode 100644 index 0000000..49aa9ec --- /dev/null +++ b/src/app/api/rentals/[id]/cancel/route.ts @@ -0,0 +1,193 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { + PaymentStatus, + RentalBookingStatus, + UserRole, +} from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { canManageRentalProvider } from "@/lib/rental-access"; +import { sendRentalCancelled } from "@/lib/email"; +import { isStripeConfigured, getStripeClient } from "@/lib/stripe"; +import { prisma } from "@/lib/prisma"; +import { computeRentalRefund } from "@/lib/rental-refund"; + +export const runtime = "nodejs"; + +const CANCELLABLE_STATUSES: RentalBookingStatus[] = [ + RentalBookingStatus.PENDING, + RentalBookingStatus.CONFIRMED, +]; + +type Body = { reason?: string }; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Non authentifié." }, { status: 401 }); + } + const { id } = await params; + const body: Body = await req.json().catch(() => ({})); + const reason = body.reason?.toString().trim().slice(0, 500) ?? null; + + const rb = await prisma.rentalBooking.findUnique({ + where: { id }, + include: { + provider: { select: { id: true, name: true, contactEmail: true, organizationId: true } }, + tenant: { select: { id: true, email: true, firstName: true } }, + lines: { select: { qty: true, item: { select: { name: true } } } }, + }, + }); + if (!rb) { + return NextResponse.json({ error: "Réservation introuvable." }, { status: 404 }); + } + + // Détecte qui annule pour l'auth + l'email : + // - tenant de la booking + // - provider's manager (RENTAL_PROVIDER nominal ou CE_MANAGER de l'org du provider) + // - admin + const role = session.user.role; + const isTenant = rb.tenantId === session.user.id; + const isAdmin = role === UserRole.ADMIN; + const canManage = await canManageRentalProvider( + session.user.id, + role, + rb.providerId, + session.user.organizationId, + ); + const cancelledBy: "tenant" | "provider" | "admin" = isAdmin + ? "admin" + : canManage + ? "provider" + : "tenant"; + + if (!isAdmin && !canManage && !isTenant) { + return NextResponse.json({ error: "Accès refusé." }, { status: 403 }); + } + + if (!CANCELLABLE_STATUSES.includes(rb.status)) { + return NextResponse.json( + { error: `Impossible d'annuler une réservation en statut ${rb.status}.` }, + { status: 409 }, + ); + } + + // Calcule le remboursement selon la politique + const refund = computeRentalRefund({ + startDate: rb.startDate, + itemsTotal: rb.itemsTotal, + depositTotal: rb.depositTotal, + }); + + // Stripe refund best-effort si paiement déjà SUCCEEDED + session Stripe existante + let stripeRefundId: string | null = null; + let stripeRefundError: string | null = null; + if ( + rb.paymentStatus === PaymentStatus.SUCCEEDED && + rb.stripeSessionId && + isStripeConfigured() && + refund.totalRefund.gt(0) + ) { + try { + const stripe = getStripeClient(); + const sess = await stripe.checkout.sessions.retrieve(rb.stripeSessionId, { + expand: ["payment_intent"], + }); + const piId = + typeof sess.payment_intent === "string" + ? sess.payment_intent + : sess.payment_intent?.id; + if (piId) { + const stripeRefund = await stripe.refunds.create({ + payment_intent: piId, + amount: Math.round(Number(refund.totalRefund) * 100), + reason: "requested_by_customer", + }); + stripeRefundId = stripeRefund.id; + } + } catch (e) { + stripeRefundError = e instanceof Error ? e.message : String(e); + console.error("[rental.cancel] Stripe refund failed:", stripeRefundError); + } + } + + // Transaction : update booking + delete availability blocks + await prisma.$transaction([ + prisma.rentalBooking.update({ + where: { id }, + data: { + status: RentalBookingStatus.CANCELLED, + paymentStatus: + rb.paymentStatus === PaymentStatus.SUCCEEDED + ? PaymentStatus.REFUNDED + : PaymentStatus.FAILED, + }, + }), + prisma.rentalItemAvailability.deleteMany({ + where: { rentalBookingId: id }, + }), + ]); + + await recordAudit({ + scope: "rental", + event: "rental.cancel", + target: id, + actorEmail: session.user.email ?? null, + details: { + cancelledBy, + reason, + policy: refund.policy, + itemsRefund: refund.itemsRefund.toString(), + depositRefund: refund.depositRefund.toString(), + totalRefund: refund.totalRefund.toString(), + stripeRefundId, + stripeRefundError, + }, + }); + + // Email best-effort : tenant + provider + try { + await sendRentalCancelled( + rb.tenant.email, + rb.tenant.firstName, + rb.id, + rb.provider.name, + refund.totalRefund.toString(), + rb.currency, + refund.policyLabel, + cancelledBy, + ); + if (rb.provider.contactEmail && cancelledBy !== "provider") { + await sendRentalCancelled( + rb.provider.contactEmail, + rb.provider.name, + rb.id, + rb.provider.name, + refund.totalRefund.toString(), + rb.currency, + refund.policyLabel, + cancelledBy, + ); + } + } catch (e) { + console.error("[rental.cancel] email send failed:", e instanceof Error ? e.message : e); + } + + return NextResponse.json({ + ok: true, + rentalBookingId: id, + refund: { + itemsRefund: refund.itemsRefund.toNumber(), + depositRefund: refund.depositRefund.toNumber(), + totalRefund: refund.totalRefund.toNumber(), + policy: refund.policy, + policyLabel: refund.policyLabel, + }, + stripeRefundId, + stripeRefundError, + }); +} diff --git a/src/app/api/rentals/checkout/route.ts b/src/app/api/rentals/checkout/route.ts new file mode 100644 index 0000000..06fccb6 --- /dev/null +++ b/src/app/api/rentals/checkout/route.ts @@ -0,0 +1,361 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums"; +import { Prisma } from "@/generated/prisma/client"; +import { recordAudit } from "@/lib/admin/audit"; +import { + sendRentalRequestedProvider, + sendRentalRequestedTenant, +} from "@/lib/email"; +import { isPluginEnabled } from "@/lib/plugins/server"; +import { prisma } from "@/lib/prisma"; +import { CART_COOKIE, EMPTY_CART, diffDays, parseCart } from "@/lib/rental-cart"; +import { + getStripeClient, + isStripeConfigured, + toStripeAmountCents, +} from "@/lib/stripe"; + +export const runtime = "nodejs"; + +type LineInput = { + itemId: string; + qty: number; + startDate: Date; + endDate: Date; + nights: number; +}; + +function parseDateOnly(s: string): Date { + return new Date(s + "T00:00:00Z"); +} + +export async function POST() { + if (!(await isPluginEnabled("gear-rental"))) { + return NextResponse.json({ error: "Service de location indisponible." }, { status: 404 }); + } + const session = await auth(); + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Connectez-vous pour finaliser." }, { status: 401 }); + } + + const jar = await cookies(); + const cart = parseCart(jar.get(CART_COOKIE)?.value); + if (cart.items.length === 0) { + return NextResponse.json({ error: "Panier vide." }, { status: 400 }); + } + + // Charge tous les items du panier + const itemIds = Array.from(new Set(cart.items.map((e) => e.itemId))); + const items = await prisma.rentalItem.findMany({ + where: { id: { in: itemIds }, active: true }, + include: { + provider: { + select: { + id: true, + name: true, + active: true, + approved: true, + commissionPct: true, + isSystemD: true, + }, + }, + }, + }); + const itemById = new Map(items.map((i) => [i.id, i])); + + // Validations préliminaires : items valides + provider actif/approved + for (const entry of cart.items) { + const it = itemById.get(entry.itemId); + if (!it) { + return NextResponse.json( + { error: `Item ${entry.itemId} introuvable ou désactivé.` }, + { status: 409 }, + ); + } + if (!it.provider.active || !it.provider.approved) { + return NextResponse.json( + { error: `Prestataire ${it.provider.name} indisponible.` }, + { status: 409 }, + ); + } + if (entry.qty < 1 || entry.qty > it.totalQty) { + return NextResponse.json( + { error: `Quantité invalide pour « ${it.name} ».` }, + { status: 400 }, + ); + } + const start = parseDateOnly(entry.startDate); + const end = parseDateOnly(entry.endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { + return NextResponse.json( + { error: `Dates invalides pour « ${it.name} ».` }, + { status: 400 }, + ); + } + } + + // Groupe par provider + type Group = { + providerId: string; + providerName: string; + commissionPct: number; + lines: LineInput[]; + itemsTotal: Prisma.Decimal; + depositTotal: Prisma.Decimal; + startDate: Date; + endDate: Date; + }; + + const groups = new Map(); + for (const entry of cart.items) { + const it = itemById.get(entry.itemId)!; + const start = parseDateOnly(entry.startDate); + const end = parseDateOnly(entry.endDate); + const nights = Math.max(1, diffDays(entry.startDate, entry.endDate)); + const lineSub = new Prisma.Decimal(it.pricePerDay).mul(entry.qty).mul(nights); + const lineDeposit = new Prisma.Decimal(it.deposit).mul(entry.qty); + + let g = groups.get(it.provider.id); + if (!g) { + g = { + providerId: it.provider.id, + providerName: it.provider.name, + commissionPct: Number(it.provider.commissionPct), + lines: [], + itemsTotal: new Prisma.Decimal(0), + depositTotal: new Prisma.Decimal(0), + startDate: start, + endDate: end, + }; + groups.set(it.provider.id, g); + } + g.lines.push({ itemId: it.id, qty: entry.qty, startDate: start, endDate: end, nights }); + g.itemsTotal = g.itemsTotal.add(lineSub); + g.depositTotal = g.depositTotal.add(lineDeposit); + if (start < g.startDate) g.startDate = start; + if (end > g.endDate) g.endDate = end; + } + + // Transaction : recheck stock + crée RentalBookings + Lines + Availabilities + let grandTotal = new Prisma.Decimal(0); + let grandDeposit = new Prisma.Decimal(0); + let rentalBookingIds: string[] = []; + + try { + rentalBookingIds = await prisma.$transaction(async (tx) => { + const created: string[] = []; + + for (const g of groups.values()) { + // Recheck stock disponible pour chaque ligne + for (const line of g.lines) { + const blocked = await tx.rentalItemAvailability.aggregate({ + where: { + itemId: line.itemId, + startDate: { lt: line.endDate }, + endDate: { gt: line.startDate }, + }, + _sum: { qty: true }, + }); + const item = itemById.get(line.itemId)!; + const used = Number(blocked._sum.qty ?? 0); + const free = item.totalQty - used; + if (line.qty > free) { + throw new Error(`Stock insuffisant pour « ${item.name} » sur les dates demandées (libre: ${free}).`); + } + } + + const commissionAmount = g.itemsTotal + .mul(g.commissionPct) + .div(100) + .toDecimalPlaces(2); + const amount = g.itemsTotal.add(g.depositTotal).toDecimalPlaces(2); + + const rb = await tx.rentalBooking.create({ + data: { + tenantId: session.user!.id!, + providerId: g.providerId, + startDate: g.startDate, + endDate: g.endDate, + status: RentalBookingStatus.PENDING, + paymentStatus: PaymentStatus.PENDING, + itemsTotal: g.itemsTotal.toDecimalPlaces(2), + depositTotal: g.depositTotal.toDecimalPlaces(2), + commissionAmount, + amount, + currency: "EUR", + lines: { + create: g.lines.map((line) => { + const item = itemById.get(line.itemId)!; + const lineTotal = new Prisma.Decimal(item.pricePerDay) + .mul(line.qty) + .mul(line.nights) + .toDecimalPlaces(2); + return { + itemId: line.itemId, + qty: line.qty, + pricePerDay: new Prisma.Decimal(item.pricePerDay), + deposit: new Prisma.Decimal(item.deposit), + lineTotal, + }; + }), + }, + }, + select: { id: true }, + }); + + // Bloque les dispos + for (const line of g.lines) { + await tx.rentalItemAvailability.create({ + data: { + itemId: line.itemId, + startDate: line.startDate, + endDate: line.endDate, + qty: line.qty, + reason: "RENTAL_BOOKING", + rentalBookingId: rb.id, + }, + }); + } + + created.push(rb.id); + grandTotal = grandTotal.add(g.itemsTotal); + grandDeposit = grandDeposit.add(g.depositTotal); + } + + return created; + }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Erreur lors de la création." }, + { status: 409 }, + ); + } + + const totalAmount = grandTotal.add(grandDeposit).toDecimalPlaces(2); + + await recordAudit({ + scope: "rental", + event: "rental.checkout.created", + target: rentalBookingIds.join(","), + actorEmail: session.user.email, + details: { + rentalBookingIds, + amount: totalAmount.toNumber(), + depositTotal: grandDeposit.toNumber(), + providers: Array.from(groups.keys()), + }, + }); + + // Emails best-effort : 1 mail au locataire (récap par prestataire) + 1 mail + // à chaque prestataire (sa demande). En cas d'échec d'envoi, on ne bloque pas. + try { + const fullBookings = await prisma.rentalBooking.findMany({ + where: { id: { in: rentalBookingIds } }, + include: { + provider: { select: { name: true, contactEmail: true } }, + lines: { include: { item: { select: { name: true } } } }, + }, + }); + const tenantName = session.user.name ?? session.user.email!; + for (const rb of fullBookings) { + const lineSummary = rb.lines.map((l) => ({ qty: l.qty, itemName: l.item.name })); + await sendRentalRequestedTenant( + session.user.email!, + tenantName, + rb.id, + rb.provider.name, + rb.startDate, + rb.endDate, + rb.amount.toString(), + rb.currency, + lineSummary, + ); + if (rb.provider.contactEmail) { + await sendRentalRequestedProvider( + rb.provider.contactEmail, + rb.provider.name, + rb.id, + tenantName, + rb.startDate, + rb.endDate, + lineSummary, + ); + } + } + } catch (e) { + console.error("[rental.checkout] email send failed:", e instanceof Error ? e.message : e); + } + + // Vide le panier + jar.set(CART_COOKIE, JSON.stringify(EMPTY_CART), { + httpOnly: false, + sameSite: "lax", + path: "/", + maxAge: 0, + }); + + // Stripe ou paiement différé + if (!isStripeConfigured()) { + return NextResponse.json( + { rentalBookingIds, totalAmount: totalAmount.toNumber() }, + { status: 201 }, + ); + } + + const appUrl = process.env.APP_URL; + if (!appUrl) { + return NextResponse.json({ error: "APP_URL manquante." }, { status: 500 }); + } + + // Une session Stripe avec une ligne par RentalBooking (agrégée) + const stripe = getStripeClient(); + const bookingDetails = await prisma.rentalBooking.findMany({ + where: { id: { in: rentalBookingIds } }, + include: { + provider: { select: { name: true } }, + lines: { select: { qty: true, item: { select: { name: true } } } }, + }, + }); + + const line_items = bookingDetails.map((rb) => ({ + quantity: 1, + price_data: { + currency: "eur", + unit_amount: toStripeAmountCents(Number(rb.amount)), + product_data: { + name: `Matériel — ${rb.provider.name}`, + description: rb.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ").slice(0, 500), + }, + }, + })); + + const checkoutSession = await stripe.checkout.sessions.create({ + mode: "payment", + success_url: `${appUrl}/mes-locations?payment=success&ids=${rentalBookingIds.join(",")}`, + cancel_url: `${appUrl}/panier?payment=cancel`, + customer_email: session.user.email, + line_items, + metadata: { + type: "rental-bundle", + rentalBookingIds: rentalBookingIds.join(","), + }, + }); + + await prisma.rentalBooking.updateMany({ + where: { id: { in: rentalBookingIds } }, + data: { stripeSessionId: checkoutSession.id }, + }); + + return NextResponse.json( + { + rentalBookingIds, + totalAmount: totalAmount.toNumber(), + checkoutSessionId: checkoutSession.id, + checkoutUrl: checkoutSession.url, + }, + { status: 201 }, + ); +} diff --git a/src/app/api/rentals/items/[id]/availability/route.ts b/src/app/api/rentals/items/[id]/availability/route.ts new file mode 100644 index 0000000..dc3b8b2 --- /dev/null +++ b/src/app/api/rentals/items/[id]/availability/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { getItemAvailability } from "@/lib/rentals-public"; +import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + const from = parseIsoDate(req.nextUrl.searchParams.get("from")); + const to = parseIsoDate(req.nextUrl.searchParams.get("to")); + if (!from || !to) { + return NextResponse.json( + { error: "Paramètres from et to (YYYY-MM-DD) requis." }, + { status: 400 }, + ); + } + const start = normalizeUtcDayStart(from); + const end = normalizeUtcDayStart(to); + if (end <= start) { + return NextResponse.json({ error: "to doit être > from." }, { status: 400 }); + } + const calendar = await getItemAvailability(id, start, end); + return NextResponse.json({ + itemId: id, + from: start.toISOString(), + to: end.toISOString(), + calendar, + }); +} diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 1ded993..739bf1b 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -2,11 +2,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { UserRole } from "@/generated/prisma/enums"; +import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites"; import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; -import { sendSignupWelcome } from "@/lib/email"; +import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email"; import { rateLimitRequest } from "@/lib/rate-limit"; +import { slugify } from "@/lib/slug"; export const runtime = "nodejs"; @@ -16,11 +18,16 @@ 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, UserRole.CE_MANAGER]) + .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(), + orgName: z.string().trim().min(2).max(200).optional(), + inviteToken: z.string().trim().min(8).max(200).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,35 +50,150 @@ 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 }); + } + if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) { + return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 }); + } + + // Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER + // et on rattache à l'org du token (org déjà validée — pas de bannière pending). + let inviteOrgId: string | null = null; + if (data.inviteToken) { + const invite = await getOrgInviteByToken(data.inviteToken); + if (!invite) { + return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 }); + } + if (invite.email && invite.email.toLowerCase() !== data.email) { + return NextResponse.json( + { error: "Ce lien d'invitation est réservé à un autre email." }, + { status: 400 }, + ); + } + inviteOrgId = invite.organizationId; + } + 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 }); } const passwordHash = await hashPassword(data.password); - const user = await prisma.user.create({ - data: { - email: data.email, - passwordHash, - firstName: data.firstName, - lastName: data.lastName, - phone: data.phone?.trim() || null, - role: data.role, - isActive: true, - }, - select: { id: true, email: true, role: true }, - }); + + // CE_MANAGER : transaction atomique User + Organization. Le slug est unique + // sur Organization → on retente avec un suffixe en cas de collision. + let createdProviderId: string | null = null; + let createdOrgId: string | null = null; + let user: { id: string; email: string; role: UserRole }; + + if (inviteOrgId) { + // Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role. + user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone?.trim() || null, + role: UserRole.CE_MEMBER, + organizationId: inviteOrgId, + isActive: true, + }, + select: { id: true, email: true, role: true }, + }); + createdOrgId = inviteOrgId; + await markOrgInviteConsumed(data.inviteToken!).catch(() => {}); + } else if (data.role === UserRole.CE_MANAGER) { + const orgName = data.orgName!.trim(); + const baseSlug = slugify(orgName); + const result = await prisma.$transaction(async (tx) => { + // Trouve un slug libre + let candidate = baseSlug || "ce"; + let suffix = 1; + for (;;) { + const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } }); + if (!exists) break; + suffix += 1; + candidate = `${baseSlug}-${suffix}`; + } + // candidate now holds a free slug + const org = await tx.organization.create({ + data: { + name: orgName, + slug: candidate, + contactEmail: data.email, + approved: false, + }, + select: { id: true }, + }); + const u = await tx.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone?.trim() || null, + role: UserRole.CE_MANAGER, + organizationId: org.id, + isActive: true, + }, + select: { id: true, email: true, role: true }, + }); + return { user: u, orgId: org.id }; + }); + user = result.user; + createdOrgId = result.orgId; + sendNewCeRequest(orgName, user.email).catch(() => {}); + } else { + user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone?.trim() || null, + role: data.role, + isActive: true, + }, + select: { id: true, email: true, role: true }, + }); + + // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation. + 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, organizationId: createdOrgId }, }); - // 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, + organizationId: createdOrgId, + }); } diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 1e4296f..6a896ec 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -4,9 +4,11 @@ import Stripe from "stripe"; import { BookingStatus, PaymentStatus, + RentalBookingStatus, SubscriptionStatus, } from "@/generated/prisma/enums"; import { refreshCarbetLastBookedAt } from "@/lib/carbet-last-booked"; +import { sendRentalConfirmed } from "@/lib/email"; import { prisma } from "@/lib/prisma"; import { fromStripeTimestamp, getStripeClient } from "@/lib/stripe"; @@ -51,6 +53,43 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { return; } + if (type === "rental-bundle") { + const idsRaw = session.metadata?.rentalBookingIds; + if (!idsRaw) return; + const ids = idsRaw.split(",").map((s) => s.trim()).filter(Boolean); + if (ids.length === 0) return; + await prisma.rentalBooking.updateMany({ + where: { id: { in: ids } }, + data: { + paymentStatus: PaymentStatus.SUCCEEDED, + status: RentalBookingStatus.CONFIRMED, + }, + }); + try { + const rentals = await prisma.rentalBooking.findMany({ + where: { id: { in: ids } }, + include: { + provider: { select: { name: true } }, + tenant: { select: { email: true, firstName: true } }, + }, + }); + for (const rb of rentals) { + if (!rb.tenant.email) continue; + await sendRentalConfirmed( + rb.tenant.email, + rb.tenant.firstName ?? rb.tenant.email, + rb.id, + rb.provider.name, + rb.startDate, + rb.endDate, + ); + } + } catch (e) { + console.error("[webhook.rental] email send failed:", e instanceof Error ? e.message : e); + } + return; + } + if (type === "owner_subscription") { const ownerId = session.metadata?.ownerId; const carbetId = session.metadata?.carbetId; @@ -79,6 +118,27 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { async function handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent) { const bookingId = paymentIntent.metadata?.bookingId; + const rentalIdsRaw = paymentIntent.metadata?.rentalBookingIds; + + if (rentalIdsRaw) { + const ids = rentalIdsRaw.split(",").map((s) => s.trim()).filter(Boolean); + if (ids.length > 0) { + // Marque les paiements échoués + libère les blocages de dispo + await prisma.$transaction([ + prisma.rentalBooking.updateMany({ + where: { id: { in: ids } }, + data: { + paymentStatus: PaymentStatus.FAILED, + status: RentalBookingStatus.CANCELLED, + }, + }), + prisma.rentalItemAvailability.deleteMany({ + where: { rentalBookingId: { in: ids } }, + }), + ]); + } + } + if (!bookingId) { return; } diff --git a/src/app/api/uploads/rental-finalize/route.ts b/src/app/api/uploads/rental-finalize/route.ts new file mode 100644 index 0000000..befc16f --- /dev/null +++ b/src/app/api/uploads/rental-finalize/route.ts @@ -0,0 +1,105 @@ +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 }); +} diff --git a/src/app/api/uploads/rental-presign/route.ts b/src/app/api/uploads/rental-presign/route.ts new file mode 100644 index 0000000..73244e0 --- /dev/null +++ b/src/app/api/uploads/rental-presign/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { canManageRentalProvider } from "@/lib/rental-access"; +import { rateLimitRequest } from "@/lib/rate-limit"; +import { presignRentalItemUpload } from "@/lib/uploads"; + +export const runtime = "nodejs"; + +const schema = z.object({ + itemId: z.string().min(1), + mime: z.string().min(3).max(100), + sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024), +}); + +export async function POST(req: Request) { + const rl = rateLimitRequest(req, "rental-presign", 60_000, 60); + if (!rl.ok) { + return NextResponse.json( + { error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` }, + { status: 429 }, + ); + } + 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 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 }); + } + + const result = await presignRentalItemUpload({ + itemId: item.id, + mime: parsed.data.mime, + sizeBytes: parsed.data.sizeBytes, + }); + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + return NextResponse.json(result); +} diff --git a/src/app/carbets/[slug]/_components/CompleteYourStay.tsx b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx new file mode 100644 index 0000000..a1b8b42 --- /dev/null +++ b/src/app/carbets/[slug]/_components/CompleteYourStay.tsx @@ -0,0 +1,105 @@ +import Link from "next/link"; + +import { isPluginEnabled } from "@/lib/plugins/server"; +import { prisma } from "@/lib/prisma"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +type Props = { + river: string; + capacity: number; +}; + +const EMOJI: Record = { + SLEEP: "💤", + NAVIGATION: "🛶", + FISHING: "🎣", + COOKING: "🍳", + SAFETY: "🦺", +}; + +export async function CompleteYourStay({ river, capacity }: Props) { + if (!(await isPluginEnabled("gear-rental"))) return null; + const providers = await prisma.rentalProvider.findMany({ + where: { + active: true, + approved: true, + OR: [ + { isSystemD: true }, + { rivers: { has: river } }, + ], + }, + select: { + id: true, + items: { + where: { active: true }, + orderBy: [{ category: "asc" }, { pricePerDay: "asc" }], + take: 24, + select: { + id: true, + name: true, + category: true, + imageUrl: true, + pricePerDay: true, + provider: { select: { name: true, isSystemD: true } }, + }, + }, + }, + }); + + const items = providers.flatMap((p) => p.items).slice(0, 9); + if (items.length === 0) return null; + + return ( +
+
+
+

+ Compléter votre séjour +

+

+ Pour {capacity} voyageur{capacity > 1 ? "s" : ""} sur le {river}, + pensez à louer hamacs, moustiquaires, pirogue ou kayak auprès des + prestataires locaux. +

+
+ + Voir tout → + +
+ +
    + {items.map((it) => ( +
  • + +
    + {it.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {it.name} + ) : ( + {EMOJI[it.category] ?? "🎒"} + )} +
    +
    +

    {it.name}

    +
    + {RENTAL_CATEGORY_LABEL[it.category]} + + {Number(it.pricePerDay).toFixed(0)} €/j + +
    + {it.provider.isSystemD ? ( + + Karbé + + ) : null} +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index ae53374..dbaeeaf 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -15,6 +15,7 @@ import { formatAverageRating } from "@/lib/reviews"; import { isStripeConfigured } from "@/lib/stripe"; import { BookingForm } from "../_components/booking-form"; +import { CompleteYourStay } from "./_components/CompleteYourStay"; import { CarbetGallery } from "../_components/carbet-gallery"; import { CarbetMap } from "../_components/carbet-map"; import { ReviewsSection } from "../_components/reviews-section"; @@ -114,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) { ? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}` : ` · Route directe (embarquement ${carbet.embarkPoint})`}

+ {carbet.organizations.length > 0 ? ( +

+ Géré par le CE{" "} + {carbet.organizations.map((o, i) => ( + + {o.name} + {i < carbet.organizations.length - 1 ? ", " : ""} + + ))} +

+ ) : null} {carbet.reviewStats.count > 0 && carbet.reviewStats.averageRating !== null ? (

@@ -277,6 +289,8 @@ export default async function PublicCarbetPage({ params }: PageProps) { + + setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))} - className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5" + inputMode="numeric" + className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base" /> @@ -215,7 +216,7 @@ export function BookingForm({ type="button" onClick={submit} disabled={!canSubmit} - className="w-full rounded-md bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" + className="w-full min-h-[44px] rounded-md bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50" > {busy ? "Envoi…" diff --git a/src/app/espace-ce/analytics/page.tsx b/src/app/espace-ce/analytics/page.tsx new file mode 100644 index 0000000..c9fc2be --- /dev/null +++ b/src/app/espace-ce/analytics/page.tsx @@ -0,0 +1,95 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart"; +import { getCarbetsOccupancy, getMonthlyRevenueSeries } from "@/lib/analytics"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Analytics CE — Karbé" }; + +function fmtEur(n: number): string { + return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); +} + +export default async function CeAnalyticsPage() { + const org = await getCurrentCeOrganization(); + if (!org) redirect("/admin/organizations"); + + const [series, occupancy] = await Promise.all([ + getMonthlyRevenueSeries({ organizationId: org.id, monthsBack: 12 }), + getCarbetsOccupancy({ organizationId: org.id, monthsBack: 3 }), + ]); + + const total12m = series.reduce((s, p) => s + p.total, 0); + const totalCarbet12m = series.reduce((s, p) => s + p.carbetRevenue, 0); + const totalRental12m = series.reduce((s, p) => s + p.rentalRevenue, 0); + + return ( +

+
+ + ← Tableau de bord CE + +

+ Analytics — {org.name} +

+

+ Chiffre d'affaires des 12 derniers mois et taux d'occupation des carbets co-gérés. +

+
+ +
+ + + +
+ +
+

+ Chiffre d'affaires mensuel +

+ +
+ +
+

+ Taux d'occupation des carbets (3 derniers mois) +

+ {occupancy.length === 0 ? ( +

Pas encore de carbet publié.

+ ) : ( +
    + {occupancy.map((c) => ( +
  • +
    + + {c.title} + + + {c.occupancyPct} % ({c.bookedNights}/{c.totalNights} nuits) + +
    +
    +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/espace-ce/carbets/[carbetId]/page.tsx b/src/app/espace-ce/carbets/[carbetId]/page.tsx new file mode 100644 index 0000000..08c8c21 --- /dev/null +++ b/src/app/espace-ce/carbets/[carbetId]/page.tsx @@ -0,0 +1,126 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { MediaUploader } from "@/components/MediaUploader"; +import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { prisma } from "@/lib/prisma"; + +import { updateCarbet } from "../../../espace-hote/carbets/actions"; +import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form"; + +export const dynamic = "force-dynamic"; + +export default async function EditCeCarbetPage({ + params, + searchParams, +}: { + params: Promise<{ carbetId: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const session = await requireOwnerSession(); + const org = await getCurrentCeOrganization(); + if (!org) redirect("/admin/organizations"); + const { carbetId } = await params; + const { publishError } = await searchParams; + + const carbet = await prisma.carbet.findUnique({ + where: { id: carbetId }, + select: { + id: true, + ownerId: true, + title: true, + description: true, + river: true, + latitude: true, + longitude: true, + embarkPoint: true, + pirogueDurationMin: true, + capacity: true, + roadAccess: true, + electricity: true, + gsmAtCarbet: true, + gsmExitDistanceKm: true, + status: true, + media: { + orderBy: { sortOrder: "asc" }, + select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true }, + }, + amenities: { select: { amenity: { select: { key: true } } } }, + organizations: { select: { organizationId: true } }, + }, + }); + + if ( + !carbet || + !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) + ) { + notFound(); + } + + // Sécurité supplémentaire : assure que le carbet est bien lié à l'org du user. + // (Un ADMIN peut éditer n'importe quel carbet via /admin, pas via /espace-ce.) + const isLinked = carbet.organizations.some((o) => o.organizationId === org.id); + if (!isLinked && session.user.role !== "ADMIN") { + notFound(); + } + + const defaults = { + title: carbet.title, + description: carbet.description, + river: carbet.river, + latitude: carbet.latitude.toString(), + longitude: carbet.longitude.toString(), + embarkPoint: carbet.embarkPoint, + pirogueDurationMin: String(carbet.pirogueDurationMin), + capacity: String(carbet.capacity), + roadAccess: carbet.roadAccess ?? "", + electricity: carbet.electricity ?? "", + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "", + status: carbet.status, + amenityKeys: carbet.amenities.map((entry) => entry.amenity.key), + }; + + return ( +
+ + ← Carbets de {org.name} + +

+ {carbet.title} +

+

+ Co-géré par {org.name} +

+ + {publishError ? ( +

+ Ajoutez au moins un média avant de publier ce carbet. +

+ ) : null} + +
+

Médias

+

+ Déposez photos et vidéos courtes, réorganisez par glisser-déposer. + Le premier média sert de cover sur le catalogue. +

+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/espace-ce/carbets/nouveau/page.tsx b/src/app/espace-ce/carbets/nouveau/page.tsx new file mode 100644 index 0000000..c168cd3 --- /dev/null +++ b/src/app/espace-ce/carbets/nouveau/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { requireApprovedOrg } from "@/lib/ce-access"; + +import { createCarbet } from "../../../espace-hote/carbets/actions"; +import { CarbetForm } from "../../../espace-hote/carbets/_components/carbet-form"; + +export const dynamic = "force-dynamic"; + +export default async function NewCeCarbetPage() { + // Bloque la création si l'org n'est pas validée — redirect vers dashboard + // avec bannière « En attente de validation ». + const org = await requireApprovedOrg(); + if (!org) redirect("/espace-ce"); + + return ( +
+ + ← Carbets de {org.name} + +

+ Nouveau carbet CE +

+

+ Ce carbet sera automatiquement lié à {org.name} et co-géré + par tous ses CE_MANAGERs. Vous ajouterez les médias après la création. +

+ +
+ +
+
+ ); +} diff --git a/src/app/espace-ce/carbets/page.tsx b/src/app/espace-ce/carbets/page.tsx new file mode 100644 index 0000000..d4d9f6d --- /dev/null +++ b/src/app/espace-ce/carbets/page.tsx @@ -0,0 +1,163 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { CarbetStatus } from "@/generated/prisma/enums"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { prisma } from "@/lib/prisma"; + +import { + deleteCarbet, + setCarbetStatus, +} from "../../espace-hote/carbets/actions"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Mes carbets CE — Karbé" }; + +const STATUS_LABELS: Record = { + [CarbetStatus.DRAFT]: "Brouillon", + [CarbetStatus.PUBLISHED]: "Publié", + [CarbetStatus.ARCHIVED]: "Archivé", +}; + +const STATUS_STYLES: Record = { + [CarbetStatus.DRAFT]: "bg-zinc-100 text-zinc-700", + [CarbetStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800", + [CarbetStatus.ARCHIVED]: "bg-amber-100 text-amber-800", +}; + +export default async function CeCarbetsListPage() { + const org = await getCurrentCeOrganization(); + if (!org) redirect("/admin/organizations"); + + const memberships = await prisma.organizationCarbetMembership.findMany({ + where: { organizationId: org.id }, + orderBy: { addedAt: "desc" }, + select: { + carbet: { + select: { + id: true, + title: true, + river: true, + status: true, + updatedAt: true, + ownerId: true, + owner: { select: { firstName: true, lastName: true } }, + _count: { select: { media: true } }, + }, + }, + }, + }); + const carbets = memberships.map((m) => m.carbet); + + return ( +
+
+
+ + ← Tableau de bord CE + +

+ Carbets co-gérés par {org.name} +

+

+ Les carbets visibles ici peuvent être édités par tous les CE_MANAGERs de votre + organisation. La propriété nominale reste sur leur créateur initial. +

+
+ {org.approved ? ( + + Nouveau carbet + + ) : ( + + Publication bloquée : organisation en attente de validation + + )} +
+ + {carbets.length === 0 ? ( +

+ Votre CE n'a pas encore de carbet.{" "} + {org.approved ? "Créez votre premier carbet pour démarrer." : "Vous pourrez en publier dès que votre organisation sera validée."} +

+ ) : ( +
    + {carbets.map((carbet) => ( +
  • +
    +
    + + {carbet.title} + + + {STATUS_LABELS[carbet.status]} + +
    +

    + {carbet.river} · {carbet._count.media} média{carbet._count.media > 1 ? "s" : ""} + {" · "}créé par {carbet.owner.firstName} {carbet.owner.lastName} +

    +
    + +
    + + Éditer + + + {org.approved && carbet.status !== CarbetStatus.PUBLISHED ? ( +
    + + + +
    + ) : null} + + {carbet.status === CarbetStatus.PUBLISHED ? ( +
    + + + +
    + ) : null} + +
    + + +
    +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/espace-ce/layout.tsx b/src/app/espace-ce/layout.tsx new file mode 100644 index 0000000..75cdc1c --- /dev/null +++ b/src/app/espace-ce/layout.tsx @@ -0,0 +1,8 @@ +import { requirePluginOr404 } from "@/lib/plugins/guard"; +import { requireCeManagerSession } from "@/lib/ce-access"; + +export default async function CeLayout({ children }: { children: React.ReactNode }) { + await requirePluginOr404("ce-management"); + await requireCeManagerSession(); + return <>{children}; +} diff --git a/src/app/espace-ce/materiel/actions.ts b/src/app/espace-ce/materiel/actions.ts new file mode 100644 index 0000000..959d9f3 --- /dev/null +++ b/src/app/espace-ce/materiel/actions.ts @@ -0,0 +1,66 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { prisma } from "@/lib/prisma"; + +/** + * Active la location matériel pour un CE : crée le RentalProvider lié à son + * organizationId. Approuvé automatiquement si l'org elle-même est approuvée. + * - Si un provider existe déjà pour cette org : redirige sans rien créer. + * - Bloque si l'org n'est pas validée (la création doit attendre l'approval). + */ +export async function activateRentalProviderForCeAction(): Promise { + const session = await auth(); + if (!session?.user?.id) redirect("/connexion?next=/espace-ce/materiel"); + if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) { + redirect("/"); + } + const org = await getCurrentCeOrganization(); + if (!org) redirect("/espace-ce"); + if (!org.approved) { + // L'org doit être validée avant activation. La page affichera la bannière. + redirect("/espace-ce/materiel?activateError=pending"); + } + + const existing = await prisma.rentalProvider.findFirst({ + where: { organizationId: org.id }, + select: { id: true }, + }); + if (existing) { + redirect("/espace-ce/materiel"); + } + + const created = await prisma.rentalProvider.create({ + data: { + name: `Matériel — ${org.name}`, + isSystemD: false, + managedByUserId: session.user.id, + organizationId: org.id, + contactEmail: org.contactEmail, + rivers: [], + commissionPct: 10, + active: true, + approved: true, + approvedAt: new Date(), + approvedBy: session.user.email ?? "system", + }, + select: { id: true, name: true }, + }); + + await recordAudit({ + scope: "ce", + event: "ce.rental_provider.activate", + target: created.id, + actorEmail: session.user.email ?? null, + details: { organizationId: org.id, name: created.name }, + }); + + revalidatePath("/espace-ce/materiel"); + redirect("/espace-ce/materiel"); +} diff --git a/src/app/espace-ce/materiel/items/[itemId]/page.tsx b/src/app/espace-ce/materiel/items/[itemId]/page.tsx new file mode 100644 index 0000000..2d406a9 --- /dev/null +++ b/src/app/espace-ce/materiel/items/[itemId]/page.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { MediaUploader } from "@/components/MediaUploader"; +import { + getCurrentRentalProvider, + requireRentalProviderSession, +} from "@/lib/rental-access"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; +import { getHostItem } from "@/lib/rental-host"; + +import { + addItemBlockAction, + deleteHostItemAction, + removeItemBlockAction, + updateHostItemAction, +} from "../../../../espace-prestataire/actions"; +import { HostItemForm } from "../../../../espace-prestataire/items/_components/ItemForm"; +import { ItemBlocksManager } from "../../../../espace-prestataire/items/[itemId]/_components/ItemBlocksManager"; +import { ItemInlineDelete } from "../../../../espace-prestataire/items/[itemId]/_components/ItemInlineDelete"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ itemId: string }> }; + +export default async function EditCeItemPage({ params }: PageProps) { + await requireRentalProviderSession(); + const provider = await getCurrentRentalProvider(); + if (!provider) redirect("/espace-ce/materiel"); + 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 +

+
+ +
+ +
+

+ Photos & vidéos +

+ +
+ +
+ +
+ +
+

+ 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-ce/materiel/items/new/page.tsx b/src/app/espace-ce/materiel/items/new/page.tsx new file mode 100644 index 0000000..8a6d468 --- /dev/null +++ b/src/app/espace-ce/materiel/items/new/page.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; + +import { requireRentalProviderSession } from "@/lib/rental-access"; + +import { createHostItemAction } from "../../../../espace-prestataire/actions"; +import { HostItemForm } from "../../../../espace-prestataire/items/_components/ItemForm"; + +export const dynamic = "force-dynamic"; + +export default async function NewCeItemPage() { + await requireRentalProviderSession(); + return ( +
+ + ← Mes items + +

Nouvel item

+
+ +
+
+ ); +} diff --git a/src/app/espace-ce/materiel/items/page.tsx b/src/app/espace-ce/materiel/items/page.tsx new file mode 100644 index 0000000..68be691 --- /dev/null +++ b/src/app/espace-ce/materiel/items/page.tsx @@ -0,0 +1,109 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; +import { + getCurrentRentalProvider, + requireRentalProviderSession, +} from "@/lib/rental-access"; +import { listHostItems } from "@/lib/rental-host"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Items rental CE — Karbé" }; + +export default async function CeMaterielItemsPage() { + await requireRentalProviderSession(); + const provider = await getCurrentRentalProvider(); + // Sans provider activé → renvoie sur l'onboarding /espace-ce/materiel + if (!provider) redirect("/espace-ce/materiel"); + + const items = await listHostItems(provider.id); + + return ( +
+
+
+ + ← Dashboard matériel CE + +

+ Items locables — {provider.name} +

+

+ {items.length} item{items.length > 1 ? "s" : ""} +

+
+ + + Nouvel item + +
+ + {items.length === 0 ? ( +
+ Pas encore d'item.{" "} + + Créer mon premier item + +
+ ) : ( +
+ + + + + + + + + + + + + + {items.map((i) => ( + + + + + + + + + + ))} + +
NomCatégorie€/jStockCautionRésaÉtat
+ + {i.name} + +
+ {i.withMotor ? "⚙️ moteur · " : ""} + {i.requiresLicense ? "🪪 permis · " : ""} + {i.fuelIncluded ? "⛽ essence " : ""} +
+
{RENTAL_CATEGORY_LABEL[i.category]} + {Number(i.pricePerDay).toFixed(0)} + {i.totalQty} + {Number(i.deposit).toFixed(0)} + {i._count.lines} + {i.active ? ( + + Actif + + ) : ( + + Inactif + + )} +
+
+ )} +
+ ); +} diff --git a/src/app/espace-ce/materiel/page.tsx b/src/app/espace-ce/materiel/page.tsx new file mode 100644 index 0000000..2c13ead --- /dev/null +++ b/src/app/espace-ce/materiel/page.tsx @@ -0,0 +1,152 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { isPluginEnabled } from "@/lib/plugins/server"; +import { + getCurrentRentalProviderForCe, +} from "@/lib/rental-access"; +import { getHostRentalKpis } from "@/lib/rental-host"; + +import { activateRentalProviderForCeAction } from "./actions"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Matériel CE — Karbé" }; + +function fmtEur(amount: string | number): string { + return Number(amount).toLocaleString("fr-FR", { style: "currency", currency: "EUR" }); +} + +export default async function CeMaterielPage() { + // Soft dependency : si le plugin gear-rental est off, on masque /espace-ce/materiel + // (le bouton du dashboard a déjà été désactivé côté UX). + if (!(await isPluginEnabled("gear-rental"))) { + return ( +
+

Matériel rental

+

+ La marketplace location matériel n'est pas activée. Activez le plugin + gear-rental{" "} + dans /admin/plugins. +

+
+ ); + } + + const org = await getCurrentCeOrganization(); + if (!org) redirect("/admin/organizations"); + + const provider = await getCurrentRentalProviderForCe(org.id); + + // Onboarding : pas encore de provider activé + if (!provider) { + return ( +
+ + ← Tableau de bord CE + +

Matériel rental

+

+ Activez la location matériel pour proposer hamacs, kayaks, pirogues, etc. à vos + membres et au public touriste. Le provider sera créé au nom de votre CE. +

+ + {!org.approved ? ( +

+ 🕒 Votre organisation est en attente de validation. La location matériel sera + activable dès qu'un admin Karbé aura validé votre CE. +

+ ) : ( +
+ +

+ Vous pourrez ensuite ajouter vos items (hamac, pirogue, kayak…). Commission + par défaut : 10 % (ajustable par un admin Karbé). +

+
+ )} +
+ ); + } + + // Provider existant : dashboard + KPIs + const kpis = await getHostRentalKpis(provider.id); + + return ( +
+
+ + ← Tableau de bord CE + +

+ Matériel rental — {provider.name} +

+

+ Commission Karbé : {Number(provider.commissionPct).toFixed(1)} % · Géré par {org.name} +

+
+ +
+ + + + +
+ +
+ 0 + ? `${kpis.itemsActive} item${kpis.itemsActive > 1 ? "s" : ""} en location.` + : "Ajoutez votre premier item (hamac, kayak, pirogue…)." + } + /> + 0 + ? `${kpis.bookingsPending} demande${kpis.bookingsPending > 1 ? "s" : ""} à préparer.` + : "Suivez vos réservations en cours, à préparer et terminées." + } + /> +
+
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ActionCard({ + href, + title, + description, +}: { + href: string; + title: string; + description: string; +}) { + return ( + +

{title}

+

{description}

+ + ); +} diff --git a/src/app/espace-ce/materiel/reservations/page.tsx b/src/app/espace-ce/materiel/reservations/page.tsx new file mode 100644 index 0000000..6a1bfdc --- /dev/null +++ b/src/app/espace-ce/materiel/reservations/page.tsx @@ -0,0 +1,150 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { RentalBookingStatus } from "@/generated/prisma/enums"; +import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings"; +import { + getCurrentRentalProvider, + requireRentalProviderSession, +} from "@/lib/rental-access"; +import { listHostBookings } from "@/lib/rental-host"; + +import { BookingDecision } from "../../../espace-prestataire/reservations/_components/BookingDecision"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Réservations matériel CE — Karbé" }; + +const STATUS_VALUES = new Set([ + RentalBookingStatus.PENDING, + RentalBookingStatus.CONFIRMED, + RentalBookingStatus.HANDED_OVER, + RentalBookingStatus.RETURNED, + RentalBookingStatus.CANCELLED, +]); + +type PageProps = { + searchParams: Promise<{ status?: string }>; +}; + +const dateFmt = new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "2-digit", +}); + +export default async function CeReservationsPage({ searchParams }: PageProps) { + await requireRentalProviderSession(); + const provider = await getCurrentRentalProvider(); + if (!provider) redirect("/espace-ce/materiel"); + const sp = await searchParams; + const status = STATUS_VALUES.has(sp.status ?? "") + ? (sp.status as RentalBookingStatus) + : undefined; + + const bookings = await listHostBookings(provider.id, { status }); + + return ( +
+
+
+ + ← Dashboard matériel CE + +

Réservations

+

+ {bookings.length} résultat{bookings.length > 1 ? "s" : ""} +

+
+
+ + +
+
+ + {bookings.length === 0 ? ( +
+ Aucune réservation matériel. +
+ ) : ( +
    + {bookings.map((b) => ( +
  • +
    +
    +

    + {b.tenant.firstName} {b.tenant.lastName} +

    +

    + {b.tenant.email} + {b.tenant.phone ? ` · ${b.tenant.phone}` : ""} +

    + {b.booking ? ( +

    + 🏠 Lié à la résa carbet :{" "} + + {b.booking.carbet.title} + +

    + ) : ( +

    + Location standalone (sans carbet) +

    + )} +
    +
    +
    + {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} +
    +
    + {Number(b.amount).toFixed(2)} {b.currency} +
    +
    +
    + +
      + {b.lines.map((l) => ( +
    • + + {l.qty}× {l.item.name} + + + {Number(l.lineTotal).toFixed(2)} € + +
    • + ))} +
    + +
    +
    + + {RENTAL_STATUS_LABEL[b.status]} + + + {b.paymentStatus} + +
    + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/espace-ce/membres/_components/InviteForm.tsx b/src/app/espace-ce/membres/_components/InviteForm.tsx new file mode 100644 index 0000000..4a8fd96 --- /dev/null +++ b/src/app/espace-ce/membres/_components/InviteForm.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState, useTransition } from "react"; + +import type { CreateInviteResult } from "../actions"; + +export function InviteForm({ + action, + siteUrl, +}: { + action: (fd: FormData) => Promise; + siteUrl: string; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [link, setLink] = useState(null); + const [emailSent, setEmailSent] = useState(false); + + function onSubmit(fd: FormData) { + setError(null); + setLink(null); + setEmailSent(false); + const emailValue = ((fd.get("email") as string | null) ?? "").trim(); + startTransition(async () => { + const res = await action(fd); + if (!res.ok) { + setError(res.error); + return; + } + setLink(`${siteUrl}/inscription?invite=${res.token}`); + setEmailSent(Boolean(emailValue)); + }); + } + + return ( +
+
+ + +
+ {error ? ( +
+ {error} +
+ ) : null} + {link ? ( +
+

+ ✓ Lien d'invitation généré (valable 14 jours) + {emailSent ? " · email envoyé" : ""} +

+ + {link} + + +
+ ) : null} +

+ Si vous indiquez un email, l'invitation sera envoyée automatiquement et le lien + sera bloqué pour toute autre adresse à la connexion. Sans email, n'importe qui + ayant le lien peut rejoindre votre CE. +

+
+ ); +} diff --git a/src/app/espace-ce/membres/actions.ts b/src/app/espace-ce/membres/actions.ts new file mode 100644 index 0000000..b042c00 --- /dev/null +++ b/src/app/espace-ce/membres/actions.ts @@ -0,0 +1,82 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { + createOrgInviteToken, + revokeOrgInviteToken, +} from "@/lib/ce-invites"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { sendCeInviteEmail } from "@/lib/email"; +import { prisma } from "@/lib/prisma"; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; + +export type CreateInviteResult = + | { ok: true; token: string } + | { ok: false; error: string }; + +export async function createInviteAction(fd: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) return { ok: false, error: "Non authentifié." }; + if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) { + return { ok: false, error: "Réservé aux CE_MANAGER." }; + } + const org = await getCurrentCeOrganization(); + if (!org) return { ok: false, error: "Aucune organisation détectée." }; + if (!org.approved) return { ok: false, error: "Votre organisation doit être validée." }; + + const email = ((fd.get("email") as string | null) ?? "").trim().toLowerCase() || null; + if (email && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) { + return { ok: false, error: "Email invalide." }; + } + + const token = await createOrgInviteToken({ + organizationId: org.id, + createdByUserId: session.user.id, + email, + }); + await recordAudit({ + scope: "ce.invite", + event: "invite.create", + target: org.id, + actorEmail: session.user.email ?? null, + details: { email, emailedAutomatically: Boolean(email) }, + }); + // Envoi automatique si email destinataire fourni (best-effort, dry-run sans Resend). + if (email) { + const inviteUrl = `${SITE_URL}/inscription?invite=${token}`; + try { + await sendCeInviteEmail(email, org.name, inviteUrl, session.user.name); + } catch (e) { + console.error("[ce.invite] email send failed:", e instanceof Error ? e.message : e); + } + } + revalidatePath("/espace-ce/membres"); + return { ok: true, token }; +} + +export async function revokeInviteAction(tokenHash: string): Promise { + const session = await auth(); + if (!session?.user?.id) return; + if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) return; + const org = await getCurrentCeOrganization(); + if (!org) return; + const invite = await prisma.orgInviteToken.findUnique({ + where: { tokenHash }, + select: { organizationId: true }, + }); + if (!invite || invite.organizationId !== org.id) return; + await revokeOrgInviteToken(tokenHash); + await recordAudit({ + scope: "ce.invite", + event: "invite.revoke", + target: org.id, + actorEmail: session.user.email ?? null, + details: {}, + }); + revalidatePath("/espace-ce/membres"); +} diff --git a/src/app/espace-ce/membres/page.tsx b/src/app/espace-ce/membres/page.tsx new file mode 100644 index 0000000..e77ed19 --- /dev/null +++ b/src/app/espace-ce/membres/page.tsx @@ -0,0 +1,173 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { UserRole } from "@/generated/prisma/enums"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { listOrgInviteTokens } from "@/lib/ce-invites"; +import { prisma } from "@/lib/prisma"; + +import { createInviteAction, revokeInviteAction } from "./actions"; +import { InviteForm } from "./_components/InviteForm"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Membres CE — Karbé" }; + +const ROLE_LABEL: Record = { + CE_MANAGER: "Manager", + CE_MEMBER: "Membre", +}; + +export default async function CeMembresPage() { + const org = await getCurrentCeOrganization(); + if (!org) redirect("/admin/organizations"); + + const [members, invites] = await Promise.all([ + prisma.user.findMany({ + where: { + organizationId: org.id, + role: { in: [UserRole.CE_MANAGER, UserRole.CE_MEMBER] }, + isActive: true, + }, + orderBy: [{ role: "asc" }, { lastName: "asc" }], + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + createdAt: true, + }, + }), + listOrgInviteTokens(org.id), + ]); + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; + + const dateFmt = new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "2-digit", + }); + + return ( +
+
+ + ← Tableau de bord CE + +

+ Membres — {org.name} +

+

+ {members.length} membre{members.length > 1 ? "s" : ""} actif{members.length > 1 ? "s" : ""}. + Générez un lien d'invitation pour qu'un nouveau CE_MEMBER s'inscrive et + rejoigne automatiquement votre organisation. +

+
+ +
+

+ Inviter un membre +

+ {!org.approved ? ( +

+ 🕒 La génération d'invitations est bloquée tant que votre organisation n'est + pas validée. +

+ ) : ( +
+ +
+ )} +
+ +
+

+ Membres ({members.length}) +

+ {members.length === 0 ? ( +

Aucun membre actif pour l'instant.

+ ) : ( +
    + {members.map((m) => ( +
  • +
    +
    + {m.firstName} {m.lastName} +
    +
    {m.email}
    +
    + + {ROLE_LABEL[m.role] ?? m.role} + +
  • + ))} +
+ )} +
+ +
+

+ Invitations en cours ({invites.filter((i) => !i.usedAt && i.expiresAt > new Date()).length}) +

+ {invites.length === 0 ? ( +

Aucune invitation envoyée pour l'instant.

+ ) : ( +
    + {invites.map((inv) => { + const expired = inv.expiresAt < new Date(); + const used = inv.usedAt !== null; + const status = used ? "consommé" : expired ? "expiré" : "actif"; + return ( +
  • +
    +
    + {inv.email ?? "(lien partagé)"} +
    +
    + Créé {dateFmt.format(inv.createdAt)} · Expire {dateFmt.format(inv.expiresAt)} +
    +
    +
    + + {status} + + {!used && !expired ? ( +
    + +
    + ) : null} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/espace-ce/page.tsx b/src/app/espace-ce/page.tsx new file mode 100644 index 0000000..a730184 --- /dev/null +++ b/src/app/espace-ce/page.tsx @@ -0,0 +1,150 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { getCeOrgKpis } from "@/lib/ce-dashboard"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Espace CE — Karbé" }; + +function fmtEur(n: number): string { + return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" }); +} + +export default async function CeDashboardPage() { + const org = await getCurrentCeOrganization(); + if (!org) { + // ADMIN sans organizationId ciblé : pour l'instant, renvoyer vers la liste admin. + redirect("/admin/organizations"); + } + + const kpis = await getCeOrgKpis(org.id); + + return ( +
+
+

+ Espace CE — {org.name} +

+

+ Dashboard de votre comité d'entreprise. Co-gérez vos carbets et activez la location + de matériel pour vos membres et le public touriste. +

+
+ + {!org.approved ? ( +
+

+ 🕒 Votre organisation est en attente de validation +

+

+ L'équipe Karbé vérifie votre demande. Vous pouvez préparer vos carbets et items + en brouillon, mais rien ne sera publié tant que votre organisation n'est pas + validée. Cela prend généralement moins de 48h. Si vous n'avez pas de retour + sous 72h, contactez{" "} + + contact@karbe.cosmolan.fr + + . +

+
+ ) : null} + +
+ + + + +
+ +
+ 0 + ? `${kpis.carbetsCount} carbet${kpis.carbetsCount > 1 ? "s" : ""} co-géré${kpis.carbetsCount > 1 ? "s" : ""} par votre CE.` + : org.approved + ? "Ajoutez votre premier carbet et ouvrez-le à vos membres + au public." + : "Vous pouvez préparer vos carbets en brouillon, ils seront publiables après validation." + } + /> + 0 + ? `${kpis.rentalItemsCount} item${kpis.rentalItemsCount > 1 ? "s" : ""} en location.` + : org.approved + ? "Proposez hamacs, kayaks, pirogue… à vos membres et au public." + : "Disponible après validation de votre organisation." + } + disabled={!org.approved} + comingSoon + /> +
+ +

+ Voir aussi vos{" "} + + analytics CA & occupation + {" "} + et gérez vos{" "} + + membres et invitations CE + + . +

+
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ActionCard({ + href, + title, + description, + disabled, + comingSoon, +}: { + href: string; + title: string; + description: string; + disabled?: boolean; + comingSoon?: boolean; +}) { + const baseCls = + "rounded-lg border bg-white px-5 py-4 shadow-sm transition " + + (disabled + ? "border-zinc-200 opacity-60" + : "border-zinc-200 hover:border-zinc-400 hover:shadow"); + const inner = ( + <> +

+ {title} + {comingSoon ? ( + + Bientôt + + ) : null} +

+

{description}

+ + ); + if (disabled || comingSoon) { + return
{inner}
; + } + return ( + + {inner} + + ); +} diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx index 39ae0f9..8ecdaaa 100644 --- a/src/app/espace-hote/carbets/[carbetId]/page.tsx +++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx @@ -42,10 +42,14 @@ export default async function EditCarbetPage({ select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true }, }, amenities: { select: { amenity: { select: { key: true } } } }, + organizations: { select: { organizationId: true } }, }, }); - if (!carbet || !canManageCarbet(session, carbet.ownerId)) { + if ( + !carbet || + !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) + ) { notFound(); } diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts index ba29ac4..470d1ad 100644 --- a/src/app/espace-hote/carbets/actions.ts +++ b/src/app/espace-hote/carbets/actions.ts @@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma"; import { ensureUniqueCarbetSlug } from "@/lib/slug"; import { deleteObject } from "@/lib/storage"; import { Prisma } from "@/generated/prisma/client"; -import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums"; +import { CarbetStatus, Electricity, RoadAccess, UserRole } from "@/generated/prisma/enums"; import type { CarbetFormState } from "./form-types"; @@ -213,6 +213,10 @@ export async function createCarbet( const slug = await ensureUniqueCarbetSlug(data.title); + // Si CE_MANAGER : on lie automatiquement le carbet à son org via OrganizationCarbetMembership. + const isCeCreator = + session.user.role === UserRole.CE_MANAGER && Boolean(session.user.organizationId); + const carbet = await prisma.$transaction(async (tx) => { const created = await tx.carbet.create({ data: { @@ -235,9 +239,22 @@ export async function createCarbet( select: { id: true }, }); await syncAmenities(tx, created.id, data.amenities); + if (isCeCreator) { + await tx.organizationCarbetMembership.create({ + data: { + organizationId: session.user.organizationId!, + carbetId: created.id, + addedByUserId: session.user.id, + }, + }); + } return created; }); + if (isCeCreator) { + revalidatePath("/espace-ce/carbets"); + redirect(`/espace-ce/carbets/${carbet.id}`); + } revalidatePath("/espace-hote/carbets"); redirect(`/espace-hote/carbets/${carbet.id}`); } @@ -251,10 +268,17 @@ export async function updateCarbet( const existing = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { ownerId: true, _count: { select: { media: true } } }, + select: { + ownerId: true, + organizations: { select: { organizationId: true } }, + _count: { select: { media: true } }, + }, }); - if (!existing || !canManageCarbet(session, existing.ownerId)) { + if ( + !existing || + !canManageCarbet(session, existing.ownerId, existing.organizations.map((o) => o.organizationId)) + ) { return { ok: false, errors: { _global: "Carbet introuvable ou accès refusé." }, @@ -313,10 +337,17 @@ export async function setCarbetStatus(formData: FormData): Promise { const carbet = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { ownerId: true, _count: { select: { media: true } } }, + select: { + ownerId: true, + organizations: { select: { organizationId: true } }, + _count: { select: { media: true } }, + }, }); - if (!carbet || !canManageCarbet(session, carbet.ownerId)) { + if ( + !carbet || + !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) + ) { notFound(); } @@ -340,10 +371,17 @@ export async function deleteCarbet(formData: FormData): Promise { const carbet = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { ownerId: true, media: { select: { s3Key: true } } }, + select: { + ownerId: true, + organizations: { select: { organizationId: true } }, + media: { select: { s3Key: true } }, + }, }); - if (!carbet || !canManageCarbet(session, carbet.ownerId)) { + if ( + !carbet || + !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) + ) { notFound(); } @@ -366,10 +404,17 @@ export async function reorderMedia( const carbet = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { ownerId: true, media: { select: { id: true } } }, + select: { + ownerId: true, + organizations: { select: { organizationId: true } }, + media: { select: { id: true } }, + }, }); - if (!carbet || !canManageCarbet(session, carbet.ownerId)) { + if ( + !carbet || + !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) + ) { return { ok: false }; } @@ -396,10 +441,26 @@ export async function deleteMedia( const media = await prisma.media.findUnique({ where: { id: mediaId }, - select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } }, + select: { + s3Key: true, + carbetId: true, + carbet: { + select: { + ownerId: true, + organizations: { select: { organizationId: true } }, + }, + }, + }, }); - if (!media || !canManageCarbet(session, media.carbet.ownerId)) { + if ( + !media || + !canManageCarbet( + session, + media.carbet.ownerId, + media.carbet.organizations.map((o) => o.organizationId), + ) + ) { return { ok: false }; } diff --git a/src/app/espace-prestataire/actions.ts b/src/app/espace-prestataire/actions.ts new file mode 100644 index 0000000..0d790a5 --- /dev/null +++ b/src/app/espace-prestataire/actions.ts @@ -0,0 +1,250 @@ +"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; + basePath: string; +}> { + 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é"); + // Un CE_MANAGER reste sous /espace-ce/materiel ; un RENTAL_PROVIDER/ADMIN + // reste sous /espace-prestataire. Les actions sont mutualisées et redirigent + // vers l'espace contextuel du user. + const basePath = + session.user.role === UserRole.CE_MANAGER ? "/espace-ce/materiel" : "/espace-prestataire"; + return { + providerId: provider.id, + actorEmail: session.user.email ?? null, + basePath, + }; +} + +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, basePath } = 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(`${basePath}/items`); + redirect(`${basePath}/items/${created.id}`); +} + +export async function updateHostItemAction(itemId: string, fd: FormData) { + const { providerId, actorEmail, basePath } = await requireOwnedProvider(); + const session = await auth(); + if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId, session?.user?.organizationId))) { + 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(`${basePath}/items`); + revalidatePath(`${basePath}/items/${itemId}`); + return { ok: true as const }; +} + +export async function deleteHostItemAction(itemId: string) { + const { providerId, actorEmail, basePath } = 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(`${basePath}/items`); + redirect(`${basePath}/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, basePath } = 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(`${basePath}/items/${itemId}`); + return { ok: true as const }; +} + +export async function removeItemBlockAction(blockId: string) { + const { providerId, actorEmail, basePath } = 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(`${basePath}/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, basePath } = 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(`${basePath}/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..ee46102 --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/page.tsx @@ -0,0 +1,118 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { MediaUploader } from "@/components/MediaUploader"; +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 +

+
+ +
+ +
+

+ Photos & vidéos +

+ +
+ +
+ +
+ +
+

+ 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 ( +
+
+
+ + + +