From 63a29d9ade283139eea58a2ab60afd873c34e713 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 2 Jun 2026 23:12:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(ce):=20Sprint=20H=20=E2=80=94=20signup=20C?= =?UTF-8?q?E=20public=20+=20/espace-ce=20shell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/lib/ce-access.ts (NEW) : - requireCeManagerSession (redirect connexion ou / si rôle insuffisant) - getCurrentCeOrganization (CE_MANAGER → son org via organizationId, ADMIN → org ciblée par paramètre ou null) - canManageCarbetForCe (owner direct OU membre d'une org liée) - requireApprovedOrg (redirect /espace-ce?pending=1 si non validée) Emails best-effort : - sendNewCeRequest → admin (contact@karbe) avec lien filtré /admin/organizations?status=pending - sendCeApproved → CE_MANAGERs actifs de l'org après validation - Branchement dans approveOrganizationAction : envoie le mail à tous les CE_MANAGERs actifs de l'org en best-effort. Signup CE public : - SignupForm 4e tuile « Comité d'Entreprise » avec champ orgName. Layout grid 4 colonnes sur lg, 2 sur sm. - /api/signup étendu : - zod accepte CE_MANAGER + orgName - transaction $tx atomique : Organization (approved=false, slug auto-unique via slugify + suffix) + User (role=CE_MANAGER, organizationId lié) - sendNewCeRequest best-effort - réponse étendue avec organizationId - Pattern slug : retry avec suffix -2, -3… jusqu'à libre Dashboard /espace-ce : - layout.tsx : requirePluginOr404("ce-management") + requireCeManagerSession - page.tsx : 4 KPIs (carbets co-gérés, items rental, bookings 30j, revenu 30j), bannière « En attente de validation » si pending, 2 ActionCards (Mes carbets, Matériel rental) marquées « Bientôt » jusqu'aux sprints I et J - ce-dashboard.ts : getCeOrgKpis (agrège bookings carbets via membership + rentalBookings via provider.organizationId) + listCeCarbets pour Sprint I SiteHeader : lien « Espace CE » conditionné par role + plugin (mirror du lien Espace prestataire). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/admin/organizations/actions.ts | 19 +++ src/app/api/signup/route.ts | 125 +++++++++++---- src/app/espace-ce/layout.tsx | 8 + src/app/espace-ce/page.tsx | 145 ++++++++++++++++++ .../inscription/_components/SignupForm.tsx | 52 ++++++- src/components/SiteHeader.tsx | 11 +- src/lib/ce-access.ts | 92 +++++++++++ src/lib/ce-dashboard.ts | 90 +++++++++++ src/lib/email.ts | 38 +++++ 9 files changed, 544 insertions(+), 36 deletions(-) create mode 100644 src/app/espace-ce/layout.tsx create mode 100644 src/app/espace-ce/page.tsx create mode 100644 src/lib/ce-access.ts create mode 100644 src/lib/ce-dashboard.ts diff --git a/src/app/admin/organizations/actions.ts b/src/app/admin/organizations/actions.ts index 6b852a8..6a0ae6f 100644 --- a/src/app/admin/organizations/actions.ts +++ b/src/app/admin/organizations/actions.ts @@ -7,6 +7,7 @@ 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"; @@ -84,6 +85,24 @@ export async function approveOrganizationAction(id: string) { 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}`); diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 8953cf7..e056d9b 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -5,8 +5,9 @@ import { UserRole } from "@/generated/prisma/enums"; import { hashPassword } from "@/lib/password"; import { prisma } from "@/lib/prisma"; import { recordAudit } from "@/lib/admin/audit"; -import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email"; +import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email"; import { rateLimitRequest } from "@/lib/rate-limit"; +import { slugify } from "@/lib/slug"; export const runtime = "nodejs"; @@ -17,10 +18,11 @@ const schema = z.object({ 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]) + .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(), }); export async function POST(req: Request) { @@ -49,6 +51,9 @@ export async function POST(req: Request) { 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 }); + } const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } }); if (existing) { @@ -56,38 +61,87 @@ export async function POST(req: Request) { } const passwordHash = await hashPassword(data.password); - const user = await prisma.user.create({ - data: { - email: data.email, - passwordHash, - firstName: data.firstName, - lastName: data.lastName, - phone: data.phone?.trim() || null, - role: data.role, - isActive: true, - }, - select: { id: true, email: true, role: true }, - }); - // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation. + // 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; - 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 }, + let createdOrgId: string | null = null; + let user: { id: string; email: string; role: UserRole }; + + 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 }; }); - createdProviderId = provider.id; - sendNewRentalProviderRequest(provider.name, user.email).catch(() => {}); + user = result.user; + createdOrgId = result.orgId; + sendNewCeRequest(orgName, user.email).catch(() => {}); + } else { + user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone?.trim() || null, + role: data.role, + isActive: true, + }, + select: { id: true, email: true, role: true }, + }); + + // Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation. + if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) { + const provider = await prisma.rentalProvider.create({ + data: { + name: data.providerName, + isSystemD: false, + managedByUserId: user.id, + contactEmail: user.email, + contactPhone: data.phone?.trim() || null, + rivers: data.providerRivers ?? [], + commissionPct: 10, // valeur par défaut, ajustable par admin + active: true, + approved: false, + }, + select: { id: true, name: true }, + }); + createdProviderId = provider.id; + sendNewRentalProviderRequest(provider.name, user.email).catch(() => {}); + } } await recordAudit({ @@ -95,10 +149,15 @@ export async function POST(req: Request) { event: "user.create", target: user.id, actorEmail: user.email, - details: { role: user.role, rentalProviderId: createdProviderId }, + details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId }, }); sendSignupWelcome(user.email, data.firstName).catch(() => {}); - return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId }); + return NextResponse.json({ + ok: true, + userId: user.id, + providerId: createdProviderId, + organizationId: createdOrgId, + }); } diff --git a/src/app/espace-ce/layout.tsx b/src/app/espace-ce/layout.tsx new file mode 100644 index 0000000..75cdc1c --- /dev/null +++ b/src/app/espace-ce/layout.tsx @@ -0,0 +1,8 @@ +import { requirePluginOr404 } from "@/lib/plugins/guard"; +import { requireCeManagerSession } from "@/lib/ce-access"; + +export default async function CeLayout({ children }: { children: React.ReactNode }) { + await requirePluginOr404("ce-management"); + await requireCeManagerSession(); + return <>{children}; +} diff --git a/src/app/espace-ce/page.tsx b/src/app/espace-ce/page.tsx new file mode 100644 index 0000000..fd64787 --- /dev/null +++ b/src/app/espace-ce/page.tsx @@ -0,0 +1,145 @@ +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." + : "Disponible après validation de votre organisation." + } + disabled={!org.approved} + comingSoon + /> + 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 + /> +
+ +

