diff --git a/prisma/migrations/20260603100000_rental_item_media/migration.sql b/prisma/migrations/20260603100000_rental_item_media/migration.sql deleted file mode 100644 index 67a2d76..0000000 --- a/prisma/migrations/20260603100000_rental_item_media/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 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 deleted file mode 100644 index eb2cc87..0000000 --- a/prisma/migrations/20260603200000_ce_management/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ --- 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 deleted file mode 100644 index 7ce99e5..0000000 --- a/prisma/migrations/20260603300000_org_invite_token/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 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 deleted file mode 100644 index cff28de..0000000 --- a/prisma/migrations/20260603400000_rental_payout_mark/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 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 6ae7e3f..7580413 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,59 +72,16 @@ enum TransportMode { } model Organization { - 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 + id String @id @default(cuid()) + name String + slug String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - members User[] - carbetMemberships OrganizationCarbetMembership[] - rentalProviders RentalProvider[] - invites OrgInviteToken[] + members User[] @@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 { @@ -200,7 +157,6 @@ model Carbet { bookings Booking[] reviews Review[] subscriptions Subscription[] - organizations OrganizationCarbetMembership[] @@index([ownerId]) @@index([status]) @@ -469,9 +425,6 @@ 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([]) @@ -485,36 +438,11 @@ 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 { @@ -538,26 +466,11 @@ 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 deleted file mode 100644 index 0ec2427..0000000 --- a/src/app/admin/analytics/page.tsx +++ /dev/null @@ -1,169 +0,0 @@ -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 deleted file mode 100644 index 95bfc88..0000000 --- a/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"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 6a69e2e..bf7a972 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -1,23 +1,15 @@ 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 { - linkCarbetToOrganizationAction, - unlinkCarbetFromOrganizationAction, - updateCarbetAction, -} from "../actions"; -import { CarbetMemberships } from "./_components/CarbetMemberships"; +import { StatusBadge } from "@/components/admin/StatusBadge"; +import { MediaUploader } from "@/components/MediaUploader"; import { StatusActions } from "./_components/StatusActions"; +import { updateCarbetAction } from "../actions"; export const dynamic = "force-dynamic"; @@ -25,11 +17,10 @@ type PageProps = { params: Promise<{ id: string }> }; export default async function EditCarbetPage({ params }: PageProps) { const { id } = await params; - const [carbet, owners, providers, organizations] = await Promise.all([ + const [carbet, owners, providers] = await Promise.all([ getCarbetForEdit(id), listOwners(), listPirogueProviders(), - listOrganizationsForLink(), ]); if (!carbet) notFound(); @@ -37,14 +28,6 @@ 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 (
@@ -78,25 +61,6 @@ 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 f85950a..2004bd8 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -213,42 +213,6 @@ 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 deleted file mode 100644 index d53a21c..0000000 --- a/src/app/admin/organizations/[id]/_components/ApproveOrgButton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"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 810ba23..90c91b6 100644 --- a/src/app/admin/organizations/[id]/page.tsx +++ b/src/app/admin/organizations/[id]/page.tsx @@ -3,8 +3,7 @@ import Link from "next/link"; import { getOrganizationForAdmin } from "@/lib/admin/organizations"; import { OrgForm } from "../_components/OrgForm"; import { StatusBadge } from "@/components/admin/StatusBadge"; -import { approveOrganizationAction, deleteOrganizationAction, updateOrganizationAction } from "../actions"; -import { ApproveOrgButton } from "./_components/ApproveOrgButton"; +import { deleteOrganizationAction, updateOrganizationAction } from "../actions"; import { DeleteOrgButton } from "./_components/DeleteOrgButton"; export const dynamic = "force-dynamic"; @@ -32,10 +31,6 @@ export default async function EditOrgPage({ params }: PageProps) { "use server"; return await deleteOrganizationAction(id); }; - const approveThis = async () => { - "use server"; - return await approveOrganizationAction(id); - }; return (
@@ -44,33 +39,12 @@ export default async function EditOrgPage({ params }: PageProps) { ← Toutes les organisations -

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

+

{org.name}

- /{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.slug} · {org.members.length} membre{org.members.length > 1 ? "s" : ""}

- {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 6a0ae6f..5f8bcf6 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -5,9 +5,7 @@ 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"; @@ -77,38 +75,6 @@ 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 b6a5e95..0d394ba 100644 --- a/src/app/admin/organizations/page.tsx +++ b/src/app/admin/organizations/page.tsx @@ -1,27 +1,16 @@ import Link from "next/link"; -import { countPendingOrganizations, listOrganizationsAdmin } from "@/lib/admin/organizations"; +import { listOrganizationsAdmin } from "@/lib/admin/organizations"; export const dynamic = "force-dynamic"; type PageProps = { - searchParams: Promise<{ q?: string; status?: string }>; + searchParams: Promise<{ q?: 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 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 filters = { q: sp.q?.trim() || undefined }; + const orgs = await listOrganizationsAdmin(filters); const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); return ( @@ -41,35 +30,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { - -
- {approved !== "all" ? ( - - ) : null} Nom - Statut Slug Membres Créée @@ -101,7 +61,7 @@ export default async function OrgsAdminPage({ searchParams }: PageProps) { {orgs.length === 0 ? ( - + Aucune organisation. @@ -116,17 +76,6 @@ 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 deleted file mode 100644 index b2129d1..0000000 --- a/src/app/admin/payouts/_components/MarkPaidForm.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"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 deleted file mode 100644 index ac92fd2..0000000 --- a/src/app/admin/payouts/actions.ts +++ /dev/null @@ -1,96 +0,0 @@ -"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 deleted file mode 100644 index 0c40c19..0000000 --- a/src/app/admin/payouts/page.tsx +++ /dev/null @@ -1,155 +0,0 @@ -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 59295d2..8f4dd4a 100644 --- a/src/app/admin/rental-items/[id]/page.tsx +++ b/src/app/admin/rental-items/[id]/page.tsx @@ -2,7 +2,6 @@ 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"; @@ -57,14 +56,6 @@ export default async function EditRentalItemPage({ params }: PageProps) { /> -
-

Photos & vidéos

- -
-
o.organizationId))) { + if (!canManageCarbet(session, carbet.ownerId)) { 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 deleted file mode 100644 index 103315e..0000000 --- a/src/app/api/cron/cleanup/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 96e0573..0000000 --- a/src/app/api/cron/reminders/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 8420d8c..0000000 --- a/src/app/api/rental-media/[id]/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index d375aa0..0000000 --- a/src/app/api/rental-media/reorder/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 49aa9ec..0000000 --- a/src/app/api/rentals/[id]/cancel/route.ts +++ /dev/null @@ -1,193 +0,0 @@ -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 deleted file mode 100644 index 06fccb6..0000000 --- a/src/app/api/rentals/checkout/route.ts +++ /dev/null @@ -1,361 +0,0 @@ -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 deleted file mode 100644 index dc3b8b2..0000000 --- a/src/app/api/rentals/items/[id]/availability/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 739bf1b..1ded993 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -2,13 +2,11 @@ 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 { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email"; +import { sendSignupWelcome } from "@/lib/email"; import { rateLimitRequest } from "@/lib/rate-limit"; -import { slugify } from "@/lib/slug"; export const runtime = "nodejs"; @@ -18,16 +16,11 @@ 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, 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(), + role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST), }); 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( @@ -50,150 +43,35 @@ 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); - - // 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(() => {}); - } - } + 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 }, + }); await recordAudit({ scope: "public.signup", event: "user.create", target: user.id, actorEmail: user.email, - details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId }, + details: { role: user.role }, }); + // Best-effort welcome email. sendSignupWelcome(user.email, data.firstName).catch(() => {}); - return NextResponse.json({ - ok: true, - userId: user.id, - providerId: createdProviderId, - organizationId: createdOrgId, - }); + return NextResponse.json({ ok: true, userId: user.id }); } diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 6a896ec..1e4296f 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -4,11 +4,9 @@ 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"; @@ -53,43 +51,6 @@ 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; @@ -118,27 +79,6 @@ 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 deleted file mode 100644 index befc16f..0000000 --- a/src/app/api/uploads/rental-finalize/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 73244e0..0000000 --- a/src/app/api/uploads/rental-presign/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index a1b8b42..0000000 --- a/src/app/carbets/[slug]/_components/CompleteYourStay.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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 dbaeeaf..ae53374 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -15,7 +15,6 @@ 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"; @@ -115,17 +114,6 @@ 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 ? (

@@ -289,8 +277,6 @@ export default async function PublicCarbetPage({ params }: PageProps) { - - setGuestCount(Math.max(1, Math.min(capacity, Number(e.target.value) || 1)))} - inputMode="numeric" - className="mt-0.5 block w-full min-h-[44px] rounded-md border border-zinc-300 px-3 py-2 text-base" + className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5" /> @@ -216,7 +215,7 @@ export function BookingForm({ type="button" onClick={submit} disabled={!canSubmit} - 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" + 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" > {busy ? "Envoi…" diff --git a/src/app/espace-ce/analytics/page.tsx b/src/app/espace-ce/analytics/page.tsx deleted file mode 100644 index c9fc2be..0000000 --- a/src/app/espace-ce/analytics/page.tsx +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 08c8c21..0000000 --- a/src/app/espace-ce/carbets/[carbetId]/page.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index c168cd3..0000000 --- a/src/app/espace-ce/carbets/nouveau/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index d4d9f6d..0000000 --- a/src/app/espace-ce/carbets/page.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index 75cdc1c..0000000 --- a/src/app/espace-ce/layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 959d9f3..0000000 --- a/src/app/espace-ce/materiel/actions.ts +++ /dev/null @@ -1,66 +0,0 @@ -"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 deleted file mode 100644 index 2d406a9..0000000 --- a/src/app/espace-ce/materiel/items/[itemId]/page.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 8a6d468..0000000 --- a/src/app/espace-ce/materiel/items/new/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 68be691..0000000 --- a/src/app/espace-ce/materiel/items/page.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index 2c13ead..0000000 --- a/src/app/espace-ce/materiel/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 6a1bfdc..0000000 --- a/src/app/espace-ce/materiel/reservations/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 4a8fd96..0000000 --- a/src/app/espace-ce/membres/_components/InviteForm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"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 deleted file mode 100644 index b042c00..0000000 --- a/src/app/espace-ce/membres/actions.ts +++ /dev/null @@ -1,82 +0,0 @@ -"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 deleted file mode 100644 index e77ed19..0000000 --- a/src/app/espace-ce/membres/page.tsx +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index a730184..0000000 --- a/src/app/espace-ce/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -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 8ecdaaa..39ae0f9 100644 --- a/src/app/espace-hote/carbets/[carbetId]/page.tsx +++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx @@ -42,14 +42,10 @@ 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, carbet.organizations.map((o) => o.organizationId)) - ) { + if (!carbet || !canManageCarbet(session, carbet.ownerId)) { notFound(); } diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts index 470d1ad..ba29ac4 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, UserRole } from "@/generated/prisma/enums"; +import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums"; import type { CarbetFormState } from "./form-types"; @@ -213,10 +213,6 @@ 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: { @@ -239,22 +235,9 @@ 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}`); } @@ -268,17 +251,10 @@ export async function updateCarbet( const existing = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { - ownerId: true, - organizations: { select: { organizationId: true } }, - _count: { select: { media: true } }, - }, + select: { ownerId: true, _count: { select: { media: true } } }, }); - if ( - !existing || - !canManageCarbet(session, existing.ownerId, existing.organizations.map((o) => o.organizationId)) - ) { + if (!existing || !canManageCarbet(session, existing.ownerId)) { return { ok: false, errors: { _global: "Carbet introuvable ou accès refusé." }, @@ -337,17 +313,10 @@ export async function setCarbetStatus(formData: FormData): Promise { const carbet = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { - ownerId: true, - organizations: { select: { organizationId: true } }, - _count: { select: { media: true } }, - }, + select: { ownerId: true, _count: { select: { media: true } } }, }); - if ( - !carbet || - !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) - ) { + if (!carbet || !canManageCarbet(session, carbet.ownerId)) { notFound(); } @@ -371,17 +340,10 @@ export async function deleteCarbet(formData: FormData): Promise { const carbet = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { - ownerId: true, - organizations: { select: { organizationId: true } }, - media: { select: { s3Key: true } }, - }, + select: { ownerId: true, media: { select: { s3Key: true } } }, }); - if ( - !carbet || - !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) - ) { + if (!carbet || !canManageCarbet(session, carbet.ownerId)) { notFound(); } @@ -404,17 +366,10 @@ export async function reorderMedia( const carbet = await prisma.carbet.findUnique({ where: { id: carbetId }, - select: { - ownerId: true, - organizations: { select: { organizationId: true } }, - media: { select: { id: true } }, - }, + select: { ownerId: true, media: { select: { id: true } } }, }); - if ( - !carbet || - !canManageCarbet(session, carbet.ownerId, carbet.organizations.map((o) => o.organizationId)) - ) { + if (!carbet || !canManageCarbet(session, carbet.ownerId)) { return { ok: false }; } @@ -441,26 +396,10 @@ export async function deleteMedia( const media = await prisma.media.findUnique({ where: { id: mediaId }, - select: { - s3Key: true, - carbetId: true, - carbet: { - select: { - ownerId: true, - organizations: { select: { organizationId: true } }, - }, - }, - }, + select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } }, }); - if ( - !media || - !canManageCarbet( - session, - media.carbet.ownerId, - media.carbet.organizations.map((o) => o.organizationId), - ) - ) { + if (!media || !canManageCarbet(session, media.carbet.ownerId)) { return { ok: false }; } diff --git a/src/app/espace-prestataire/actions.ts b/src/app/espace-prestataire/actions.ts deleted file mode 100644 index 0d790a5..0000000 --- a/src/app/espace-prestataire/actions.ts +++ /dev/null @@ -1,250 +0,0 @@ -"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 deleted file mode 100644 index e83e53b..0000000 --- a/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"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 deleted file mode 100644 index bc81c8b..0000000 --- a/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"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 deleted file mode 100644 index ee46102..0000000 --- a/src/app/espace-prestataire/items/[itemId]/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index c0033ad..0000000 --- a/src/app/espace-prestataire/items/_components/ItemForm.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"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 ( -
-
-
- - - -