From 17df3ee71aba159030728b1a1aade70a6d171d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Wed, 8 Oct 2025 10:48:42 +0000 Subject: [PATCH] Merged in feature/SW-3516-pass-eurobonus-number-on-booking (pull request #2902) * feat(SW-3516): Include partnerLoyaltyNumber on bookings - Added user context to BookingFlowProviders for user state management. - Updated booking input and output schemas to accommodate new user data. - Refactored booking mutation logic to include user-related information. - Improved type definitions for better TypeScript support across booking components. Approved-by: Anton Gunnarsson --- .../components/BookingFlowProviders.tsx | 76 +++++++- apps/partner-sas/hooks/useIsUserLoggedIn.ts | 32 +--- apps/partner-sas/lib/trpc/index.ts | 1 + .../components/BookingFlowProviders.tsx | 37 +++- apps/scandic-web/lib/trpc/server.ts | 1 + .../booking-flow/lib/bookingFlowContext.tsx | 38 ++-- .../EnterDetails/Payment/PaymentClient.tsx | 150 ++++++++------- .../lib/hooks/useBookingFlowContext.ts | 18 ++ .../booking-flow/lib/hooks/useIsLoggedIn.ts | 2 +- packages/booking-flow/package.json | 1 + packages/trpc/lib/context.ts | 1 + packages/trpc/lib/routers/booking/input.ts | 100 ---------- .../routers/booking/mutation/create/index.ts | 83 +++++++++ .../routers/booking/mutation/create/schema.ts | 173 ++++++++++++++++++ .../{mutation.ts => mutation/index.ts} | 94 ++-------- packages/trpc/lib/routers/booking/output.ts | 68 +------ packages/trpc/lib/routers/booking/query.ts | 2 +- packages/trpc/lib/routers/booking/utils.ts | 3 +- 18 files changed, 510 insertions(+), 370 deletions(-) create mode 100644 packages/booking-flow/lib/hooks/useBookingFlowContext.ts create mode 100644 packages/trpc/lib/routers/booking/mutation/create/index.ts create mode 100644 packages/trpc/lib/routers/booking/mutation/create/schema.ts rename packages/trpc/lib/routers/booking/{mutation.ts => mutation/index.ts} (76%) diff --git a/apps/partner-sas/components/BookingFlowProviders.tsx b/apps/partner-sas/components/BookingFlowProviders.tsx index 6bc783db4..e231aa52f 100644 --- a/apps/partner-sas/components/BookingFlowProviders.tsx +++ b/apps/partner-sas/components/BookingFlowProviders.tsx @@ -1,17 +1,83 @@ "use client" +import { useSession } from "next-auth/react" + import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider" +import { logger } from "@scandic-hotels/common/logger" +import { trpc } from "@scandic-hotels/trpc/client" -import { useIsUserLoggedIn } from "../hooks/useIsUserLoggedIn" - -import type { ReactNode } from "react" +import type { Session } from "next-auth" +import type { ComponentProps, ReactNode } from "react" export function BookingFlowProviders({ children }: { children: ReactNode }) { - const isLoggedIn = useIsUserLoggedIn() + const user = useBookingFlowUser() return ( - + {children} ) } + +type BookingFlowContextData = ComponentProps< + typeof BookingFlowContextProvider +>["data"] +type BookingFlowUser = BookingFlowContextData["user"] + +function useBookingFlowUser(): BookingFlowUser { + const { data: session } = useSession() + const hasValidSession = isValidClientSession(session) + + const { + data: euroBonusProfile, + isError, + isLoading, + } = trpc.partner.sas.getEuroBonusProfile.useQuery(undefined, { + enabled: hasValidSession, + }) + + if (isLoading) { + return { state: "loading", data: undefined } + } + + if (isError || !euroBonusProfile) { + return { state: "error", data: undefined } + } + + return { + state: "loaded", + data: { + type: "partner-sas", + partnerLoyaltyNumber: `EB${euroBonusProfile.eurobonusNumber}`, + firstName: euroBonusProfile.firstName || null, + lastName: euroBonusProfile.lastName || null, + email: euroBonusProfile.email, + }, + } +} + +function isValidClientSession(session: Session | null) { + if (!session) { + return false + } + if (session.error) { + logger.error(`Session error: ${session.error}`) + return false + } + + if (session.token.error) { + logger.error(`Session token error: ${session.token.error}`) + return false + } + if (session.token.expires_at && session.token.expires_at < Date.now()) { + logger.error(`Session expired: ${session.token.expires_at}`) + return false + } + + return true +} diff --git a/apps/partner-sas/hooks/useIsUserLoggedIn.ts b/apps/partner-sas/hooks/useIsUserLoggedIn.ts index 398c2e6da..21a386b63 100644 --- a/apps/partner-sas/hooks/useIsUserLoggedIn.ts +++ b/apps/partner-sas/hooks/useIsUserLoggedIn.ts @@ -1,32 +1,6 @@ -import { useSession } from "next-auth/react" - -import { logger } from "@scandic-hotels/common/logger" - -import type { Session } from "next-auth" +import { useBookingFlowContext } from "@scandic-hotels/booking-flow/hooks/useBookingFlowContext" export function useIsUserLoggedIn() { - const { data: session } = useSession() - const isUserLoggedIn = isValidClientSession(session) - return isUserLoggedIn -} - -function isValidClientSession(session: Session | null) { - if (!session) { - return false - } - if (session.error) { - logger.error(`Session error: ${session.error}`) - return false - } - - if (session.token.error) { - logger.error(`Session token error: ${session.token.error}`) - return false - } - if (session.token.expires_at && session.token.expires_at < Date.now()) { - logger.error(`Session expired: ${session.token.expires_at}`) - return false - } - - return true + const { isLoggedIn } = useBookingFlowContext() + return isLoggedIn } diff --git a/apps/partner-sas/lib/trpc/index.ts b/apps/partner-sas/lib/trpc/index.ts index 43570b570..4e99398f0 100644 --- a/apps/partner-sas/lib/trpc/index.ts +++ b/apps/partner-sas/lib/trpc/index.ts @@ -14,6 +14,7 @@ export async function createAppContext() { const headersList = await headers() const ctx = createContext({ + app: "partner-sas", lang: headersList.get("x-lang") as Lang, pathname: headersList.get("x-pathname")!, uid: headersList.get("x-uid"), diff --git a/apps/scandic-web/components/BookingFlowProviders.tsx b/apps/scandic-web/components/BookingFlowProviders.tsx index 1ce800214..370a72821 100644 --- a/apps/scandic-web/components/BookingFlowProviders.tsx +++ b/apps/scandic-web/components/BookingFlowProviders.tsx @@ -1,17 +1,50 @@ "use client" import { BookingFlowContextProvider } from "@scandic-hotels/booking-flow/BookingFlowContextProvider" +import { trpc } from "@scandic-hotels/trpc/client" import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn" -import type { ReactNode } from "react" +import type { ComponentProps, ReactNode } from "react" export function BookingFlowProviders({ children }: { children: ReactNode }) { const isLoggedIn = useIsUserLoggedIn() + const user = useBookingFlowUser() return ( - + {children} ) } + +type BookingFlowContextData = ComponentProps< + typeof BookingFlowContextProvider +>["data"] +type BookingFlowUser = BookingFlowContextData["user"] + +function useBookingFlowUser(): BookingFlowUser { + const isLoggedIn = useIsUserLoggedIn() + const { data, isError, isLoading } = trpc.user.getSafely.useQuery(undefined, { + enabled: isLoggedIn, + }) + + if (isLoading) { + return { state: "loading", data: undefined } + } + if (isError || !data) { + return { state: "error", data: undefined } + } + + return { + state: "loaded", + data: { + type: "scandic", + partnerLoyaltyNumber: null, + membershipNumber: data.membershipNumber, + firstName: data.firstName || null, + lastName: data.lastName || null, + email: data.email, + }, + } +} diff --git a/apps/scandic-web/lib/trpc/server.ts b/apps/scandic-web/lib/trpc/server.ts index 77e819f34..c6ad2d137 100644 --- a/apps/scandic-web/lib/trpc/server.ts +++ b/apps/scandic-web/lib/trpc/server.ts @@ -24,6 +24,7 @@ export async function createAppContext() { const loginType = headersList.get("loginType") const ctx = createContext({ + app: "scandic-web", lang: headersList.get("x-lang") as Lang, pathname: headersList.get("x-pathname")!, uid: headersList.get("x-uid"), diff --git a/packages/booking-flow/lib/bookingFlowContext.tsx b/packages/booking-flow/lib/bookingFlowContext.tsx index fdb3576a7..21d92999d 100644 --- a/packages/booking-flow/lib/bookingFlowContext.tsx +++ b/packages/booking-flow/lib/bookingFlowContext.tsx @@ -1,23 +1,35 @@ "use client" -import { createContext, useContext } from "react" +import { createContext } from "react" + +type BaseUser = { + firstName: string | null + lastName: string | null + email: string +} + +export type BookingFlowUser = + | (BaseUser & { + type: "partner-sas" + partnerLoyaltyNumber: `EB${string}` + }) + | (BaseUser & { + type: "scandic" + /** + * This will always be null for Scandic Friends members + */ + partnerLoyaltyNumber: null + membershipNumber: string + }) export type BookingFlowContextData = { isLoggedIn: boolean + user: + | { state: "loading"; data: undefined } + | { state: "error"; data: undefined } + | { state: "loaded"; data: BookingFlowUser | undefined } } export const BookingFlowContext = createContext< BookingFlowContextData | undefined >(undefined) - -export const useBookingFlowContext = (): BookingFlowContextData => { - const context = useContext(BookingFlowContext) - - if (!context) { - throw new Error( - "useBookingFlowContext must be used within a BookingFlowContextProvider. Did you forget to use the provider in the consuming app?" - ) - } - - return context -} diff --git a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx index a9c25e88c..58f9d3572 100644 --- a/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx +++ b/packages/booking-flow/lib/components/EnterDetails/Payment/PaymentClient.tsx @@ -38,6 +38,7 @@ import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { env } from "../../../../env/client" import { useAvailablePaymentOptions } from "../../../hooks/useAvailablePaymentOptions" +import { useBookingFlowContext } from "../../../hooks/useBookingFlowContext" import { useHandleBookingStatus } from "../../../hooks/useHandleBookingStatus" import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn" import useLang from "../../../hooks/useLang" @@ -59,6 +60,7 @@ import TermsAndConditions from "./TermsAndConditions" import styles from "./payment.module.css" +import type { CreateBookingInput } from "@scandic-hotels/trpc/routers/booking/mutation/create/schema" import type { CreditCard } from "@scandic-hotels/trpc/types/user" import type { PriceChangeData } from "../PriceChangeData" @@ -83,6 +85,7 @@ export default function PaymentClient({ const searchParams = useSearchParams() const isUserLoggedIn = useIsLoggedIn() const { getTopOffset } = useStickyPosition({}) + const { user } = useBookingFlowContext() const [showBookingAlert, setShowBookingAlert] = useState(false) @@ -397,39 +400,33 @@ export default function PaymentClient({ } writePaymentInfoToSessionStorage(paymentMethodType, !!savedCreditCard) - const payload = { + const payload: CreateBookingInput = { checkInDate: fromDate, checkOutDate: toDate, hotelId, language: lang, payment, - rooms: rooms.map(({ room }, idx) => { - const isMainRoom = idx === 0 - let rateCode = "" - if (isMainRoom && isUserLoggedIn) { - rateCode = booking.rooms[idx].rateCode - } else if ( - (room.guest.join || room.guest.membershipNo) && - booking.rooms[idx].counterRateCode - ) { - rateCode = booking.rooms[idx].counterRateCode - } else { - rateCode = booking.rooms[idx].rateCode - } + rooms: rooms.map( + ({ room }, idx): CreateBookingInput["rooms"][number] => { + const isMainRoom = idx === 0 + let rateCode = "" + if (isMainRoom && isUserLoggedIn) { + rateCode = booking.rooms[idx].rateCode + } else if ( + (room.guest.join || room.guest.membershipNo) && + booking.rooms[idx].counterRateCode + ) { + rateCode = booking.rooms[idx].counterRateCode + } else { + rateCode = booking.rooms[idx].rateCode + } - const phoneNumber = formatPhoneNumber( - room.guest.phoneNumber, - room.guest.phoneNumberCC - ) + const phoneNumber = formatPhoneNumber( + room.guest.phoneNumber, + room.guest.phoneNumberCC + ) - return { - adults: room.adults, - bookingCode: room.roomRate.bookingCode, - childrenAges: room.childrenInRoom?.map((child) => ({ - age: child.age, - bedType: bedTypeMap[parseInt(child.bed.toString())], - })), - guest: { + const guest: CreateBookingInput["rooms"][number]["guest"] = { becomeMember: room.guest.join, countryCode: room.guest.countryCode, email: room.guest.email, @@ -437,19 +434,24 @@ export default function PaymentClient({ lastName: room.guest.lastName, membershipNumber: room.guest.membershipNo, phoneNumber, - // Only allowed for room one - ...(idx === 0 && { - dateOfBirth: - "dateOfBirth" in room.guest && room.guest.dateOfBirth - ? room.guest.dateOfBirth - : undefined, - postalCode: - "zipCode" in room.guest && room.guest.zipCode - ? room.guest.zipCode - : undefined, - }), - }, - packages: { + partnerLoyaltyNumber: null, + } + + if (isMainRoom) { + // Only valid for main room + guest.partnerLoyaltyNumber = + user?.data?.partnerLoyaltyNumber || null + guest.dateOfBirth = + "dateOfBirth" in room.guest && room.guest.dateOfBirth + ? room.guest.dateOfBirth + : undefined + guest.postalCode = + "zipCode" in room.guest && room.guest.zipCode + ? room.guest.zipCode + : undefined + } + + const packages: CreateBookingInput["rooms"][number]["packages"] = { accessibility: room.roomFeatures?.some( (feature) => @@ -464,47 +466,59 @@ export default function PaymentClient({ room.roomFeatures?.some( (feature) => feature.code === RoomPackageCodeEnum.PET_ROOM ) ?? false, - }, - rateCode, - roomPrice: { - memberPrice: - "member" in room.roomRate - ? room.roomRate.member?.localPrice.pricePerStay + } + + return { + adults: room.adults, + bookingCode: room.roomRate.bookingCode, + childrenAges: room.childrenInRoom?.map((child) => ({ + age: child.age, + bedType: bedTypeMap[parseInt(child.bed.toString())], + })), + guest, + packages, + rateCode, + roomPrice: { + memberPrice: + "member" in room.roomRate + ? room.roomRate.member?.localPrice.pricePerStay + : undefined, + publicPrice: + "public" in room.roomRate + ? room.roomRate.public?.localPrice.pricePerStay + : undefined, + }, + roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step. + smsConfirmationRequested: data.smsConfirmation, + specialRequest: { + comment: room.specialRequest.comment + ? room.specialRequest.comment : undefined, - publicPrice: - "public" in room.roomRate - ? room.roomRate.public?.localPrice.pricePerStay - : undefined, - }, - roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step. - smsConfirmationRequested: data.smsConfirmation, - specialRequest: { - comment: room.specialRequest.comment - ? room.specialRequest.comment - : undefined, - }, + }, + } } - }), + ), } initiateBooking.mutate(payload) }, [ + setIsSubmitting, + preSubmitCallbacks, + rooms, + getPaymentMethod, savedCreditCards, lang, - initiateBooking, - hotelId, + bookingMustBeGuaranteed, + hasOnlyFlexRates, fromDate, toDate, - rooms, - booking.rooms, - getPaymentMethod, - hasOnlyFlexRates, - bookingMustBeGuaranteed, - preSubmitCallbacks, - isUserLoggedIn, + hotelId, + initiateBooking, getTopOffset, - setIsSubmitting, + isUserLoggedIn, + booking.rooms, + user?.data?.partnerLoyaltyNumber, ] ) diff --git a/packages/booking-flow/lib/hooks/useBookingFlowContext.ts b/packages/booking-flow/lib/hooks/useBookingFlowContext.ts new file mode 100644 index 000000000..7683add30 --- /dev/null +++ b/packages/booking-flow/lib/hooks/useBookingFlowContext.ts @@ -0,0 +1,18 @@ +import { useContext } from "react" + +import { + BookingFlowContext, + type BookingFlowContextData, +} from "../bookingFlowContext" + +export const useBookingFlowContext = (): BookingFlowContextData => { + const context = useContext(BookingFlowContext) + + if (!context) { + throw new Error( + "useBookingFlowContext must be used within a BookingFlowContextProvider. Did you forget to use the provider in the consuming app?" + ) + } + + return context +} diff --git a/packages/booking-flow/lib/hooks/useIsLoggedIn.ts b/packages/booking-flow/lib/hooks/useIsLoggedIn.ts index 81cdebb6c..b8b083d50 100644 --- a/packages/booking-flow/lib/hooks/useIsLoggedIn.ts +++ b/packages/booking-flow/lib/hooks/useIsLoggedIn.ts @@ -1,4 +1,4 @@ -import { useBookingFlowContext } from "../bookingFlowContext" +import { useBookingFlowContext } from "./useBookingFlowContext" export function useIsLoggedIn() { const data = useBookingFlowContext() diff --git a/packages/booking-flow/package.json b/packages/booking-flow/package.json index 78cca3d0e..d611656e4 100644 --- a/packages/booking-flow/package.json +++ b/packages/booking-flow/package.json @@ -29,6 +29,7 @@ "./global.d.ts": "./global.d.ts", "./hooks/useHandleBookingStatus": "./lib/hooks/useHandleBookingStatus.ts", "./hooks/useBookingWidgetState": "./lib/hooks/useBookingWidgetState.ts", + "./hooks/useBookingFlowContext": "./lib/hooks/useBookingFlowContext.ts", "./pages/*": "./lib/pages/*.tsx", "./stores/enter-details/types": "./lib/stores/enter-details/types.ts", "./stores/hotels-map": "./lib/stores/hotels-map.ts", diff --git a/packages/trpc/lib/context.ts b/packages/trpc/lib/context.ts index 05f4b31bf..75205e159 100644 --- a/packages/trpc/lib/context.ts +++ b/packages/trpc/lib/context.ts @@ -17,6 +17,7 @@ type CreateContextOptions = { url: string webToken?: string contentType?: string + app: "scandic-web" | "partner-sas" } export function createContext(opts: CreateContextOptions) { diff --git a/packages/trpc/lib/routers/booking/input.ts b/packages/trpc/lib/routers/booking/input.ts index 42e291290..7cca42f3e 100644 --- a/packages/trpc/lib/routers/booking/input.ts +++ b/packages/trpc/lib/routers/booking/input.ts @@ -3,106 +3,6 @@ import { z } from "zod" import { Lang } from "@scandic-hotels/common/constants/language" import { langToApiLang } from "../../constants/apiLang" -import { ChildBedTypeEnum } from "../../enums/childBedTypeEnum" - -const roomsSchema = z - .array( - z.object({ - adults: z.number().int().nonnegative(), - bookingCode: z.string().nullish(), - childrenAges: z - .array( - z.object({ - age: z.number().int().nonnegative(), - bedType: z.nativeEnum(ChildBedTypeEnum), - }) - ) - .default([]), - rateCode: z.string(), - redemptionCode: z.string().optional(), - roomTypeCode: z.coerce.string(), - guest: z.object({ - becomeMember: z.boolean(), - countryCode: z.string(), - dateOfBirth: z.string().nullish(), - email: z.string().email(), - firstName: z.string(), - lastName: z.string(), - membershipNumber: z.string().nullish(), - postalCode: z.string().nullish(), - phoneNumber: z.string(), - }), - smsConfirmationRequested: z.boolean(), - specialRequest: z.object({ - comment: z.string().optional(), - }), - packages: z.object({ - breakfast: z.boolean(), - allergyFriendly: z.boolean(), - petFriendly: z.boolean(), - accessibility: z.boolean(), - }), - roomPrice: z.object({ - memberPrice: z.number().nullish(), - publicPrice: z.number().nullish(), - }), - }) - ) - .superRefine((data, ctx) => { - data.forEach((room, idx) => { - if (idx === 0 && room.guest.becomeMember) { - if (!room.guest.dateOfBirth) { - ctx.addIssue({ - code: z.ZodIssueCode.invalid_type, - expected: "string", - received: typeof room.guest.dateOfBirth, - path: ["guest", "dateOfBirth"], - }) - } - - if (!room.guest.postalCode) { - ctx.addIssue({ - code: z.ZodIssueCode.invalid_type, - expected: "string", - received: typeof room.guest.postalCode, - path: ["guest", "postalCode"], - }) - } - } - }) - }) - -const paymentSchema = z.object({ - paymentMethod: z.string(), - card: z - .object({ - alias: z.string(), - expiryDate: z.string(), - cardType: z.string(), - }) - .optional(), - cardHolder: z - .object({ - email: z.string().email(), - name: z.string(), - phoneCountryCode: z.string(), - phoneSubscriber: z.string(), - }) - .optional(), - success: z.string(), - error: z.string(), - cancel: z.string(), -}) - -// Mutation -export const createBookingInput = z.object({ - hotelId: z.string(), - checkInDate: z.string(), - checkOutDate: z.string(), - rooms: roomsSchema, - payment: paymentSchema.optional(), - language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), -}) export const addPackageInput = z.object({ ancillaryComment: z.string(), diff --git a/packages/trpc/lib/routers/booking/mutation/create/index.ts b/packages/trpc/lib/routers/booking/mutation/create/index.ts new file mode 100644 index 000000000..a94562bfd --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/create/index.ts @@ -0,0 +1,83 @@ +import "server-only" + +import { createCounter } from "@scandic-hotels/common/telemetry" + +import * as api from "../../../../api" +import { safeProtectedServiceProcedure } from "../../../../procedures" +import { encrypt } from "../../../../utils/encryption" +import { isValidSession } from "../../../../utils/session" +import { getMembershipNumber } from "../../../user/utils" +import { createBookingInput, createBookingSchema } from "./schema" + +export const create = safeProtectedServiceProcedure + .input(createBookingInput) + .use(async ({ ctx, next }) => { + const token = isValidSession(ctx.session) + ? ctx.session.token.access_token + : ctx.serviceToken + + return next({ + ctx: { + token, + }, + }) + }) + .mutation(async function ({ ctx, input }) { + const { language, ...inputWithoutLang } = input + const { rooms, ...loggableInput } = inputWithoutLang + + const createBookingCounter = createCounter("trpc.booking", "create") + const metricsCreateBooking = createBookingCounter.init({ + membershipNumber: await getMembershipNumber(ctx.session), + language, + ...loggableInput, + rooms: inputWithoutLang.rooms.map(({ guest, ...room }) => { + const { becomeMember, membershipNumber } = guest + return { ...room, guest: { becomeMember, membershipNumber } } + }), + }) + + metricsCreateBooking.start() + + const headers = { + Authorization: `Bearer ${ctx.token}`, + } + + const apiResponse = await api.post( + api.endpoints.v1.Booking.bookings, + { + headers, + body: inputWithoutLang, + }, + { language } + ) + + if (!apiResponse.ok) { + await metricsCreateBooking.httpError(apiResponse) + + const apiJson = await apiResponse.json() + if ("errors" in apiJson && apiJson.errors.length) { + const error = apiJson.errors[0] + return { error: true, cause: error.code } as const + } + + return null + } + + const apiJson = await apiResponse.json() + + const verifiedData = createBookingSchema.safeParse(apiJson) + if (!verifiedData.success) { + metricsCreateBooking.validationError(verifiedData.error) + return null + } + + metricsCreateBooking.success() + + const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry + + return { + booking: verifiedData.data, + sig: encrypt(expire.toString()), + } + }) diff --git a/packages/trpc/lib/routers/booking/mutation/create/schema.ts b/packages/trpc/lib/routers/booking/mutation/create/schema.ts new file mode 100644 index 000000000..a8377e6a4 --- /dev/null +++ b/packages/trpc/lib/routers/booking/mutation/create/schema.ts @@ -0,0 +1,173 @@ +import { z } from "zod" + +import { Lang } from "@scandic-hotels/common/constants/language" + +import { langToApiLang } from "../../../../constants/apiLang" +import { ChildBedTypeEnum } from "../../../../enums/childBedTypeEnum" +import { calculateRefId } from "../../../../utils/refId" +import { guestSchema } from "../../output" + +const roomsSchema = z + .array( + z.object({ + adults: z.number().int().nonnegative(), + bookingCode: z.string().nullish(), + childrenAges: z + .array( + z.object({ + age: z.number().int().nonnegative(), + bedType: z.nativeEnum(ChildBedTypeEnum), + }) + ) + .default([]), + rateCode: z.string(), + redemptionCode: z.string().optional(), + roomTypeCode: z.coerce.string(), + guest: z.object({ + becomeMember: z.boolean(), + countryCode: z.string(), + dateOfBirth: z.string().nullish(), + email: z.string().email(), + firstName: z.string(), + lastName: z.string(), + membershipNumber: z.string().nullish(), + postalCode: z.string().nullish(), + phoneNumber: z.string(), + partnerLoyaltyNumber: z.string().nullable(), + }), + smsConfirmationRequested: z.boolean(), + specialRequest: z.object({ + comment: z.string().optional(), + }), + packages: z.object({ + breakfast: z.boolean(), + allergyFriendly: z.boolean(), + petFriendly: z.boolean(), + accessibility: z.boolean(), + }), + roomPrice: z.object({ + memberPrice: z.number().nullish(), + publicPrice: z.number().nullish(), + }), + }) + ) + .superRefine((data, ctx) => { + data.forEach((room, idx) => { + if (idx === 0 && room.guest.becomeMember) { + if (!room.guest.dateOfBirth) { + ctx.addIssue({ + code: z.ZodIssueCode.invalid_type, + expected: "string", + received: typeof room.guest.dateOfBirth, + path: ["guest", "dateOfBirth"], + }) + } + + if (!room.guest.postalCode) { + ctx.addIssue({ + code: z.ZodIssueCode.invalid_type, + expected: "string", + received: typeof room.guest.postalCode, + path: ["guest", "postalCode"], + }) + } + } + }) + }) + +const paymentSchema = z.object({ + paymentMethod: z.string(), + card: z + .object({ + alias: z.string(), + expiryDate: z.string(), + cardType: z.string(), + }) + .optional(), + cardHolder: z + .object({ + email: z.string().email(), + name: z.string(), + phoneCountryCode: z.string(), + phoneSubscriber: z.string(), + }) + .optional(), + success: z.string(), + error: z.string(), + cancel: z.string(), +}) + +export type CreateBookingInput = z.input +export const createBookingInput = z.object({ + hotelId: z.string(), + checkInDate: z.string(), + checkOutDate: z.string(), + rooms: roomsSchema, + payment: paymentSchema.optional(), + language: z.nativeEnum(Lang).transform((val) => langToApiLang[val]), +}) + +export const createBookingSchema = z + .object({ + data: z.object({ + attributes: z.object({ + reservationStatus: z.string(), + guest: guestSchema.optional(), + paymentUrl: z.string().nullable().optional(), + rooms: z + .array( + z.object({ + confirmationNumber: z.string(), + cancellationNumber: z.string().nullable(), + priceChangedMetadata: z + .object({ + roomPrice: z.number(), + totalPrice: z.number(), + }) + .nullable() + .optional(), + }) + ) + .default([]), + errors: z + .array( + z.object({ + confirmationNumber: z.string().nullable().optional(), + errorCode: z.string(), + description: z.string().nullable().optional(), + meta: z + .record(z.string(), z.union([z.string(), z.number()])) + .nullable() + .optional(), + }) + ) + .default([]), + }), + type: z.string(), + id: z.string(), + links: z.object({ + self: z.object({ + href: z.string().url(), + meta: z.object({ + method: z.string(), + }), + }), + }), + }), + }) + .transform((d) => ({ + id: d.data.id, + links: d.data.links, + type: d.data.type, + reservationStatus: d.data.attributes.reservationStatus, + paymentUrl: d.data.attributes.paymentUrl, + rooms: d.data.attributes.rooms.map((room) => { + const lastName = d.data.attributes.guest?.lastName ?? "" + return { + ...room, + refId: calculateRefId(room.confirmationNumber, lastName), + } + }), + errors: d.data.attributes.errors, + guest: d.data.attributes.guest, + })) diff --git a/packages/trpc/lib/routers/booking/mutation.ts b/packages/trpc/lib/routers/booking/mutation/index.ts similarity index 76% rename from packages/trpc/lib/routers/booking/mutation.ts rename to packages/trpc/lib/routers/booking/mutation/index.ts index e5b84e8ba..f9ecd4563 100644 --- a/packages/trpc/lib/routers/booking/mutation.ts +++ b/packages/trpc/lib/routers/booking/mutation/index.ts @@ -1,100 +1,28 @@ import { createLogger } from "@scandic-hotels/common/logger/createLogger" import { createCounter } from "@scandic-hotels/common/telemetry" -import { router } from "../.." -import * as api from "../../api" -import { createRefIdPlugin } from "../../plugins/refIdToConfirmationNumber" -import { safeProtectedServiceProcedure } from "../../procedures" -import { encrypt } from "../../utils/encryption" -import { isValidSession } from "../../utils/session" -import { getMembershipNumber } from "../user/utils/getMemberShipNumber" +import { router } from "../../.." +import * as api from "../../../api" +import { createRefIdPlugin } from "../../../plugins/refIdToConfirmationNumber" +import { safeProtectedServiceProcedure } from "../../../procedures" +import { isValidSession } from "../../../utils/session" import { addPackageInput, cancelBookingsInput, - createBookingInput, guaranteeBookingInput, removePackageInput, updateBookingInput, -} from "./input" -import { bookingConfirmationSchema, createBookingSchema } from "./output" -import { cancelBooking } from "./utils" +} from "../input" +import { bookingConfirmationSchema } from "../output" +import { cancelBooking } from "../utils" +import { createBookingSchema } from "./create/schema" +import { create } from "./create" const refIdPlugin = createRefIdPlugin() const bookingLogger = createLogger("trpc.booking") export const bookingMutationRouter = router({ - create: safeProtectedServiceProcedure - .input(createBookingInput) - .use(async ({ ctx, next }) => { - const token = isValidSession(ctx.session) - ? ctx.session.token.access_token - : ctx.serviceToken - - return next({ - ctx: { - token, - }, - }) - }) - .mutation(async function ({ ctx, input }) { - const { language, ...inputWithoutLang } = input - const { rooms, ...loggableInput } = inputWithoutLang - - const createBookingCounter = createCounter("trpc.booking", "create") - const metricsCreateBooking = createBookingCounter.init({ - membershipNumber: await getMembershipNumber(ctx.session), - language, - ...loggableInput, - rooms: inputWithoutLang.rooms.map(({ guest, ...room }) => { - const { becomeMember, membershipNumber } = guest - return { ...room, guest: { becomeMember, membershipNumber } } - }), - }) - - metricsCreateBooking.start() - - const headers = { - Authorization: `Bearer ${ctx.token}`, - } - - const apiResponse = await api.post( - api.endpoints.v1.Booking.bookings, - { - headers, - body: inputWithoutLang, - }, - { language } - ) - - if (!apiResponse.ok) { - await metricsCreateBooking.httpError(apiResponse) - - const apiJson = await apiResponse.json() - if ("errors" in apiJson && apiJson.errors.length) { - const error = apiJson.errors[0] - return { error: true, cause: error.code } as const - } - - return null - } - - const apiJson = await apiResponse.json() - - const verifiedData = createBookingSchema.safeParse(apiJson) - if (!verifiedData.success) { - metricsCreateBooking.validationError(verifiedData.error) - return null - } - - metricsCreateBooking.success() - - const expire = Math.floor(Date.now() / 1000) + 60 // 1 minute expiry - - return { - booking: verifiedData.data, - sig: encrypt(expire.toString()), - } - }), + create, priceChange: safeProtectedServiceProcedure .concat(refIdPlugin.toConfirmationNumber) .use(async ({ ctx, next }) => { diff --git a/packages/trpc/lib/routers/booking/output.ts b/packages/trpc/lib/routers/booking/output.ts index 70fc5aa49..bb7c52131 100644 --- a/packages/trpc/lib/routers/booking/output.ts +++ b/packages/trpc/lib/routers/booking/output.ts @@ -13,7 +13,7 @@ import { BreakfastPackageEnum } from "../../enums/breakfast" import { ChildBedTypeEnum } from "../../enums/childBedTypeEnum" import { calculateRefId } from "../../utils/refId" -const guestSchema = z.object({ +export const guestSchema = z.object({ email: nullableStringEmailValidator, firstName: nullableStringValidator, lastName: nullableStringValidator, @@ -24,72 +24,6 @@ const guestSchema = z.object({ export type Guest = z.output -// MUTATION -export const createBookingSchema = z - .object({ - data: z.object({ - attributes: z.object({ - reservationStatus: z.string(), - guest: guestSchema.optional(), - paymentUrl: z.string().nullable().optional(), - rooms: z - .array( - z.object({ - confirmationNumber: z.string(), - cancellationNumber: z.string().nullable(), - priceChangedMetadata: z - .object({ - roomPrice: z.number(), - totalPrice: z.number(), - }) - .nullable() - .optional(), - }) - ) - .default([]), - errors: z - .array( - z.object({ - confirmationNumber: z.string().nullable().optional(), - errorCode: z.string(), - description: z.string().nullable().optional(), - meta: z - .record(z.string(), z.union([z.string(), z.number()])) - .nullable() - .optional(), - }) - ) - .default([]), - }), - type: z.string(), - id: z.string(), - links: z.object({ - self: z.object({ - href: z.string().url(), - meta: z.object({ - method: z.string(), - }), - }), - }), - }), - }) - .transform((d) => ({ - id: d.data.id, - links: d.data.links, - type: d.data.type, - reservationStatus: d.data.attributes.reservationStatus, - paymentUrl: d.data.attributes.paymentUrl, - rooms: d.data.attributes.rooms.map((room) => { - const lastName = d.data.attributes.guest?.lastName ?? "" - return { - ...room, - refId: calculateRefId(room.confirmationNumber, lastName), - } - }), - errors: d.data.attributes.errors, - guest: d.data.attributes.guest, - })) - // QUERY const childBedPreferencesSchema = z.object({ bedType: z.nativeEnum(ChildBedTypeEnum), diff --git a/packages/trpc/lib/routers/booking/query.ts b/packages/trpc/lib/routers/booking/query.ts index 111290111..4ca1d17a7 100644 --- a/packages/trpc/lib/routers/booking/query.ts +++ b/packages/trpc/lib/routers/booking/query.ts @@ -12,6 +12,7 @@ import { toApiLang } from "../../utils" import { encrypt } from "../../utils/encryption" import { isValidSession } from "../../utils/session" import { getHotel } from "../hotels/services/getHotel" +import { createBookingSchema } from "./mutation/create/schema" import { getHotelRoom } from "./helpers" import { createRefIdInput, @@ -20,7 +21,6 @@ import { getBookingStatusInput, getLinkedReservationsInput, } from "./input" -import { createBookingSchema } from "./output" import { findBooking, getBooking } from "./utils" const refIdPlugin = createRefIdPlugin() diff --git a/packages/trpc/lib/routers/booking/utils.ts b/packages/trpc/lib/routers/booking/utils.ts index 7c6b4a5d5..3b159309c 100644 --- a/packages/trpc/lib/routers/booking/utils.ts +++ b/packages/trpc/lib/routers/booking/utils.ts @@ -3,7 +3,8 @@ import { createCounter } from "@scandic-hotels/common/telemetry" import * as api from "../../api" import { badRequestError, serverErrorByStatus } from "../../errors" import { toApiLang } from "../../utils" -import { bookingConfirmationSchema, createBookingSchema } from "./output" +import { createBookingSchema } from "./mutation/create/schema" +import { bookingConfirmationSchema } from "./output" import type { Lang } from "@scandic-hotels/common/constants/language"