+ Les liens « Mes carbets » et « Matériel rental » seront actifs au Sprint I et J du plan + CE management. +

+
+ ); +} + +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/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx index 6f8f7bd..3ac9fc5 100644 --- a/src/app/inscription/_components/SignupForm.tsx +++ b/src/app/inscription/_components/SignupForm.tsx @@ -10,9 +10,10 @@ export function SignupForm({ next }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [error, setError] = useState(null); - const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER">("TOURIST"); + const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER" | "CE_MANAGER">("TOURIST"); const [providerName, setProviderName] = useState(""); const [providerRivers, setProviderRivers] = useState(""); + const [orgName, setOrgName] = useState(""); function onSubmit(formData: FormData) { setError(null); @@ -30,6 +31,10 @@ export function SignupForm({ next }: Props) { setError("Le nom de votre activité de loueur est requis."); return; } + if (role === "CE_MANAGER" && orgName.trim().length < 2) { + setError("Le nom de votre Comité d'Entreprise est requis."); + return; + } startTransition(async () => { const body: Record = { @@ -47,6 +52,9 @@ export function SignupForm({ next }: Props) { .map((s) => s.trim()) .filter((s) => s.length > 0); } + if (role === "CE_MANAGER") { + body.orgName = orgName.trim(); + } const res = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -112,7 +120,7 @@ export function SignupForm({ next }: Props) {
Type de compte -
+
+
+ {role === "CE_MANAGER" ? ( +
+

+ Votre Comité d'Entreprise sera créé en attente de validation. + Vous pouvez vous connecter à votre espace CE dès la création mais ne publierez + vos carbets et matériel qu'après validation par l'équipe Karbé. +

+ +
+ ) : null} + {role === "RENTAL_PROVIDER" ? (

diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 3d3f8f0..dd5b682 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -18,7 +18,11 @@ export async function SiteHeader() { const isAdmin = u?.role === UserRole.ADMIN; const isOwner = u?.role === UserRole.OWNER || isAdmin; const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin; - const rentalEnabled = await isPluginEnabled("gear-rental"); + const isCeManager = u?.role === UserRole.CE_MANAGER || isAdmin; + const [rentalEnabled, ceEnabled] = await Promise.all([ + isPluginEnabled("gear-rental"), + isPluginEnabled("ce-management"), + ]); return (

@@ -72,6 +76,11 @@ export async function SiteHeader() { Espace prestataire ) : null} + {isCeManager && ceEnabled ? ( + + Espace CE + + ) : null} {isAdmin ? ( Admin diff --git a/src/lib/ce-access.ts b/src/lib/ce-access.ts new file mode 100644 index 0000000..c525d04 --- /dev/null +++ b/src/lib/ce-access.ts @@ -0,0 +1,92 @@ +import "server-only"; + +import { redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +/** + * Garde-fou commun pour /espace-ce/* : redirige vers /connexion si pas de session, + * vers / si le rôle n'est pas CE_MANAGER ni ADMIN. + */ +export async function requireCeManagerSession() { + const session = await auth(); + if (!session?.user?.id) { + redirect("/connexion?next=/espace-ce"); + } + const role = session.user.role; + if (role !== UserRole.CE_MANAGER && role !== UserRole.ADMIN) { + redirect("/"); + } + return session; +} + +/** + * Récupère l'Organization de l'utilisateur connecté (via User.organizationId). + * - CE_MANAGER → son org (toujours rattaché) + * - ADMIN → soit l'org ciblée par `organizationId`, soit null pour forcer le choix + */ +export async function getCurrentCeOrganization(opts: { organizationId?: string } = {}) { + const session = await auth(); + if (!session?.user?.id) return null; + const role = session.user.role; + + if (role === UserRole.ADMIN && opts.organizationId) { + return prisma.organization.findUnique({ where: { id: opts.organizationId } }); + } + if (role === UserRole.ADMIN && !opts.organizationId) { + return null; + } + // CE_MANAGER : retourne son org via User.organizationId + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { organization: true }, + }); + return user?.organization ?? null; +} + +/** + * Un CE_MANAGER peut-il gérer ce carbet ? + * - vrai s'il en est l'owner direct (`Carbet.ownerId == userId`) + * - OU s'il est membre d'une org liée au carbet via OrganizationCarbetMembership + * - ADMIN passe toujours. + */ +export async function canManageCarbetForCe( + userId: string, + role: string | undefined, + carbetId: string, +): Promise { + if (role === UserRole.ADMIN) return true; + if (role !== UserRole.CE_MANAGER) return false; + + const [carbet, user] = await Promise.all([ + prisma.carbet.findUnique({ + where: { id: carbetId }, + select: { + ownerId: true, + organizations: { select: { organizationId: true } }, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { organizationId: true }, + }), + ]); + if (!carbet || !user?.organizationId) return false; + if (carbet.ownerId === userId) return true; + return carbet.organizations.some((m) => m.organizationId === user.organizationId); +} + +/** + * Garantit que l'org du user est `approved=true`. Sinon redirige vers le dashboard + * /espace-ce qui affiche une bannière « En attente de validation ». + * Utiliser sur les pages qui doivent publier du contenu (créer carbet/item). + */ +export async function requireApprovedOrg() { + const org = await getCurrentCeOrganization(); + if (!org || !org.approved) { + redirect("/espace-ce?pending=1"); + } + return org; +} diff --git a/src/lib/ce-dashboard.ts b/src/lib/ce-dashboard.ts new file mode 100644 index 0000000..c5edb40 --- /dev/null +++ b/src/lib/ce-dashboard.ts @@ -0,0 +1,90 @@ +import "server-only"; + +import { + BookingStatus, + RentalBookingStatus, +} from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +/** + * KPIs agrégés à l'échelle d'une organisation CE. + * - carbets : nombre de carbets co-gérés via OrganizationCarbetMembership + * - rentalItems : items des providers liés à l'org + * - bookings30d : bookings confirmées sur les carbets de l'org (30 derniers jours) + * - rentalBookings30d : RentalBooking confirmées sur les providers de l'org + * - revenue30d : somme des amounts (booking + rental) sur 30j + */ +export async function getCeOrgKpis(organizationId: string) { + const since = new Date(Date.now() - 30 * 86_400_000); + + const [carbetsCount, providers, bookings30d, rentalBookings30d] = await Promise.all([ + prisma.organizationCarbetMembership.count({ where: { organizationId } }), + prisma.rentalProvider.findMany({ + where: { organizationId }, + select: { + id: true, + approved: true, + active: true, + _count: { select: { items: true } }, + }, + }), + prisma.booking.findMany({ + where: { + status: BookingStatus.CONFIRMED, + createdAt: { gte: since }, + carbet: { organizations: { some: { organizationId } } }, + }, + select: { amount: true, currency: true }, + }), + prisma.rentalBooking.findMany({ + where: { + status: RentalBookingStatus.CONFIRMED, + createdAt: { gte: since }, + provider: { organizationId }, + }, + select: { amount: true, currency: true }, + }), + ]); + + const itemsCount = providers.reduce((s, p) => s + p._count.items, 0); + const revenue30d = [ + ...bookings30d.map((b) => Number(b.amount)), + ...rentalBookings30d.map((r) => Number(r.amount)), + ].reduce((s, n) => s + n, 0); + + return { + carbetsCount, + providersCount: providers.length, + rentalItemsCount: itemsCount, + rentalProviderApproved: providers.every((p) => p.approved), + bookings30dCount: bookings30d.length, + rentalBookings30dCount: rentalBookings30d.length, + revenue30d, + }; +} + +/** + * Liste les carbets co-gérés par une org (joinés via membership). + */ +export async function listCeCarbets(organizationId: string) { + const memberships = await prisma.organizationCarbetMembership.findMany({ + where: { organizationId }, + orderBy: { addedAt: "desc" }, + select: { + carbet: { + select: { + id: true, + slug: true, + title: true, + river: true, + status: true, + capacity: true, + nightlyPrice: true, + ownerId: true, + owner: { select: { firstName: true, lastName: true } }, + }, + }, + }, + }); + return memberships.map((m) => m.carbet); +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 2e9f79a..070370f 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -186,6 +186,44 @@ export async function sendBookingConfirmed( }); } +export async function sendNewCeRequest( + orgName: string, + managerEmail: string, +): Promise { + const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr"; + await sendEmail({ + to: adminEmail, + subject: `Nouvelle demande CE — ${orgName}`, + html: wrap( + "Demande de Comité d'Entreprise à valider", + `

Une organisation vient de s'inscrire en tant que Comité d'Entreprise.

+
    +
  • Nom : ${orgName}
  • +
  • Email du manager : ${managerEmail}
  • +
+

Valider sur l'admin Karbé

+

Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que Organization.approved=false.

`, + ), + }); +} + +export async function sendCeApproved( + to: string, + firstName: string, + orgName: string, +): Promise { + await sendEmail({ + to, + subject: `Votre CE « ${orgName} » est validé sur Karbé`, + html: wrap( + "Organisation validée", + `

Bonjour ${firstName},

+

Votre Comité d'Entreprise ${orgName} vient d'être validé. Vous pouvez désormais publier vos carbets et activer la location de matériel pour vos membres et le public touriste.

+

Accéder à mon espace CE

`, + ), + }); +} + export async function sendNewRentalProviderRequest( providerName: string, userEmail: string,