From 0e697595496e581c2ef4905f6e6a6950de21d670 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 2 Jun 2026 15:47:36 -0300 Subject: [PATCH] feat(email-validation): implement email verification checks for booking creation and resend functionality --- apps/backend/src/lib/auth.ts | 7 +- .../services/admin-booking.service.ts | 10 ++ .../services/public-booking.service.ts | 33 ++++ .../frontend/src/features/booking/booking.tsx | 38 +++++ .../public-booking/public-booking-page.tsx | 150 ++++++++++-------- apps/frontend/src/main.tsx | 6 +- 6 files changed, 173 insertions(+), 71 deletions(-) diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts index 61b0d83..240ce45 100644 --- a/apps/backend/src/lib/auth.ts +++ b/apps/backend/src/lib/auth.ts @@ -31,11 +31,14 @@ export const auth = betterAuth({ sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { + const verificationUrl = new URL(url); + const appUrl = process.env.APP_BASE_URL ?? 'http://localhost:5173'; + verificationUrl.searchParams.set('callbackURL', appUrl); await sendMail({ to: user.email, subject: 'Verificá tu email en Playzer', - html: `Hacé click para verificar tu email: ${url}`, - text: `Hacé click para verificar tu email: ${url}`, + html: `Hacé click para verificar tu email: ${verificationUrl.toString()}`, + text: `Hacé click para verificar tu email: ${verificationUrl.toString()}`, }); }, }, diff --git a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts index 2109c37..5070514 100644 --- a/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts +++ b/apps/backend/src/modules/admin-booking/services/admin-booking.service.ts @@ -258,6 +258,16 @@ export async function createAdminBooking( input: CreateAdminBookingInput ) { const complex = await ensureComplexAccess(complexId, userId); + + const adminUser = await db.user.findUnique({ + where: { id: userId }, + select: { emailVerified: true }, + }); + + if (!adminUser?.emailVerified) { + throw new AdminBookingServiceError('Debés verificar tu email para poder crear reservas.', 403); + } + const bookingDate = parseIsoDate(input.date); const dayOfWeek = getDayOfWeek(bookingDate); diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts index af8ef10..0ff7ff6 100644 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts @@ -329,9 +329,34 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC }; } +async function hasVerifiedAdmin(complexId: string): Promise { + const verifiedAdmin = await db.complexUser.findFirst({ + where: { + complexId, + role: 'ADMIN', + user: { emailVerified: true }, + }, + }); + return verifiedAdmin !== null; +} + export async function listPublicAvailability(complexSlug: string, query: PublicAvailabilityQuery) { const { bookingDate, dayOfWeek } = parseIsoDate(query.date); const complex = await getComplexWithBookingData(complexSlug); + + if (!(await hasVerifiedAdmin(complex.id))) { + return { + complexId: complex.id, + complexName: complex.complexName, + complexAddress: complex.physicalAddress ?? null, + complexSlug: complex.complexSlug, + date: query.date, + sportSelectionRequired: false, + sports: [], + courts: [], + }; + } + const sports = resolveSports(complex); const sportSelectionRequired = sports.length > 1; @@ -427,6 +452,14 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA export async function createPublicBooking(complexSlug: string, input: CreatePublicBookingInput) { const { bookingDate, dayOfWeek } = parseIsoDate(input.date); const complex = await getComplexWithBookingData(complexSlug); + + if (!(await hasVerifiedAdmin(complex.id))) { + throw new PublicBookingServiceError( + 'El complejo no puede recibir reservas hasta que un administrador verifique su email.', + 403 + ); + } + const sports = resolveSports(complex); const { sportSelectionRequired } = validateSportSelection(sports, input.sportId); diff --git a/apps/frontend/src/features/booking/booking.tsx b/apps/frontend/src/features/booking/booking.tsx index c2000e1..72b7c4b 100644 --- a/apps/frontend/src/features/booking/booking.tsx +++ b/apps/frontend/src/features/booking/booking.tsx @@ -1,5 +1,8 @@ import { useIsMobile } from '@/hooks/use-mobile'; +import { authClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; import type { ComplexWithRole } from '@repo/api-contract'; +import { useState } from 'react'; import { BookingProvider } from './booking-provider'; import { BookingCreateDialog } from './components/booking-create-dialog'; import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary'; @@ -15,9 +18,44 @@ interface BookingProps { export function Booking({ complex }: BookingProps) { const isMobile = useIsMobile(); + const { user } = useAuth(); + const [sending, setSending] = useState(false); + const [emailSent, setEmailSent] = useState(false); + + const emailNotVerified = user && !user.emailVerified; + + const handleResendVerification = async () => { + if (!user?.email || sending) return; + setSending(true); + try { + await authClient.sendVerificationEmail({ email: user.email }); + setEmailSent(true); + setTimeout(() => setEmailSent(false), 6000); + } catch { + // Silently fail — the banner already shows the email to contact + } finally { + setSending(false); + } + }; return ( + {emailNotVerified && ( +
+ + No verificaste tu email. Para crear reservas necesitás verificar tu dirección de correo + electrónico. + + +
+ )} {isMobile ? ( ) : ( diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx index e4a5420..db49798 100644 --- a/apps/frontend/src/features/public-booking/public-booking-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx @@ -638,48 +638,52 @@ function PublicBookingDesktop(props: BookingShellProps) { )} {isError && {errorMessage}} {isLoading && Buscando disponibilidad...} - -
- -
- -
- - {selectedDay?.longLabel ?? selectedDate} + {selectedCourt && ( + <> + +
+ +
+ +
+ + {selectedDay?.longLabel ?? selectedDate} +
+ +
- + +
+ + +
+
+ +
+ +
-
- -
- - -
- - -
- - -
+ + )}
@@ -711,16 +715,24 @@ function PublicBookingMobile(props: BookingShellProps) { options={props.sports.map((sport) => ({ value: sport.id, label: sport.name }))} /> )} - ({ - value: court.courtId, - label: court.courtName, - }))} - /> + {props.courts.length > 0 ? ( + ({ + value: court.courtId, + label: court.courtName, + }))} + /> + ) : ( + !props.isLoading && ( +

+ No hay canchas disponibles para esta fecha. +

+ ) + )}
@@ -756,25 +768,27 @@ function PublicBookingMobile(props: BookingShellProps) { )} {props.isLoading && Buscando disponibilidad...} {props.isError && {props.errorMessage}} - -
- -
- + {selectedCourt && ( + +
+ +
+ +
-
-
- -
-
- -
- +
+ +
+
+ +
+ + )}
); diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index f5fb82c..2d2b641 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -27,7 +27,11 @@ function AppRouter() { useEffect(() => { configureApiClient({ - onForbidden: async () => { + onForbidden: async (error) => { + if (error.message?.includes('verificar tu email')) { + return; + } + if (!auth.isAuthenticated) { return; }